@bookedsolid/rea 0.40.0 → 0.42.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.
@@ -0,0 +1,685 @@
1
+ /**
2
+ * `rea upgrade --check` — 0.41.0 consumer-UX dry-run preview.
3
+ *
4
+ * Today `rea upgrade` rewrites consumer files immediately (gated by
5
+ * interactive prompts and `--dry-run` for a coarse preview). There has
6
+ * been no way to PREVIEW what would change, file-by-file, with the
7
+ * actual textual delta, before running the real upgrade. `--check`
8
+ * fills that gap.
9
+ *
10
+ * # Contract
11
+ *
12
+ * - Reads the same canonical file set as `rea upgrade`, classifies it
13
+ * against the installed manifest, and emits a summary table of
14
+ * counts (created / modified / unchanged / removed-upstream) plus a
15
+ * unified diff per modified file.
16
+ * - Never writes to disk. The classification phase is pure; nothing
17
+ * downstream of `computeUpgradePlan` mutates the filesystem.
18
+ * - Exits 0 regardless of what would change — `--check` is a preview,
19
+ * not an enforcement gate. Consumers wiring `rea upgrade --check`
20
+ * into CI gate on diff-present via the JSON output (`--json`).
21
+ * - Distinct from `--dry-run`: `--dry-run` runs the FULL interactive
22
+ * upgrade flow with writes suppressed (prompts still fire, output
23
+ * still streams in classification order). `--check` is purely
24
+ * structured, non-interactive, and emits the unified diffs that
25
+ * `--dry-run` does not.
26
+ *
27
+ * # JSON output
28
+ *
29
+ * `--json` emits a single document with shape:
30
+ *
31
+ * {
32
+ * "schema_version": 1,
33
+ * "rea_version": "0.41.0",
34
+ * "target_root": "/abs/path/to/repo",
35
+ * "bootstrap": false,
36
+ * "counts": { "created": 1, "modified": 3, "unchanged": 47,
37
+ * "removed_upstream": 0 },
38
+ * "files": [
39
+ * { "path": "hooks/foo.sh", "action": "modified",
40
+ * "old_sha": "…", "new_sha": "…", "diff": "--- …\n+++ …\n…" },
41
+ * …
42
+ * ]
43
+ * }
44
+ *
45
+ * `diff` is included for `created` (showing full content as additions),
46
+ * `modified` (true unified diff), and `removed_upstream` (showing the
47
+ * full content as removals). It is omitted for `unchanged`.
48
+ *
49
+ * # Why not extend `--dry-run`?
50
+ *
51
+ * `--dry-run` already serves a different purpose: rehearse the full
52
+ * upgrade flow in interactive mode. Bolting structured output onto it
53
+ * would either change its existing output shape (breaking pipelines)
54
+ * or fork it into two output paths anyway. A new flag is cleaner.
55
+ */
56
+ import fs from 'node:fs';
57
+ import fsPromises from 'node:fs/promises';
58
+ import path from 'node:path';
59
+ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
60
+ import { buildFragment, extractFragment } from './install/claude-md.js';
61
+ import { safeReadFile } from './install/fs-safe.js';
62
+ import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneHookCommands, readSettings, } from './install/settings-merge.js';
63
+ import { ensureReaGitignore } from './install/gitignore.js';
64
+ import { manifestExists, readManifest } from './install/manifest-io.js';
65
+ import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
66
+ import { diffUnified, DIFF_TOO_LARGE_NOTICE } from './install/unified-diff.js';
67
+ import { loadPolicy } from '../policy/loader.js';
68
+ import { validateSettings } from '../config/settings-schema.js';
69
+ import { err, getPkgVersion, log } from './utils.js';
70
+ /** Hard cap on the diff input size. Mirrors `DIFF_SIZE_CAP_BYTES` in
71
+ * upgrade.ts so the two preview surfaces agree on the "too big to
72
+ * render" threshold. */
73
+ export const CHECK_DIFF_SIZE_CAP_BYTES = 256 * 1024;
74
+ /**
75
+ * Stable schema marker for the JSON document. Bumped when the shape
76
+ * changes in a non-additive way.
77
+ */
78
+ export const UPGRADE_CHECK_SCHEMA_VERSION = 1;
79
+ /**
80
+ * Tokens for hook commands that older rea versions registered into
81
+ * `.claude/settings.json` and that the upgrade flow PRUNES on every
82
+ * run. Mirrors `STALE_HOOK_COMMAND_TOKENS` in `upgrade.ts` — kept in
83
+ * sync because both modules want to anticipate the same prune set.
84
+ *
85
+ * Re-exported by upgrade.ts so the actual write path uses the same
86
+ * list.
87
+ */
88
+ export const UPGRADE_CHECK_STALE_HOOK_TOKENS = [
89
+ 'push-review-gate.sh',
90
+ 'commit-review-gate.sh',
91
+ 'push-review-gate-git.sh',
92
+ ];
93
+ /**
94
+ * Compute a unified diff and translate the LCS-overflow sentinel
95
+ * into a `diff_truncated` verdict. Codex round-1 P1: pathological
96
+ * line counts inside the byte cap can still return the
97
+ * `DIFF_TOO_LARGE_NOTICE` sentinel from `diffUnified`; treat that
98
+ * sentinel as a truncation rather than a real diff body.
99
+ *
100
+ * Returns `{ diff?, diff_truncated? }` — never both, never neither.
101
+ */
102
+ function safeDiff(oldText, newText, pathLabel) {
103
+ const body = diffUnified(oldText, newText, { oldPath: pathLabel, newPath: pathLabel });
104
+ if (body.length === 0)
105
+ return {};
106
+ if (body.includes(DIFF_TOO_LARGE_NOTICE)) {
107
+ return { diff_truncated: true };
108
+ }
109
+ return { diff: body };
110
+ }
111
+ /**
112
+ * Read a file from disk under `safeReadFile` containment. Returns
113
+ * `null` when the file does not exist (the standard "not on disk yet"
114
+ * signal in this module).
115
+ */
116
+ async function readLocalFile(resolvedRoot, relPath) {
117
+ const buf = await safeReadFile(resolvedRoot, relPath);
118
+ if (buf === null)
119
+ return null;
120
+ return { bytes: buf, sha: sha256OfBuffer(buf) };
121
+ }
122
+ /**
123
+ * Classify a single canonical file against the local copy + manifest
124
+ * entry and return an UpgradeCheckFile.
125
+ */
126
+ async function classifyOne(resolvedRoot, canonical, manifestEntry, includeDiffs) {
127
+ const canonicalSha = await sha256OfFile(canonical.sourceAbsPath);
128
+ const local = await readLocalFile(resolvedRoot, canonical.destRelPath);
129
+ if (local === null) {
130
+ // Treat as `created`. Diff shows full canonical content as adds.
131
+ const file = {
132
+ path: canonical.destRelPath,
133
+ action: 'created',
134
+ new_sha: canonicalSha,
135
+ };
136
+ if (includeDiffs) {
137
+ const canonicalStat = await fsPromises.stat(canonical.sourceAbsPath);
138
+ if (canonicalStat.size > CHECK_DIFF_SIZE_CAP_BYTES) {
139
+ file.diff_truncated = true;
140
+ }
141
+ else {
142
+ const canonicalText = await fsPromises.readFile(canonical.sourceAbsPath, 'utf8');
143
+ Object.assign(file, safeDiff('', canonicalText, canonical.destRelPath));
144
+ }
145
+ }
146
+ return file;
147
+ }
148
+ // File exists on disk. Resolve manifest tier:
149
+ // - manifest entry present + sha matches local → `unmodified` per
150
+ // manifest, but if local !== canonical, the actual upgrade will
151
+ // auto-update. Treat as `modified` in the check view (the
152
+ // consumer needs to know this byte will change).
153
+ // - manifest entry absent + local matches canonical → `unchanged`
154
+ // (no work).
155
+ // - manifest entry absent + local diverges → `modified` (rea will
156
+ // prompt; --check reports the would-be canonical replacement).
157
+ if (local.sha === canonicalSha) {
158
+ return {
159
+ path: canonical.destRelPath,
160
+ action: 'unchanged',
161
+ old_sha: local.sha,
162
+ new_sha: canonicalSha,
163
+ };
164
+ }
165
+ const file = {
166
+ path: canonical.destRelPath,
167
+ action: 'modified',
168
+ old_sha: local.sha,
169
+ new_sha: canonicalSha,
170
+ };
171
+ if (includeDiffs) {
172
+ const canonicalStat = await fsPromises.stat(canonical.sourceAbsPath);
173
+ if (local.bytes.byteLength > CHECK_DIFF_SIZE_CAP_BYTES ||
174
+ canonicalStat.size > CHECK_DIFF_SIZE_CAP_BYTES) {
175
+ file.diff_truncated = true;
176
+ }
177
+ else {
178
+ const oldText = local.bytes.toString('utf8');
179
+ const newText = await fsPromises.readFile(canonical.sourceAbsPath, 'utf8');
180
+ Object.assign(file, safeDiff(oldText, newText, canonical.destRelPath));
181
+ }
182
+ }
183
+ // Manifest context is informational only. Pre-write we cannot
184
+ // distinguish auto-update from prompt-on-drift without re-running
185
+ // the interactive flow; both surface as `modified` here. The actual
186
+ // upgrade decision (auto vs prompt) is made by `runUpgrade`.
187
+ if (manifestEntry === undefined) {
188
+ file.note = 'no manifest entry — first observed on this run';
189
+ }
190
+ else if (local.sha === manifestEntry.sha256) {
191
+ file.note = 'auto-update — local matches last installed SHA';
192
+ }
193
+ else {
194
+ file.note = 'drift — interactive upgrade will prompt to overwrite or keep';
195
+ }
196
+ return file;
197
+ }
198
+ /**
199
+ * Build the synthetic CLAUDE.md fragment entry. Mirrors
200
+ * `upgradeClaudeMdFragment` in `upgrade.ts` but never writes — it just
201
+ * reports what the fragment WOULD look like.
202
+ */
203
+ async function classifyClaudeMd(resolvedRoot, includeDiffs) {
204
+ // The fragment requires the live policy to render. If policy load
205
+ // fails (e.g. consumer hasn't run `rea init` yet), the upgrade flow
206
+ // skips the fragment too — mirror that.
207
+ let fragmentInput;
208
+ try {
209
+ const policy = loadPolicy(resolvedRoot);
210
+ fragmentInput = {
211
+ policyPath: '.rea/policy.yaml',
212
+ profile: policy.profile,
213
+ autonomyLevel: policy.autonomy_level,
214
+ maxAutonomyLevel: policy.max_autonomy_level,
215
+ blockedPathsCount: policy.blocked_paths.length,
216
+ blockAiAttribution: policy.block_ai_attribution,
217
+ };
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ const newFragment = buildFragment(fragmentInput);
223
+ const newSha = sha256OfBuffer(newFragment);
224
+ const claudeMdPath = path.join(resolvedRoot, 'CLAUDE.md');
225
+ if (!fs.existsSync(claudeMdPath)) {
226
+ const wouldBe = `# CLAUDE.md\n\n${newFragment}\n`;
227
+ // Codex round-2 P2: hash the full would-be CLAUDE.md content,
228
+ // NOT the fragment-only SHA. The fragment SHA is what the
229
+ // manifest tracks; the on-disk file is the wrapper + fragment.
230
+ // Reporting fragment SHA as new_sha makes JSON consumers
231
+ // diffing SHA fields see a false mismatch against the on-disk
232
+ // hash post-upgrade.
233
+ const file = {
234
+ path: 'CLAUDE.md',
235
+ action: 'created',
236
+ synthetic: 'claude-md',
237
+ new_sha: sha256OfBuffer(Buffer.from(wouldBe, 'utf8')),
238
+ note: `managed fragment will be installed into a fresh CLAUDE.md (fragment SHA ${newSha.slice(0, 12)}…)`,
239
+ };
240
+ if (includeDiffs) {
241
+ Object.assign(file, safeDiff('', wouldBe, 'CLAUDE.md'));
242
+ }
243
+ return file;
244
+ }
245
+ const existing = await fsPromises.readFile(claudeMdPath, 'utf8');
246
+ const existingSha = sha256OfBuffer(Buffer.from(existing, 'utf8'));
247
+ const currentFragment = extractFragment(existing);
248
+ if (currentFragment === newFragment) {
249
+ return {
250
+ path: 'CLAUDE.md',
251
+ action: 'unchanged',
252
+ synthetic: 'claude-md',
253
+ old_sha: existingSha,
254
+ new_sha: existingSha,
255
+ note: `managed fragment up to date (fragment SHA ${newSha.slice(0, 12)}…)`,
256
+ };
257
+ }
258
+ // Build the would-be replacement text the same way `upgrade.ts` does
259
+ // (replace existing fragment in place, or append if no markers).
260
+ let next;
261
+ if (currentFragment !== null) {
262
+ next = existing.replace(currentFragment, newFragment);
263
+ }
264
+ else {
265
+ const trailer = existing.endsWith('\n') ? '' : '\n';
266
+ next = `${existing}${trailer}\n${newFragment}\n`;
267
+ }
268
+ const file = {
269
+ path: 'CLAUDE.md',
270
+ action: 'modified',
271
+ synthetic: 'claude-md',
272
+ old_sha: existingSha,
273
+ new_sha: sha256OfBuffer(Buffer.from(next, 'utf8')),
274
+ note: `managed fragment will be updated; non-managed content preserved (fragment SHA ${newSha.slice(0, 12)}…)`,
275
+ };
276
+ if (includeDiffs) {
277
+ if (Buffer.byteLength(existing, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES ||
278
+ Buffer.byteLength(next, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES) {
279
+ file.diff_truncated = true;
280
+ }
281
+ else {
282
+ Object.assign(file, safeDiff(existing, next, 'CLAUDE.md'));
283
+ }
284
+ }
285
+ return file;
286
+ }
287
+ /**
288
+ * Classify the synthetic `.claude/settings.json` entry. Like the real
289
+ * upgrade flow, we prune known-stale hook tokens BEFORE merging the
290
+ * default-desired hook set — the order matters so the merge sees a
291
+ * clean baseline.
292
+ *
293
+ * 0.42.0 — also returns the merged object so the caller can run the
294
+ * same `validateSettings` check the real `runUpgrade` runs. We hand
295
+ * back the merged shape directly (not the file rendering) so the
296
+ * caller can decide whether to thread it into the schema check.
297
+ */
298
+ async function classifySettings(resolvedRoot, includeDiffs) {
299
+ const desired = defaultDesiredHooks();
300
+ // `canonicalSettingsSubsetHash` is the MANIFEST-tracked SHA of the
301
+ // rea-owned subset (used by drift detection). It does NOT equal the
302
+ // on-disk file hash, because consumers commonly add their own hook
303
+ // entries that we leave alone. Keep it in the note for forensics,
304
+ // but report `old_sha` / `new_sha` as the actual on-disk vs.
305
+ // would-be-on-disk file hashes — that's what consumers diffing the
306
+ // JSON expect. (Codex round-2 P2.)
307
+ const subsetSha = canonicalSettingsSubsetHash(desired);
308
+ const { settings, settingsPath } = readSettings(resolvedRoot);
309
+ const pruned = pruneHookCommands(settings, UPGRADE_CHECK_STALE_HOOK_TOKENS);
310
+ const mergeResult = mergeSettings(pruned.merged, desired);
311
+ const willWrite = pruned.removedCount > 0 || mergeResult.addedCount > 0;
312
+ const action = willWrite ? 'modified' : 'unchanged';
313
+ let oldText = '';
314
+ let exists = false;
315
+ try {
316
+ oldText = await fsPromises.readFile(settingsPath, 'utf8');
317
+ exists = true;
318
+ }
319
+ catch (e) {
320
+ if (e.code !== 'ENOENT')
321
+ throw e;
322
+ }
323
+ const newText = `${JSON.stringify(mergeResult.merged, null, 2)}\n`;
324
+ const file = {
325
+ path: path.relative(resolvedRoot, settingsPath).split(path.sep).join('/'),
326
+ action: !exists ? 'created' : action,
327
+ synthetic: 'settings',
328
+ new_sha: sha256OfBuffer(Buffer.from(newText, 'utf8')),
329
+ };
330
+ if (exists) {
331
+ file.old_sha = sha256OfBuffer(Buffer.from(oldText, 'utf8'));
332
+ }
333
+ const notes = [`rea-subset SHA ${subsetSha.slice(0, 12)}…`];
334
+ if (pruned.removedCount > 0) {
335
+ notes.push(`would prune ${String(pruned.removedCount)} stale hook entr${pruned.removedCount === 1 ? 'y' : 'ies'}`);
336
+ }
337
+ if (mergeResult.addedCount > 0) {
338
+ notes.push(`would add ${String(mergeResult.addedCount)} hook entr${mergeResult.addedCount === 1 ? 'y' : 'ies'}`);
339
+ }
340
+ if (mergeResult.skippedCount > 0) {
341
+ notes.push(`${String(mergeResult.skippedCount)} hook entr${mergeResult.skippedCount === 1 ? 'y' : 'ies'} already present`);
342
+ }
343
+ file.note = notes.join('; ');
344
+ if (includeDiffs && (file.action === 'modified' || file.action === 'created')) {
345
+ if (Buffer.byteLength(oldText, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES ||
346
+ Buffer.byteLength(newText, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES) {
347
+ file.diff_truncated = true;
348
+ }
349
+ else {
350
+ Object.assign(file, safeDiff(oldText, newText, file.path));
351
+ }
352
+ }
353
+ return { file, merged: mergeResult.merged };
354
+ }
355
+ /**
356
+ * Build a `removed_upstream` entry from a manifest record that has no
357
+ * matching canonical file. The diff body shows the full local content
358
+ * as removals.
359
+ */
360
+ async function classifyRemovedUpstream(resolvedRoot, entry, includeDiffs) {
361
+ const local = await readLocalFile(resolvedRoot, entry.path);
362
+ if (local === null) {
363
+ // The manifest references a file that's already gone. Nothing
364
+ // for --check to surface — the actual upgrade would also no-op.
365
+ return null;
366
+ }
367
+ const file = {
368
+ path: entry.path,
369
+ action: 'removed_upstream',
370
+ old_sha: local.sha,
371
+ note: 'no longer shipped by rea — interactive upgrade defaults to keep; --force deletes',
372
+ };
373
+ if (includeDiffs) {
374
+ if (local.bytes.byteLength > CHECK_DIFF_SIZE_CAP_BYTES) {
375
+ file.diff_truncated = true;
376
+ }
377
+ else {
378
+ const oldText = local.bytes.toString('utf8');
379
+ Object.assign(file, safeDiff(oldText, '', entry.path));
380
+ }
381
+ }
382
+ return file;
383
+ }
384
+ /**
385
+ * Build the synthetic `.gitignore` entry. Mirrors the real upgrade
386
+ * path's `ensureReaGitignore` call but runs in dry-run mode so it
387
+ * returns `previewContent` instead of writing.
388
+ *
389
+ * Codex round-1 P2: `runUpgrade` calls `ensureReaGitignore` to
390
+ * backfill the managed block for older installs missing
391
+ * `.rea/last-review.json` / `.rea/fingerprints.json` / etc. Without
392
+ * a synthetic check entry, `rea upgrade --check` would silently
393
+ * report a fully-in-sync repo and then `rea upgrade` would still
394
+ * mutate `.gitignore` — breaking the advertised preview contract.
395
+ */
396
+ async function classifyGitignore(resolvedRoot, includeDiffs) {
397
+ let result;
398
+ try {
399
+ result = await ensureReaGitignore(resolvedRoot, { dryRun: true });
400
+ }
401
+ catch {
402
+ // Defensive — the dry-run path inside ensureReaGitignore converts
403
+ // every recoverable failure to a warning + 'unchanged' verdict.
404
+ // Catch the unexpected and skip the row rather than failing the
405
+ // whole plan.
406
+ return null;
407
+ }
408
+ // `action` from ensureReaGitignore: 'created' | 'updated' | 'unchanged'.
409
+ // Map onto our check vocabulary.
410
+ const action = result.action === 'created'
411
+ ? 'created'
412
+ : result.action === 'updated'
413
+ ? 'modified'
414
+ : 'unchanged';
415
+ const relPath = path.relative(resolvedRoot, result.path).split(path.sep).join('/');
416
+ const file = {
417
+ path: relPath,
418
+ action,
419
+ synthetic: 'gitignore',
420
+ };
421
+ const previewContent = result.previewContent ?? '';
422
+ const previousContent = result.previousContent ?? '';
423
+ if (previewContent.length > 0) {
424
+ file.new_sha = sha256OfBuffer(Buffer.from(previewContent, 'utf8'));
425
+ }
426
+ if (previousContent.length > 0 || result.previousContent !== null) {
427
+ file.old_sha = sha256OfBuffer(Buffer.from(previousContent, 'utf8'));
428
+ }
429
+ if (result.addedEntries.length > 0) {
430
+ file.note = `managed block ${result.action} — ${String(result.addedEntries.length)} entr${result.addedEntries.length === 1 ? 'y' : 'ies'} added`;
431
+ }
432
+ else if (action === 'unchanged') {
433
+ file.note = 'managed block up to date';
434
+ }
435
+ if (result.warnings.length > 0) {
436
+ const joined = result.warnings.join('; ');
437
+ file.note = file.note !== undefined ? `${file.note}; ${joined}` : joined;
438
+ }
439
+ if (includeDiffs && action !== 'unchanged') {
440
+ if (Buffer.byteLength(previousContent, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES ||
441
+ Buffer.byteLength(previewContent, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES) {
442
+ file.diff_truncated = true;
443
+ }
444
+ else {
445
+ Object.assign(file, safeDiff(previousContent, previewContent, relPath));
446
+ }
447
+ }
448
+ return file;
449
+ }
450
+ /**
451
+ * Compute the full upgrade-check plan. Pure (filesystem reads only —
452
+ * no writes). All synthetic entries (CLAUDE.md fragment, settings
453
+ * subset hash, .gitignore managed block) are included alongside the
454
+ * canonical-file classifications.
455
+ */
456
+ export async function computeUpgradeCheck(options = {}) {
457
+ const baseDir = options.baseDir ?? process.cwd();
458
+ const includeDiffs = options.includeDiffs ?? true;
459
+ const resolvedRoot = await fsPromises.realpath(baseDir);
460
+ const canonicalFiles = options.canonicalFiles ?? (await enumerateCanonicalFiles());
461
+ const existingManifest = manifestExists(resolvedRoot) ? await readManifest(resolvedRoot) : null;
462
+ const isBootstrap = existingManifest === null;
463
+ const manifestByPath = new Map();
464
+ if (existingManifest !== null) {
465
+ for (const e of existingManifest.files)
466
+ manifestByPath.set(e.path, e);
467
+ }
468
+ const canonicalByPath = new Map();
469
+ for (const c of canonicalFiles)
470
+ canonicalByPath.set(c.destRelPath, c);
471
+ const files = [];
472
+ for (const canonical of canonicalFiles) {
473
+ files.push(await classifyOne(resolvedRoot, canonical, manifestByPath.get(canonical.destRelPath), includeDiffs));
474
+ }
475
+ // Removed-upstream entries.
476
+ if (existingManifest !== null) {
477
+ for (const entry of existingManifest.files) {
478
+ if (entry.path === CLAUDE_MD_MANIFEST_PATH || entry.path === SETTINGS_MANIFEST_PATH) {
479
+ continue; // synthetic entries handled below
480
+ }
481
+ if (!canonicalByPath.has(entry.path)) {
482
+ const file = await classifyRemovedUpstream(resolvedRoot, entry, includeDiffs);
483
+ if (file !== null)
484
+ files.push(file);
485
+ }
486
+ }
487
+ }
488
+ // Synthetic entries.
489
+ const settingsClassification = await classifySettings(resolvedRoot, includeDiffs);
490
+ files.push(settingsClassification.file);
491
+ const claudeMd = await classifyClaudeMd(resolvedRoot, includeDiffs);
492
+ if (claudeMd !== null)
493
+ files.push(claudeMd);
494
+ const gitignoreFile = await classifyGitignore(resolvedRoot, includeDiffs);
495
+ if (gitignoreFile !== null)
496
+ files.push(gitignoreFile);
497
+ // 0.42.0 charter item 2 — schema-validate the merged settings the
498
+ // real `runUpgrade` would write. `runUpgrade` calls `validateSettings`
499
+ // (non-strict, matching the upgrade flow's posture) and throws when
500
+ // the merged result fails — refusing the write. Pre-0.42.0 the
501
+ // preview never ran this check, so the planner could promise a write
502
+ // that the real upgrade would refuse. Reproduce the exact validation
503
+ // shape here so the JSON `settings_validation` field is byte-for-byte
504
+ // what `runUpgrade` would see.
505
+ const validation = validateSettings(settingsClassification.merged, { strict: false });
506
+ const settingsValidation = {
507
+ parsed: validation.parsed,
508
+ errors: validation.errors,
509
+ };
510
+ // If validation failed, annotate the settings file row so the
511
+ // human-readable rendering surfaces the refusal alongside the count
512
+ // table (operators reading the table without scrolling to the
513
+ // footer still see the warning). Note appends rather than overwrites
514
+ // so the existing merge / prune annotations remain visible.
515
+ if (!validation.parsed) {
516
+ const refusalNote = `WOULD REFUSE: schema validation failed — ${validation.errors.join('; ')}`;
517
+ const f = settingsClassification.file;
518
+ f.note = f.note !== undefined ? `${f.note}; ${refusalNote}` : refusalNote;
519
+ }
520
+ // Stable sort: action priority (modified → created → removed_upstream
521
+ // → unchanged) then path. Operators reviewing the table want to see
522
+ // the changed entries first.
523
+ const actionOrder = {
524
+ modified: 0,
525
+ created: 1,
526
+ removed_upstream: 2,
527
+ unchanged: 3,
528
+ };
529
+ files.sort((a, b) => {
530
+ const diff = actionOrder[a.action] - actionOrder[b.action];
531
+ if (diff !== 0)
532
+ return diff;
533
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
534
+ });
535
+ const counts = { created: 0, modified: 0, unchanged: 0, removed_upstream: 0 };
536
+ for (const f of files)
537
+ counts[f.action] += 1;
538
+ return {
539
+ schema_version: UPGRADE_CHECK_SCHEMA_VERSION,
540
+ rea_version: getPkgVersion(),
541
+ target_root: resolvedRoot,
542
+ bootstrap: isBootstrap,
543
+ counts,
544
+ files,
545
+ settings_validation: settingsValidation,
546
+ // Codex round 3 P2 (2026-05-16): mirror runUpgrade's pre-flight
547
+ // gate. `would_apply` is true when the real upgrade would actually
548
+ // start mutating disk — currently the only gate is settings-schema
549
+ // validation, but more pre-flight checks may land in future
550
+ // releases (in which case they get ANDed in here).
551
+ would_apply: settingsValidation.parsed,
552
+ };
553
+ }
554
+ /**
555
+ * Render the plan as a human-readable summary block. Designed for
556
+ * terminal consumption — counts table on top, then a per-file section
557
+ * with the diff body indented.
558
+ */
559
+ export function renderUpgradeCheck(plan) {
560
+ const lines = [];
561
+ lines.push(`rea upgrade --check (rea v${plan.rea_version})`);
562
+ lines.push(` target: ${plan.target_root}`);
563
+ if (plan.bootstrap) {
564
+ lines.push(` bootstrap mode: no install-manifest found yet`);
565
+ }
566
+ lines.push('');
567
+ // Codex round 3 P2 (2026-05-16): when a pre-flight gate would
568
+ // refuse the upgrade, the counts/files below describe what WOULD
569
+ // happen if the gate passed — but `rea upgrade` will actually
570
+ // write nothing in the current state. Lead with that banner so
571
+ // operators don't read the summary table as a promise of action.
572
+ if (!plan.would_apply) {
573
+ lines.push('BLOCKED — `rea upgrade` would refuse to apply this plan in its current state. ' +
574
+ 'The summary below describes the would-be plan IF the refusal were fixed first; ' +
575
+ 'the real upgrade writes nothing until the refusal clears.');
576
+ lines.push('');
577
+ }
578
+ const totalChanges = plan.counts.created + plan.counts.modified + plan.counts.removed_upstream;
579
+ const summaryLabel = plan.would_apply ? 'planned change(s)' : 'change(s) blocked by refusal';
580
+ lines.push(`Summary — ${String(totalChanges)} ${summaryLabel}:`);
581
+ lines.push(` created: ${String(plan.counts.created)}`);
582
+ lines.push(` modified: ${String(plan.counts.modified)}`);
583
+ lines.push(` removed-upstream: ${String(plan.counts.removed_upstream)}`);
584
+ lines.push(` unchanged: ${String(plan.counts.unchanged)}`);
585
+ lines.push('');
586
+ if (totalChanges === 0) {
587
+ lines.push('No changes — your install is already in sync with this rea version.');
588
+ lines.push('');
589
+ // 0.42.0 — even with zero planned changes, surface a validation
590
+ // failure here so an operator doesn't see "in sync" and miss the
591
+ // settings refusal.
592
+ if (plan.settings_validation !== null && !plan.settings_validation.parsed) {
593
+ lines.push('WARNING: `rea upgrade` would REFUSE to run — the merged ' +
594
+ '.claude/settings.json would fail schema validation:');
595
+ for (const e of plan.settings_validation.errors) {
596
+ lines.push(` - ${e}`);
597
+ }
598
+ lines.push('');
599
+ }
600
+ return lines.join('\n');
601
+ }
602
+ // Per-file detail — skip `unchanged` entries.
603
+ for (const file of plan.files) {
604
+ if (file.action === 'unchanged')
605
+ continue;
606
+ const marker = file.action === 'created' ? '+' : file.action === 'removed_upstream' ? '-' : '~';
607
+ const baseLabel = file.action === 'removed_upstream' ? 'removed-upstream' : file.action;
608
+ // Codex round 3 P2 (2026-05-16): when a refusal is active, every
609
+ // would-be-mutated file row gets a BLOCKED suffix so a quick scroll
610
+ // through the per-file detail cannot miss the fact that no write
611
+ // will happen until the refusal clears.
612
+ const label = plan.would_apply ? baseLabel : `${baseLabel} (BLOCKED — refusal active)`;
613
+ const syntheticTag = file.synthetic !== undefined ? ` [${file.synthetic}]` : '';
614
+ lines.push(`${marker} ${file.path}${syntheticTag} — ${label}`);
615
+ if (file.note !== undefined)
616
+ lines.push(` note: ${file.note}`);
617
+ if (file.old_sha !== undefined && file.new_sha !== undefined) {
618
+ lines.push(` sha: ${file.old_sha.slice(0, 12)}… → ${file.new_sha.slice(0, 12)}…`);
619
+ }
620
+ else if (file.new_sha !== undefined) {
621
+ lines.push(` sha: (new) → ${file.new_sha.slice(0, 12)}…`);
622
+ }
623
+ else if (file.old_sha !== undefined) {
624
+ lines.push(` sha: ${file.old_sha.slice(0, 12)}… → (removed)`);
625
+ }
626
+ if (file.diff_truncated === true) {
627
+ lines.push(` diff: (suppressed — file exceeds ${String(CHECK_DIFF_SIZE_CAP_BYTES)} bytes; inspect manually)`);
628
+ }
629
+ else if (file.diff !== undefined && file.diff.length > 0) {
630
+ lines.push(' diff:');
631
+ for (const dl of file.diff.split('\n')) {
632
+ // Trailing empty from split — preserve only if non-empty.
633
+ if (dl.length > 0)
634
+ lines.push(` ${dl}`);
635
+ }
636
+ }
637
+ lines.push('');
638
+ }
639
+ // 0.42.0 — surface settings-schema validation outcome alongside the
640
+ // footer. When the merged settings would fail validation, `rea
641
+ // upgrade` would refuse to start at all (codex round 2 P2 moved the
642
+ // validation to a pre-flight check BEFORE any file writes); we
643
+ // report that here so consumers can fix policy + settings before
644
+ // invoking the real upgrade.
645
+ if (plan.settings_validation !== null && !plan.settings_validation.parsed) {
646
+ lines.push('');
647
+ lines.push('WARNING: `rea upgrade` would REFUSE to run — the merged ' +
648
+ '.claude/settings.json would fail schema validation:');
649
+ for (const e of plan.settings_validation.errors) {
650
+ lines.push(` - ${e}`);
651
+ }
652
+ lines.push('No files will be written by `rea upgrade` while this is true — the ' +
653
+ 'pre-flight check runs before any canonical hook or agent file is ' +
654
+ 'installed AND before the 0.11.0 .rea/policy.yaml migration, so your ' +
655
+ 'existing install stays untouched. Fix the settings entries flagged ' +
656
+ 'above and re-run `rea upgrade --check`.');
657
+ lines.push('');
658
+ }
659
+ else {
660
+ lines.push('No changes were written. Run `rea upgrade` (without --check) to apply.');
661
+ lines.push('');
662
+ }
663
+ return lines.join('\n');
664
+ }
665
+ /**
666
+ * Commander entrypoint for `rea upgrade --check`. Always exits 0 —
667
+ * `--check` is a preview, not a gate.
668
+ */
669
+ export async function runUpgradeCheck(options = {}) {
670
+ const baseDir = process.cwd();
671
+ if (!fs.existsSync(path.join(baseDir, '.rea'))) {
672
+ err('no .rea/ directory — run `rea init` first.');
673
+ process.exit(1);
674
+ }
675
+ const plan = await computeUpgradeCheck({
676
+ baseDir,
677
+ includeDiffs: options.noDiff !== true,
678
+ });
679
+ if (options.json === true) {
680
+ process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
681
+ return;
682
+ }
683
+ process.stdout.write(renderUpgradeCheck(plan));
684
+ log(`upgrade --check complete — no changes were written.`);
685
+ }