@bookedsolid/rea 0.4.0 → 0.6.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.
@@ -141,11 +141,22 @@ export async function appendAuditRecord(baseDir, input) {
141
141
  .then(async () => {
142
142
  record = await doAppend(resolvedBase, input);
143
143
  });
144
- writeQueues.set(key, next.finally(() => {
144
+ writeQueues.set(key, next
145
+ .finally(() => {
145
146
  // Keep the queue lean — once this write resolves, drop the reference
146
147
  // if nothing newer is chained behind it.
147
148
  if (writeQueues.get(key) === next)
148
149
  writeQueues.delete(key);
150
+ })
151
+ // Swallow rejections on the stored promise so Node doesn't flag it as
152
+ // an unhandled rejection. The current caller already owns this error
153
+ // via the `await next` below; the NEXT caller that chains off this
154
+ // entry also .catch()-es it at the top of its chain. Without this
155
+ // terminal .catch(), a failed audit append surfaces as a spurious
156
+ // `unhandledRejection` event — which matters in tests that run the
157
+ // whole process and in long-lived servers that would log it.
158
+ .catch(() => {
159
+ /* handled by caller */
149
160
  }));
150
161
  await next;
151
162
  return record;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Review cache (BUG-009). The push-review-gate hook (`hooks/push-review-gate.sh`)
3
+ * has shipped since 0.3.x calling `rea cache check <sha>` to skip re-review on
4
+ * a previously-approved diff, and `rea cache set <sha> pass ...` as the
5
+ * operator's advertised way to complete the gate. Neither subcommand existed
6
+ * in the CLI through 0.4.0. Once BUG-008's pre-push stdin adapter lands and
7
+ * the gate actually fires, a protected-path push has no completion path
8
+ * without this cache — hence "paired ship blocker."
9
+ *
10
+ * ## File layout
11
+ *
12
+ * `.rea/review-cache.jsonl` — one JSON object per line, terminated with `\n`.
13
+ * Each entry:
14
+ *
15
+ * {
16
+ * "sha": "<diff-sha256>",
17
+ * "branch": "<feature-branch>",
18
+ * "base": "<target-branch>",
19
+ * "result": "pass" | "fail",
20
+ * "recorded_at": "<ISO-8601>",
21
+ * "reason"?: "<free text>" // optional, populated on fail or on skip
22
+ * }
23
+ *
24
+ * The `sha` is whatever the caller supplies — the hook happens to use a
25
+ * SHA-256 of the full diff, but the cache does not interpret or validate the
26
+ * value. Hash-chained is intentionally NOT required: this is a keyed cache,
27
+ * not an append-only integrity log. The audit log at `.rea/audit.jsonl`
28
+ * remains the integrity story.
29
+ *
30
+ * ## Concurrency
31
+ *
32
+ * Every write takes the same `proper-lockfile` lock on the `.rea/` parent
33
+ * directory that the audit helpers use (`withAuditLock`). This means a
34
+ * concurrent audit append and cache write serialize against each other — a
35
+ * negligible cost given cache writes happen once per push gate completion.
36
+ *
37
+ * ## Idempotency
38
+ *
39
+ * `appendEntry` writes a new line unconditionally. `lookup` returns the most
40
+ * recent entry matching `(sha, branch, base)`. This "last write wins" keeps
41
+ * the write path O(1) and the read path O(n) over the file; n is bounded by
42
+ * typical review frequency (dozens per week, not millions). If a future
43
+ * operator needs a compact file, `rea cache clear <sha>` drops matching
44
+ * entries and a separate `rea cache compact` (not in 0.5.0) could rewrite.
45
+ *
46
+ * ## TTL
47
+ *
48
+ * `lookup` honors `review.cache_max_age_seconds` (default 3600). Entries
49
+ * older than the window are treated as a miss. Expired entries are not
50
+ * garbage-collected on read — `rea cache clear` or `rea cache compact`
51
+ * is the operator tool for shrinking.
52
+ */
53
+ /** Default TTL when policy does not supply one. */
54
+ export declare const DEFAULT_CACHE_MAX_AGE_SECONDS = 3600;
55
+ export type CacheResult = 'pass' | 'fail';
56
+ export interface CacheEntry {
57
+ sha: string;
58
+ branch: string;
59
+ base: string;
60
+ result: CacheResult;
61
+ recorded_at: string;
62
+ reason?: string;
63
+ }
64
+ export interface CacheLookupInput {
65
+ sha: string;
66
+ branch: string;
67
+ base: string;
68
+ /** Epoch ms used as the "now" reference for TTL comparison. Defaults to `Date.now()`. */
69
+ nowMs?: number;
70
+ /** TTL in seconds; defaults to {@link DEFAULT_CACHE_MAX_AGE_SECONDS}. */
71
+ maxAgeSeconds?: number;
72
+ }
73
+ export interface CacheLookupResult {
74
+ hit: boolean;
75
+ entry?: CacheEntry;
76
+ /** Reason for a miss. One of `'no-entry' | 'expired' | 'empty-file'`. Always set when `hit === false`. */
77
+ missReason?: 'no-entry' | 'expired' | 'empty-file';
78
+ }
79
+ export interface CacheAppendInput {
80
+ sha: string;
81
+ branch: string;
82
+ base: string;
83
+ result: CacheResult;
84
+ reason?: string;
85
+ /** ISO-8601 timestamp. Defaults to `new Date().toISOString()`. */
86
+ timestamp?: string;
87
+ }
88
+ export declare function resolveCacheFile(baseDir: string): string;
89
+ /**
90
+ * Append an entry to the cache. Writes are serialized through the shared
91
+ * `.rea/` directory lock so audit writes and cache writes do not interleave.
92
+ */
93
+ export declare function appendEntry(baseDir: string, input: CacheAppendInput): Promise<CacheEntry>;
94
+ /**
95
+ * Find the most-recent entry matching `(sha, branch, base)` within the TTL
96
+ * window. Idempotent and side-effect free.
97
+ */
98
+ export declare function lookup(baseDir: string, input: CacheLookupInput): Promise<CacheLookupResult>;
99
+ /**
100
+ * Remove every entry matching `sha`. Returns the count removed. A `0` return
101
+ * is a valid outcome (sha not present). Writes back via the same lock as
102
+ * `appendEntry`, so concurrent sets do not lose entries.
103
+ *
104
+ * Writes use temp-file + `fs.rename` (atomic within a single directory on
105
+ * POSIX) so unlocked readers (`lookup`, `list`) can never observe a torn or
106
+ * empty intermediate state. Codex F4 on the 0.5.0 PR1 review.
107
+ */
108
+ export declare function clear(baseDir: string, sha: string): Promise<number>;
109
+ /**
110
+ * Return every entry, optionally filtered by branch. Entries are returned in
111
+ * file order (oldest first). Callers that want "newest first" should reverse.
112
+ */
113
+ export declare function list(baseDir: string, options?: {
114
+ branch?: string;
115
+ }): Promise<CacheEntry[]>;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Review cache (BUG-009). The push-review-gate hook (`hooks/push-review-gate.sh`)
3
+ * has shipped since 0.3.x calling `rea cache check <sha>` to skip re-review on
4
+ * a previously-approved diff, and `rea cache set <sha> pass ...` as the
5
+ * operator's advertised way to complete the gate. Neither subcommand existed
6
+ * in the CLI through 0.4.0. Once BUG-008's pre-push stdin adapter lands and
7
+ * the gate actually fires, a protected-path push has no completion path
8
+ * without this cache — hence "paired ship blocker."
9
+ *
10
+ * ## File layout
11
+ *
12
+ * `.rea/review-cache.jsonl` — one JSON object per line, terminated with `\n`.
13
+ * Each entry:
14
+ *
15
+ * {
16
+ * "sha": "<diff-sha256>",
17
+ * "branch": "<feature-branch>",
18
+ * "base": "<target-branch>",
19
+ * "result": "pass" | "fail",
20
+ * "recorded_at": "<ISO-8601>",
21
+ * "reason"?: "<free text>" // optional, populated on fail or on skip
22
+ * }
23
+ *
24
+ * The `sha` is whatever the caller supplies — the hook happens to use a
25
+ * SHA-256 of the full diff, but the cache does not interpret or validate the
26
+ * value. Hash-chained is intentionally NOT required: this is a keyed cache,
27
+ * not an append-only integrity log. The audit log at `.rea/audit.jsonl`
28
+ * remains the integrity story.
29
+ *
30
+ * ## Concurrency
31
+ *
32
+ * Every write takes the same `proper-lockfile` lock on the `.rea/` parent
33
+ * directory that the audit helpers use (`withAuditLock`). This means a
34
+ * concurrent audit append and cache write serialize against each other — a
35
+ * negligible cost given cache writes happen once per push gate completion.
36
+ *
37
+ * ## Idempotency
38
+ *
39
+ * `appendEntry` writes a new line unconditionally. `lookup` returns the most
40
+ * recent entry matching `(sha, branch, base)`. This "last write wins" keeps
41
+ * the write path O(1) and the read path O(n) over the file; n is bounded by
42
+ * typical review frequency (dozens per week, not millions). If a future
43
+ * operator needs a compact file, `rea cache clear <sha>` drops matching
44
+ * entries and a separate `rea cache compact` (not in 0.5.0) could rewrite.
45
+ *
46
+ * ## TTL
47
+ *
48
+ * `lookup` honors `review.cache_max_age_seconds` (default 3600). Entries
49
+ * older than the window are treated as a miss. Expired entries are not
50
+ * garbage-collected on read — `rea cache clear` or `rea cache compact`
51
+ * is the operator tool for shrinking.
52
+ */
53
+ import fs from 'node:fs/promises';
54
+ import path from 'node:path';
55
+ import { withAuditLock } from '../audit/fs.js';
56
+ /** Default TTL when policy does not supply one. */
57
+ export const DEFAULT_CACHE_MAX_AGE_SECONDS = 3600;
58
+ /**
59
+ * Tolerated clock skew for future-dated entries. A `recorded_at` more than
60
+ * this far in the future relative to `nowMs` is treated as tampered or
61
+ * severely-drifted and forces a miss (re-review). 60s covers NTP jitter on
62
+ * well-synced hosts; anything beyond that is noise we do not trust.
63
+ */
64
+ const FUTURE_SKEW_ALLOWANCE_MS = 60_000;
65
+ const CACHE_FILENAME = 'review-cache.jsonl';
66
+ const REA_DIRNAME = '.rea';
67
+ export function resolveCacheFile(baseDir) {
68
+ return path.join(baseDir, REA_DIRNAME, CACHE_FILENAME);
69
+ }
70
+ /**
71
+ * Load every entry from the cache file. Returns `[]` when the file does not
72
+ * exist or is empty. Malformed lines are skipped — we never throw on a
73
+ * corrupt line, because the cache is advisory and a bad write (e.g. a
74
+ * half-written line from a crashed host) must not block a subsequent push.
75
+ */
76
+ async function loadEntries(cacheFile) {
77
+ let raw;
78
+ try {
79
+ raw = await fs.readFile(cacheFile, 'utf8');
80
+ }
81
+ catch (err) {
82
+ if (err.code === 'ENOENT')
83
+ return [];
84
+ throw err;
85
+ }
86
+ if (raw.length === 0)
87
+ return [];
88
+ const entries = [];
89
+ for (const line of raw.split('\n')) {
90
+ if (line.length === 0)
91
+ continue;
92
+ try {
93
+ const parsed = JSON.parse(line);
94
+ if (typeof parsed.sha === 'string' &&
95
+ typeof parsed.branch === 'string' &&
96
+ typeof parsed.base === 'string' &&
97
+ (parsed.result === 'pass' || parsed.result === 'fail') &&
98
+ typeof parsed.recorded_at === 'string') {
99
+ entries.push(parsed);
100
+ }
101
+ }
102
+ catch {
103
+ // Skip malformed line.
104
+ }
105
+ }
106
+ return entries;
107
+ }
108
+ /**
109
+ * Append an entry to the cache. Writes are serialized through the shared
110
+ * `.rea/` directory lock so audit writes and cache writes do not interleave.
111
+ */
112
+ export async function appendEntry(baseDir, input) {
113
+ const cacheFile = resolveCacheFile(baseDir);
114
+ await fs.mkdir(path.dirname(cacheFile), { recursive: true });
115
+ const entry = {
116
+ sha: input.sha,
117
+ branch: input.branch,
118
+ base: input.base,
119
+ result: input.result,
120
+ recorded_at: input.timestamp ?? new Date().toISOString(),
121
+ ...(input.reason !== undefined && input.reason.length > 0
122
+ ? { reason: input.reason }
123
+ : {}),
124
+ };
125
+ await withAuditLock(cacheFile, async () => {
126
+ const line = JSON.stringify(entry) + '\n';
127
+ await fs.appendFile(cacheFile, line);
128
+ });
129
+ return entry;
130
+ }
131
+ /**
132
+ * Find the most-recent entry matching `(sha, branch, base)` within the TTL
133
+ * window. Idempotent and side-effect free.
134
+ */
135
+ export async function lookup(baseDir, input) {
136
+ const cacheFile = resolveCacheFile(baseDir);
137
+ const entries = await loadEntries(cacheFile);
138
+ if (entries.length === 0)
139
+ return { hit: false, missReason: 'empty-file' };
140
+ // Walk from the tail so the first match is the newest.
141
+ let matched;
142
+ for (let i = entries.length - 1; i >= 0; i--) {
143
+ const e = entries[i];
144
+ if (e.sha === input.sha && e.branch === input.branch && e.base === input.base) {
145
+ matched = e;
146
+ break;
147
+ }
148
+ }
149
+ if (matched === undefined)
150
+ return { hit: false, missReason: 'no-entry' };
151
+ const nowMs = input.nowMs ?? Date.now();
152
+ const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_CACHE_MAX_AGE_SECONDS;
153
+ const recordedMs = Date.parse(matched.recorded_at);
154
+ if (Number.isNaN(recordedMs)) {
155
+ // Corrupt timestamp — treat as an expired miss so the caller re-reviews.
156
+ return { hit: false, missReason: 'expired', entry: matched };
157
+ }
158
+ if (recordedMs > nowMs + FUTURE_SKEW_ALLOWANCE_MS) {
159
+ return { hit: false, missReason: 'expired', entry: matched };
160
+ }
161
+ if ((nowMs - recordedMs) / 1000 > maxAgeSeconds) {
162
+ return { hit: false, missReason: 'expired', entry: matched };
163
+ }
164
+ return { hit: true, entry: matched };
165
+ }
166
+ /**
167
+ * Remove every entry matching `sha`. Returns the count removed. A `0` return
168
+ * is a valid outcome (sha not present). Writes back via the same lock as
169
+ * `appendEntry`, so concurrent sets do not lose entries.
170
+ *
171
+ * Writes use temp-file + `fs.rename` (atomic within a single directory on
172
+ * POSIX) so unlocked readers (`lookup`, `list`) can never observe a torn or
173
+ * empty intermediate state. Codex F4 on the 0.5.0 PR1 review.
174
+ */
175
+ export async function clear(baseDir, sha) {
176
+ const cacheFile = resolveCacheFile(baseDir);
177
+ return withAuditLock(cacheFile, async () => {
178
+ const entries = await loadEntries(cacheFile);
179
+ const kept = entries.filter((e) => e.sha !== sha);
180
+ const removed = entries.length - kept.length;
181
+ if (removed === 0)
182
+ return 0;
183
+ const out = kept.length === 0 ? '' : kept.map((e) => JSON.stringify(e)).join('\n') + '\n';
184
+ const tmpFile = `${cacheFile}.tmp.${process.pid}.${Date.now()}`;
185
+ await fs.writeFile(tmpFile, out);
186
+ await fs.rename(tmpFile, cacheFile);
187
+ return removed;
188
+ });
189
+ }
190
+ /**
191
+ * Return every entry, optionally filtered by branch. Entries are returned in
192
+ * file order (oldest first). Callers that want "newest first" should reverse.
193
+ */
194
+ export async function list(baseDir, options = {}) {
195
+ const cacheFile = resolveCacheFile(baseDir);
196
+ const entries = await loadEntries(cacheFile);
197
+ if (options.branch === undefined)
198
+ return entries;
199
+ return entries.filter((e) => e.branch === options.branch);
200
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `rea cache` — push-review cache operator subcommands (BUG-009).
3
+ *
4
+ * Four verbs:
5
+ * - `check <sha> --branch <b> --base <b>` — JSON to stdout ONLY; never
6
+ * diagnostics. `hooks/push-review-gate.sh` reads this via
7
+ * `printf '%s' "$CACHE_RESULT" | jq -e '.hit == true'`, so any stray
8
+ * text on stdout would poison the hook's JSON parse.
9
+ * - `set <sha> pass|fail --branch <b> --base <b> [--reason <s>]` — record
10
+ * a review outcome.
11
+ * - `clear <sha>` — drop every entry for a sha (dev convenience).
12
+ * - `list [--branch <b>]` — pretty-print entries.
13
+ *
14
+ * The TTL used by `check` reads `review.cache_max_age_seconds` from
15
+ * `.rea/policy.yaml` when present, falling back to
16
+ * {@link DEFAULT_CACHE_MAX_AGE_SECONDS} (1 hour) when the policy file or
17
+ * field is absent. An unreadable/malformed policy file is NOT fatal for
18
+ * `check` — it degrades to the default so a broken policy never deadlocks
19
+ * the push gate; other commands that don't consume the TTL ignore the policy
20
+ * entirely.
21
+ */
22
+ import { type CacheResult } from '../cache/review-cache.js';
23
+ export interface CacheCheckOptions {
24
+ sha: string;
25
+ branch: string;
26
+ base: string;
27
+ }
28
+ export interface CacheSetOptions {
29
+ sha: string;
30
+ result: CacheResult;
31
+ branch: string;
32
+ base: string;
33
+ reason?: string;
34
+ }
35
+ export interface CacheClearOptions {
36
+ sha: string;
37
+ }
38
+ export interface CacheListOptions {
39
+ branch?: string;
40
+ }
41
+ /**
42
+ * Print the cache-check JSON to stdout. Hook contract: stdout is ONLY JSON.
43
+ * On a miss we still exit 0 with `{"hit":false}` — the hook interprets
44
+ * non-zero as "rea broken, force re-review" via its `|| echo '{"hit":false}'`
45
+ * fallback.
46
+ */
47
+ export declare function runCacheCheck(options: CacheCheckOptions): Promise<void>;
48
+ export declare function runCacheSet(options: CacheSetOptions): Promise<void>;
49
+ export declare function runCacheClear(options: CacheClearOptions): Promise<void>;
50
+ export declare function runCacheList(options: CacheListOptions): Promise<void>;
51
+ /** Parse-and-validate helper for `set` — surfaces a clean error on bad input. */
52
+ export declare function parseCacheResult(raw: string): CacheResult;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `rea cache` — push-review cache operator subcommands (BUG-009).
3
+ *
4
+ * Four verbs:
5
+ * - `check <sha> --branch <b> --base <b>` — JSON to stdout ONLY; never
6
+ * diagnostics. `hooks/push-review-gate.sh` reads this via
7
+ * `printf '%s' "$CACHE_RESULT" | jq -e '.hit == true'`, so any stray
8
+ * text on stdout would poison the hook's JSON parse.
9
+ * - `set <sha> pass|fail --branch <b> --base <b> [--reason <s>]` — record
10
+ * a review outcome.
11
+ * - `clear <sha>` — drop every entry for a sha (dev convenience).
12
+ * - `list [--branch <b>]` — pretty-print entries.
13
+ *
14
+ * The TTL used by `check` reads `review.cache_max_age_seconds` from
15
+ * `.rea/policy.yaml` when present, falling back to
16
+ * {@link DEFAULT_CACHE_MAX_AGE_SECONDS} (1 hour) when the policy file or
17
+ * field is absent. An unreadable/malformed policy file is NOT fatal for
18
+ * `check` — it degrades to the default so a broken policy never deadlocks
19
+ * the push gate; other commands that don't consume the TTL ignore the policy
20
+ * entirely.
21
+ */
22
+ import { loadPolicy } from '../policy/loader.js';
23
+ import { DEFAULT_CACHE_MAX_AGE_SECONDS, appendEntry, clear as clearEntries, list as listEntries, lookup, } from '../cache/review-cache.js';
24
+ import { err, log } from './utils.js';
25
+ function resolveMaxAgeSeconds(baseDir) {
26
+ try {
27
+ const policy = loadPolicy(baseDir);
28
+ const configured = policy.review?.cache_max_age_seconds;
29
+ if (typeof configured === 'number' && configured > 0)
30
+ return configured;
31
+ return DEFAULT_CACHE_MAX_AGE_SECONDS;
32
+ }
33
+ catch {
34
+ // Missing or malformed policy must not block the push gate — degrade to
35
+ // the default. `rea doctor` is the canonical surface for flagging a
36
+ // broken policy file; the cache is not the place to re-diagnose it.
37
+ return DEFAULT_CACHE_MAX_AGE_SECONDS;
38
+ }
39
+ }
40
+ /**
41
+ * Print the cache-check JSON to stdout. Hook contract: stdout is ONLY JSON.
42
+ * On a miss we still exit 0 with `{"hit":false}` — the hook interprets
43
+ * non-zero as "rea broken, force re-review" via its `|| echo '{"hit":false}'`
44
+ * fallback.
45
+ */
46
+ export async function runCacheCheck(options) {
47
+ const baseDir = process.cwd();
48
+ const maxAgeSeconds = resolveMaxAgeSeconds(baseDir);
49
+ const result = await lookup(baseDir, {
50
+ sha: options.sha,
51
+ branch: options.branch,
52
+ base: options.base,
53
+ maxAgeSeconds,
54
+ });
55
+ if (result.hit && result.entry !== undefined) {
56
+ const payload = {
57
+ hit: true,
58
+ result: result.entry.result,
59
+ branch: result.entry.branch,
60
+ base: result.entry.base,
61
+ recorded_at: result.entry.recorded_at,
62
+ ...(result.entry.reason !== undefined ? { reason: result.entry.reason } : {}),
63
+ };
64
+ process.stdout.write(JSON.stringify(payload) + '\n');
65
+ return;
66
+ }
67
+ process.stdout.write(JSON.stringify({ hit: false }) + '\n');
68
+ }
69
+ export async function runCacheSet(options) {
70
+ const baseDir = process.cwd();
71
+ const entry = await appendEntry(baseDir, {
72
+ sha: options.sha,
73
+ branch: options.branch,
74
+ base: options.base,
75
+ result: options.result,
76
+ ...(options.reason !== undefined && options.reason.length > 0
77
+ ? { reason: options.reason }
78
+ : {}),
79
+ });
80
+ log(`Recorded ${entry.result} for ${entry.sha.slice(0, 12)} (${entry.branch} → ${entry.base}).`);
81
+ }
82
+ export async function runCacheClear(options) {
83
+ const baseDir = process.cwd();
84
+ const removed = await clearEntries(baseDir, options.sha);
85
+ if (removed === 0) {
86
+ log(`No entries found for ${options.sha.slice(0, 12)}.`);
87
+ return;
88
+ }
89
+ log(`Cleared ${removed} entr${removed === 1 ? 'y' : 'ies'} for ${options.sha.slice(0, 12)}.`);
90
+ }
91
+ export async function runCacheList(options) {
92
+ const baseDir = process.cwd();
93
+ const entries = await listEntries(baseDir, {
94
+ ...(options.branch !== undefined ? { branch: options.branch } : {}),
95
+ });
96
+ if (entries.length === 0) {
97
+ log('No review-cache entries.');
98
+ return;
99
+ }
100
+ for (const e of entries) {
101
+ const shortSha = e.sha.slice(0, 12);
102
+ const reason = e.reason !== undefined ? ` — ${e.reason}` : '';
103
+ console.log(`${e.recorded_at} ${e.result.padEnd(4)} ${shortSha} ${e.branch} → ${e.base}${reason}`);
104
+ }
105
+ }
106
+ /** Parse-and-validate helper for `set` — surfaces a clean error on bad input. */
107
+ export function parseCacheResult(raw) {
108
+ if (raw === 'pass' || raw === 'fail')
109
+ return raw;
110
+ err(`result must be 'pass' or 'fail'; got ${JSON.stringify(raw)}`);
111
+ process.exit(1);
112
+ }
@@ -19,6 +19,33 @@ export interface CheckResult {
19
19
  * Exported so tests can drive this without spinning up the full `runDoctor`.
20
20
  */
21
21
  export declare function checkFingerprintStore(baseDir: string): Promise<CheckResult>;
22
+ /**
23
+ * Detect whether `baseDir` is a git repository. Returns true for the three
24
+ * shapes git itself accepts:
25
+ *
26
+ * 1. `.git/` is a directory (vanilla repo).
27
+ * 2. `.git` is a file with `gitdir: <path>` (linked worktree, submodule).
28
+ * The target gitdir is resolved and must exist on disk — a stale or
29
+ * orphaned gitlink (submodule whose parent moved, worktree whose main
30
+ * repo was deleted) is NOT a git repo and must return false, otherwise
31
+ * doctor short-circuits the non-git escape hatch and hard-fails on the
32
+ * pre-push check against a `.git/hooks/` that doesn't exist (F1 from
33
+ * Codex review of 0.5.1).
34
+ * 3. Anything else (including a plain file a user accidentally named
35
+ * `.git`, or a symlink to nowhere) → false.
36
+ *
37
+ * Filesystem-shape predicate only. Deliberately does not consult `GIT_DIR`
38
+ * or shell out to `git rev-parse` — `rea doctor` already checks things
39
+ * inside `baseDir/.git/hooks/`, so the shape-on-disk is the right question
40
+ * for the escape hatch. A GIT_DIR-aware secondary signal is a follow-up.
41
+ *
42
+ * Security note (F3): removing `.git/` does NOT bypass governance. The
43
+ * governance artifact is the pre-push hook; a directory with no `.git/`
44
+ * has no commits to push and no pre-push event to bypass. The escape
45
+ * hatch is a UX predicate for knowledge repos and non-source directories,
46
+ * NOT a trust boundary. Do not key security decisions on the return value.
47
+ */
48
+ export declare function isGitRepo(baseDir: string): boolean;
22
49
  /**
23
50
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
24
51
  * responsiveness (pass/warn) and one informational line about the last
@@ -240,6 +240,75 @@ function checkSettingsJson(baseDir) {
240
240
  };
241
241
  }
242
242
  }
243
+ /**
244
+ * Detect whether `baseDir` is a git repository. Returns true for the three
245
+ * shapes git itself accepts:
246
+ *
247
+ * 1. `.git/` is a directory (vanilla repo).
248
+ * 2. `.git` is a file with `gitdir: <path>` (linked worktree, submodule).
249
+ * The target gitdir is resolved and must exist on disk — a stale or
250
+ * orphaned gitlink (submodule whose parent moved, worktree whose main
251
+ * repo was deleted) is NOT a git repo and must return false, otherwise
252
+ * doctor short-circuits the non-git escape hatch and hard-fails on the
253
+ * pre-push check against a `.git/hooks/` that doesn't exist (F1 from
254
+ * Codex review of 0.5.1).
255
+ * 3. Anything else (including a plain file a user accidentally named
256
+ * `.git`, or a symlink to nowhere) → false.
257
+ *
258
+ * Filesystem-shape predicate only. Deliberately does not consult `GIT_DIR`
259
+ * or shell out to `git rev-parse` — `rea doctor` already checks things
260
+ * inside `baseDir/.git/hooks/`, so the shape-on-disk is the right question
261
+ * for the escape hatch. A GIT_DIR-aware secondary signal is a follow-up.
262
+ *
263
+ * Security note (F3): removing `.git/` does NOT bypass governance. The
264
+ * governance artifact is the pre-push hook; a directory with no `.git/`
265
+ * has no commits to push and no pre-push event to bypass. The escape
266
+ * hatch is a UX predicate for knowledge repos and non-source directories,
267
+ * NOT a trust boundary. Do not key security decisions on the return value.
268
+ */
269
+ export function isGitRepo(baseDir) {
270
+ const dotGit = path.join(baseDir, '.git');
271
+ let stat;
272
+ try {
273
+ // statSync follows symlinks, so a `.git` symlink to a real gitdir is
274
+ // treated like the real thing; a dangling symlink throws ENOENT and
275
+ // falls into the catch → false.
276
+ stat = fs.statSync(dotGit);
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ if (stat.isDirectory())
282
+ return true;
283
+ if (!stat.isFile())
284
+ return false;
285
+ // Gitlink file: `gitdir: <absolute-or-relative-path>`. Read and verify
286
+ // the target resolves. If the target is missing, git itself would fail
287
+ // in this directory, so we treat it as non-git.
288
+ let content;
289
+ try {
290
+ content = fs.readFileSync(dotGit, 'utf8');
291
+ }
292
+ catch {
293
+ return false;
294
+ }
295
+ // `\s*$` on the old shape was inert (greedy `.+` consumed trailing spaces
296
+ // and `\s ⊂ .`) — the `.trim()` below did all the work. Tighten to
297
+ // `(\S.*?)` with an explicit trailing-space class so the captured group
298
+ // starts at the first non-whitespace char and stops before trailing
299
+ // whitespace. Still handles CRLF, leading tabs, and path-internal spaces.
300
+ const match = /^gitdir:\s*(\S.*?)[ \t]*\r?$/m.exec(content);
301
+ const rawTarget = match?.[1];
302
+ if (rawTarget === undefined)
303
+ return false;
304
+ const targetPath = rawTarget;
305
+ if (targetPath.length === 0)
306
+ return false;
307
+ const resolved = path.isAbsolute(targetPath)
308
+ ? targetPath
309
+ : path.join(baseDir, targetPath);
310
+ return fs.existsSync(resolved);
311
+ }
243
312
  function checkCommitMsgHook(baseDir) {
244
313
  const hookPath = path.join(baseDir, '.git', 'hooks', 'commit-msg');
245
314
  if (!fs.existsSync(hookPath)) {
@@ -443,10 +512,23 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
443
512
  checkAgentsPresent(baseDir),
444
513
  checkHooksInstalled(baseDir),
445
514
  checkSettingsJson(baseDir),
446
- checkCommitMsgHook(baseDir),
447
515
  ];
448
- if (prePushState !== undefined) {
449
- checks.push(checkPrePushHook(prePushState));
516
+ // Non-git escape hatch: when `.git/` is absent, both git-hook checks are
517
+ // meaningless (commit-msg + pre-push can't be invoked without git). Emit
518
+ // one informational line so `rea doctor` exits 0 in knowledge repos and
519
+ // other non-source-code directories that consume rea governance.
520
+ if (isGitRepo(baseDir)) {
521
+ checks.push(checkCommitMsgHook(baseDir));
522
+ if (prePushState !== undefined) {
523
+ checks.push(checkPrePushHook(prePushState));
524
+ }
525
+ }
526
+ else {
527
+ checks.push({
528
+ label: 'git hooks',
529
+ status: 'info',
530
+ detail: 'no `.git/` at baseDir — commit-msg / pre-push checks skipped (not a git repo)',
531
+ });
450
532
  }
451
533
  if (codexRequiredFromPolicy(baseDir)) {
452
534
  checks.push(checkCodexAgent(baseDir), checkCodexCommand(baseDir));