@bookedsolid/rea 0.40.0 → 0.41.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,599 @@
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 { err, getPkgVersion, log } from './utils.js';
69
+ /** Hard cap on the diff input size. Mirrors `DIFF_SIZE_CAP_BYTES` in
70
+ * upgrade.ts so the two preview surfaces agree on the "too big to
71
+ * render" threshold. */
72
+ export const CHECK_DIFF_SIZE_CAP_BYTES = 256 * 1024;
73
+ /**
74
+ * Stable schema marker for the JSON document. Bumped when the shape
75
+ * changes in a non-additive way.
76
+ */
77
+ export const UPGRADE_CHECK_SCHEMA_VERSION = 1;
78
+ /**
79
+ * Tokens for hook commands that older rea versions registered into
80
+ * `.claude/settings.json` and that the upgrade flow PRUNES on every
81
+ * run. Mirrors `STALE_HOOK_COMMAND_TOKENS` in `upgrade.ts` — kept in
82
+ * sync because both modules want to anticipate the same prune set.
83
+ *
84
+ * Re-exported by upgrade.ts so the actual write path uses the same
85
+ * list.
86
+ */
87
+ export const UPGRADE_CHECK_STALE_HOOK_TOKENS = [
88
+ 'push-review-gate.sh',
89
+ 'commit-review-gate.sh',
90
+ 'push-review-gate-git.sh',
91
+ ];
92
+ /**
93
+ * Compute a unified diff and translate the LCS-overflow sentinel
94
+ * into a `diff_truncated` verdict. Codex round-1 P1: pathological
95
+ * line counts inside the byte cap can still return the
96
+ * `DIFF_TOO_LARGE_NOTICE` sentinel from `diffUnified`; treat that
97
+ * sentinel as a truncation rather than a real diff body.
98
+ *
99
+ * Returns `{ diff?, diff_truncated? }` — never both, never neither.
100
+ */
101
+ function safeDiff(oldText, newText, pathLabel) {
102
+ const body = diffUnified(oldText, newText, { oldPath: pathLabel, newPath: pathLabel });
103
+ if (body.length === 0)
104
+ return {};
105
+ if (body.includes(DIFF_TOO_LARGE_NOTICE)) {
106
+ return { diff_truncated: true };
107
+ }
108
+ return { diff: body };
109
+ }
110
+ /**
111
+ * Read a file from disk under `safeReadFile` containment. Returns
112
+ * `null` when the file does not exist (the standard "not on disk yet"
113
+ * signal in this module).
114
+ */
115
+ async function readLocalFile(resolvedRoot, relPath) {
116
+ const buf = await safeReadFile(resolvedRoot, relPath);
117
+ if (buf === null)
118
+ return null;
119
+ return { bytes: buf, sha: sha256OfBuffer(buf) };
120
+ }
121
+ /**
122
+ * Classify a single canonical file against the local copy + manifest
123
+ * entry and return an UpgradeCheckFile.
124
+ */
125
+ async function classifyOne(resolvedRoot, canonical, manifestEntry, includeDiffs) {
126
+ const canonicalSha = await sha256OfFile(canonical.sourceAbsPath);
127
+ const local = await readLocalFile(resolvedRoot, canonical.destRelPath);
128
+ if (local === null) {
129
+ // Treat as `created`. Diff shows full canonical content as adds.
130
+ const file = {
131
+ path: canonical.destRelPath,
132
+ action: 'created',
133
+ new_sha: canonicalSha,
134
+ };
135
+ if (includeDiffs) {
136
+ const canonicalStat = await fsPromises.stat(canonical.sourceAbsPath);
137
+ if (canonicalStat.size > CHECK_DIFF_SIZE_CAP_BYTES) {
138
+ file.diff_truncated = true;
139
+ }
140
+ else {
141
+ const canonicalText = await fsPromises.readFile(canonical.sourceAbsPath, 'utf8');
142
+ Object.assign(file, safeDiff('', canonicalText, canonical.destRelPath));
143
+ }
144
+ }
145
+ return file;
146
+ }
147
+ // File exists on disk. Resolve manifest tier:
148
+ // - manifest entry present + sha matches local → `unmodified` per
149
+ // manifest, but if local !== canonical, the actual upgrade will
150
+ // auto-update. Treat as `modified` in the check view (the
151
+ // consumer needs to know this byte will change).
152
+ // - manifest entry absent + local matches canonical → `unchanged`
153
+ // (no work).
154
+ // - manifest entry absent + local diverges → `modified` (rea will
155
+ // prompt; --check reports the would-be canonical replacement).
156
+ if (local.sha === canonicalSha) {
157
+ return {
158
+ path: canonical.destRelPath,
159
+ action: 'unchanged',
160
+ old_sha: local.sha,
161
+ new_sha: canonicalSha,
162
+ };
163
+ }
164
+ const file = {
165
+ path: canonical.destRelPath,
166
+ action: 'modified',
167
+ old_sha: local.sha,
168
+ new_sha: canonicalSha,
169
+ };
170
+ if (includeDiffs) {
171
+ const canonicalStat = await fsPromises.stat(canonical.sourceAbsPath);
172
+ if (local.bytes.byteLength > CHECK_DIFF_SIZE_CAP_BYTES ||
173
+ canonicalStat.size > CHECK_DIFF_SIZE_CAP_BYTES) {
174
+ file.diff_truncated = true;
175
+ }
176
+ else {
177
+ const oldText = local.bytes.toString('utf8');
178
+ const newText = await fsPromises.readFile(canonical.sourceAbsPath, 'utf8');
179
+ Object.assign(file, safeDiff(oldText, newText, canonical.destRelPath));
180
+ }
181
+ }
182
+ // Manifest context is informational only. Pre-write we cannot
183
+ // distinguish auto-update from prompt-on-drift without re-running
184
+ // the interactive flow; both surface as `modified` here. The actual
185
+ // upgrade decision (auto vs prompt) is made by `runUpgrade`.
186
+ if (manifestEntry === undefined) {
187
+ file.note = 'no manifest entry — first observed on this run';
188
+ }
189
+ else if (local.sha === manifestEntry.sha256) {
190
+ file.note = 'auto-update — local matches last installed SHA';
191
+ }
192
+ else {
193
+ file.note = 'drift — interactive upgrade will prompt to overwrite or keep';
194
+ }
195
+ return file;
196
+ }
197
+ /**
198
+ * Build the synthetic CLAUDE.md fragment entry. Mirrors
199
+ * `upgradeClaudeMdFragment` in `upgrade.ts` but never writes — it just
200
+ * reports what the fragment WOULD look like.
201
+ */
202
+ async function classifyClaudeMd(resolvedRoot, includeDiffs) {
203
+ // The fragment requires the live policy to render. If policy load
204
+ // fails (e.g. consumer hasn't run `rea init` yet), the upgrade flow
205
+ // skips the fragment too — mirror that.
206
+ let fragmentInput;
207
+ try {
208
+ const policy = loadPolicy(resolvedRoot);
209
+ fragmentInput = {
210
+ policyPath: '.rea/policy.yaml',
211
+ profile: policy.profile,
212
+ autonomyLevel: policy.autonomy_level,
213
+ maxAutonomyLevel: policy.max_autonomy_level,
214
+ blockedPathsCount: policy.blocked_paths.length,
215
+ blockAiAttribution: policy.block_ai_attribution,
216
+ };
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ const newFragment = buildFragment(fragmentInput);
222
+ const newSha = sha256OfBuffer(newFragment);
223
+ const claudeMdPath = path.join(resolvedRoot, 'CLAUDE.md');
224
+ if (!fs.existsSync(claudeMdPath)) {
225
+ const wouldBe = `# CLAUDE.md\n\n${newFragment}\n`;
226
+ // Codex round-2 P2: hash the full would-be CLAUDE.md content,
227
+ // NOT the fragment-only SHA. The fragment SHA is what the
228
+ // manifest tracks; the on-disk file is the wrapper + fragment.
229
+ // Reporting fragment SHA as new_sha makes JSON consumers
230
+ // diffing SHA fields see a false mismatch against the on-disk
231
+ // hash post-upgrade.
232
+ const file = {
233
+ path: 'CLAUDE.md',
234
+ action: 'created',
235
+ synthetic: 'claude-md',
236
+ new_sha: sha256OfBuffer(Buffer.from(wouldBe, 'utf8')),
237
+ note: `managed fragment will be installed into a fresh CLAUDE.md (fragment SHA ${newSha.slice(0, 12)}…)`,
238
+ };
239
+ if (includeDiffs) {
240
+ Object.assign(file, safeDiff('', wouldBe, 'CLAUDE.md'));
241
+ }
242
+ return file;
243
+ }
244
+ const existing = await fsPromises.readFile(claudeMdPath, 'utf8');
245
+ const existingSha = sha256OfBuffer(Buffer.from(existing, 'utf8'));
246
+ const currentFragment = extractFragment(existing);
247
+ if (currentFragment === newFragment) {
248
+ return {
249
+ path: 'CLAUDE.md',
250
+ action: 'unchanged',
251
+ synthetic: 'claude-md',
252
+ old_sha: existingSha,
253
+ new_sha: existingSha,
254
+ note: `managed fragment up to date (fragment SHA ${newSha.slice(0, 12)}…)`,
255
+ };
256
+ }
257
+ // Build the would-be replacement text the same way `upgrade.ts` does
258
+ // (replace existing fragment in place, or append if no markers).
259
+ let next;
260
+ if (currentFragment !== null) {
261
+ next = existing.replace(currentFragment, newFragment);
262
+ }
263
+ else {
264
+ const trailer = existing.endsWith('\n') ? '' : '\n';
265
+ next = `${existing}${trailer}\n${newFragment}\n`;
266
+ }
267
+ const file = {
268
+ path: 'CLAUDE.md',
269
+ action: 'modified',
270
+ synthetic: 'claude-md',
271
+ old_sha: existingSha,
272
+ new_sha: sha256OfBuffer(Buffer.from(next, 'utf8')),
273
+ note: `managed fragment will be updated; non-managed content preserved (fragment SHA ${newSha.slice(0, 12)}…)`,
274
+ };
275
+ if (includeDiffs) {
276
+ if (Buffer.byteLength(existing, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES ||
277
+ Buffer.byteLength(next, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES) {
278
+ file.diff_truncated = true;
279
+ }
280
+ else {
281
+ Object.assign(file, safeDiff(existing, next, 'CLAUDE.md'));
282
+ }
283
+ }
284
+ return file;
285
+ }
286
+ /**
287
+ * Classify the synthetic `.claude/settings.json` entry. Like the real
288
+ * upgrade flow, we prune known-stale hook tokens BEFORE merging the
289
+ * default-desired hook set — the order matters so the merge sees a
290
+ * clean baseline.
291
+ */
292
+ async function classifySettings(resolvedRoot, includeDiffs) {
293
+ const desired = defaultDesiredHooks();
294
+ // `canonicalSettingsSubsetHash` is the MANIFEST-tracked SHA of the
295
+ // rea-owned subset (used by drift detection). It does NOT equal the
296
+ // on-disk file hash, because consumers commonly add their own hook
297
+ // entries that we leave alone. Keep it in the note for forensics,
298
+ // but report `old_sha` / `new_sha` as the actual on-disk vs.
299
+ // would-be-on-disk file hashes — that's what consumers diffing the
300
+ // JSON expect. (Codex round-2 P2.)
301
+ const subsetSha = canonicalSettingsSubsetHash(desired);
302
+ const { settings, settingsPath } = readSettings(resolvedRoot);
303
+ const pruned = pruneHookCommands(settings, UPGRADE_CHECK_STALE_HOOK_TOKENS);
304
+ const mergeResult = mergeSettings(pruned.merged, desired);
305
+ const willWrite = pruned.removedCount > 0 || mergeResult.addedCount > 0;
306
+ const action = willWrite ? 'modified' : 'unchanged';
307
+ let oldText = '';
308
+ let exists = false;
309
+ try {
310
+ oldText = await fsPromises.readFile(settingsPath, 'utf8');
311
+ exists = true;
312
+ }
313
+ catch (e) {
314
+ if (e.code !== 'ENOENT')
315
+ throw e;
316
+ }
317
+ const newText = `${JSON.stringify(mergeResult.merged, null, 2)}\n`;
318
+ const file = {
319
+ path: path.relative(resolvedRoot, settingsPath).split(path.sep).join('/'),
320
+ action: !exists ? 'created' : action,
321
+ synthetic: 'settings',
322
+ new_sha: sha256OfBuffer(Buffer.from(newText, 'utf8')),
323
+ };
324
+ if (exists) {
325
+ file.old_sha = sha256OfBuffer(Buffer.from(oldText, 'utf8'));
326
+ }
327
+ const notes = [`rea-subset SHA ${subsetSha.slice(0, 12)}…`];
328
+ if (pruned.removedCount > 0) {
329
+ notes.push(`would prune ${String(pruned.removedCount)} stale hook entr${pruned.removedCount === 1 ? 'y' : 'ies'}`);
330
+ }
331
+ if (mergeResult.addedCount > 0) {
332
+ notes.push(`would add ${String(mergeResult.addedCount)} hook entr${mergeResult.addedCount === 1 ? 'y' : 'ies'}`);
333
+ }
334
+ if (mergeResult.skippedCount > 0) {
335
+ notes.push(`${String(mergeResult.skippedCount)} hook entr${mergeResult.skippedCount === 1 ? 'y' : 'ies'} already present`);
336
+ }
337
+ file.note = notes.join('; ');
338
+ if (includeDiffs && (file.action === 'modified' || file.action === 'created')) {
339
+ if (Buffer.byteLength(oldText, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES ||
340
+ Buffer.byteLength(newText, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES) {
341
+ file.diff_truncated = true;
342
+ }
343
+ else {
344
+ Object.assign(file, safeDiff(oldText, newText, file.path));
345
+ }
346
+ }
347
+ return file;
348
+ }
349
+ /**
350
+ * Build a `removed_upstream` entry from a manifest record that has no
351
+ * matching canonical file. The diff body shows the full local content
352
+ * as removals.
353
+ */
354
+ async function classifyRemovedUpstream(resolvedRoot, entry, includeDiffs) {
355
+ const local = await readLocalFile(resolvedRoot, entry.path);
356
+ if (local === null) {
357
+ // The manifest references a file that's already gone. Nothing
358
+ // for --check to surface — the actual upgrade would also no-op.
359
+ return null;
360
+ }
361
+ const file = {
362
+ path: entry.path,
363
+ action: 'removed_upstream',
364
+ old_sha: local.sha,
365
+ note: 'no longer shipped by rea — interactive upgrade defaults to keep; --force deletes',
366
+ };
367
+ if (includeDiffs) {
368
+ if (local.bytes.byteLength > CHECK_DIFF_SIZE_CAP_BYTES) {
369
+ file.diff_truncated = true;
370
+ }
371
+ else {
372
+ const oldText = local.bytes.toString('utf8');
373
+ Object.assign(file, safeDiff(oldText, '', entry.path));
374
+ }
375
+ }
376
+ return file;
377
+ }
378
+ /**
379
+ * Build the synthetic `.gitignore` entry. Mirrors the real upgrade
380
+ * path's `ensureReaGitignore` call but runs in dry-run mode so it
381
+ * returns `previewContent` instead of writing.
382
+ *
383
+ * Codex round-1 P2: `runUpgrade` calls `ensureReaGitignore` to
384
+ * backfill the managed block for older installs missing
385
+ * `.rea/last-review.json` / `.rea/fingerprints.json` / etc. Without
386
+ * a synthetic check entry, `rea upgrade --check` would silently
387
+ * report a fully-in-sync repo and then `rea upgrade` would still
388
+ * mutate `.gitignore` — breaking the advertised preview contract.
389
+ */
390
+ async function classifyGitignore(resolvedRoot, includeDiffs) {
391
+ let result;
392
+ try {
393
+ result = await ensureReaGitignore(resolvedRoot, { dryRun: true });
394
+ }
395
+ catch {
396
+ // Defensive — the dry-run path inside ensureReaGitignore converts
397
+ // every recoverable failure to a warning + 'unchanged' verdict.
398
+ // Catch the unexpected and skip the row rather than failing the
399
+ // whole plan.
400
+ return null;
401
+ }
402
+ // `action` from ensureReaGitignore: 'created' | 'updated' | 'unchanged'.
403
+ // Map onto our check vocabulary.
404
+ const action = result.action === 'created'
405
+ ? 'created'
406
+ : result.action === 'updated'
407
+ ? 'modified'
408
+ : 'unchanged';
409
+ const relPath = path.relative(resolvedRoot, result.path).split(path.sep).join('/');
410
+ const file = {
411
+ path: relPath,
412
+ action,
413
+ synthetic: 'gitignore',
414
+ };
415
+ const previewContent = result.previewContent ?? '';
416
+ const previousContent = result.previousContent ?? '';
417
+ if (previewContent.length > 0) {
418
+ file.new_sha = sha256OfBuffer(Buffer.from(previewContent, 'utf8'));
419
+ }
420
+ if (previousContent.length > 0 || result.previousContent !== null) {
421
+ file.old_sha = sha256OfBuffer(Buffer.from(previousContent, 'utf8'));
422
+ }
423
+ if (result.addedEntries.length > 0) {
424
+ file.note = `managed block ${result.action} — ${String(result.addedEntries.length)} entr${result.addedEntries.length === 1 ? 'y' : 'ies'} added`;
425
+ }
426
+ else if (action === 'unchanged') {
427
+ file.note = 'managed block up to date';
428
+ }
429
+ if (result.warnings.length > 0) {
430
+ const joined = result.warnings.join('; ');
431
+ file.note = file.note !== undefined ? `${file.note}; ${joined}` : joined;
432
+ }
433
+ if (includeDiffs && action !== 'unchanged') {
434
+ if (Buffer.byteLength(previousContent, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES ||
435
+ Buffer.byteLength(previewContent, 'utf8') > CHECK_DIFF_SIZE_CAP_BYTES) {
436
+ file.diff_truncated = true;
437
+ }
438
+ else {
439
+ Object.assign(file, safeDiff(previousContent, previewContent, relPath));
440
+ }
441
+ }
442
+ return file;
443
+ }
444
+ /**
445
+ * Compute the full upgrade-check plan. Pure (filesystem reads only —
446
+ * no writes). All synthetic entries (CLAUDE.md fragment, settings
447
+ * subset hash, .gitignore managed block) are included alongside the
448
+ * canonical-file classifications.
449
+ */
450
+ export async function computeUpgradeCheck(options = {}) {
451
+ const baseDir = options.baseDir ?? process.cwd();
452
+ const includeDiffs = options.includeDiffs ?? true;
453
+ const resolvedRoot = await fsPromises.realpath(baseDir);
454
+ const canonicalFiles = options.canonicalFiles ?? (await enumerateCanonicalFiles());
455
+ const existingManifest = manifestExists(resolvedRoot) ? await readManifest(resolvedRoot) : null;
456
+ const isBootstrap = existingManifest === null;
457
+ const manifestByPath = new Map();
458
+ if (existingManifest !== null) {
459
+ for (const e of existingManifest.files)
460
+ manifestByPath.set(e.path, e);
461
+ }
462
+ const canonicalByPath = new Map();
463
+ for (const c of canonicalFiles)
464
+ canonicalByPath.set(c.destRelPath, c);
465
+ const files = [];
466
+ for (const canonical of canonicalFiles) {
467
+ files.push(await classifyOne(resolvedRoot, canonical, manifestByPath.get(canonical.destRelPath), includeDiffs));
468
+ }
469
+ // Removed-upstream entries.
470
+ if (existingManifest !== null) {
471
+ for (const entry of existingManifest.files) {
472
+ if (entry.path === CLAUDE_MD_MANIFEST_PATH || entry.path === SETTINGS_MANIFEST_PATH) {
473
+ continue; // synthetic entries handled below
474
+ }
475
+ if (!canonicalByPath.has(entry.path)) {
476
+ const file = await classifyRemovedUpstream(resolvedRoot, entry, includeDiffs);
477
+ if (file !== null)
478
+ files.push(file);
479
+ }
480
+ }
481
+ }
482
+ // Synthetic entries.
483
+ const settingsFile = await classifySettings(resolvedRoot, includeDiffs);
484
+ files.push(settingsFile);
485
+ const claudeMd = await classifyClaudeMd(resolvedRoot, includeDiffs);
486
+ if (claudeMd !== null)
487
+ files.push(claudeMd);
488
+ const gitignoreFile = await classifyGitignore(resolvedRoot, includeDiffs);
489
+ if (gitignoreFile !== null)
490
+ files.push(gitignoreFile);
491
+ // Stable sort: action priority (modified → created → removed_upstream
492
+ // → unchanged) then path. Operators reviewing the table want to see
493
+ // the changed entries first.
494
+ const actionOrder = {
495
+ modified: 0,
496
+ created: 1,
497
+ removed_upstream: 2,
498
+ unchanged: 3,
499
+ };
500
+ files.sort((a, b) => {
501
+ const diff = actionOrder[a.action] - actionOrder[b.action];
502
+ if (diff !== 0)
503
+ return diff;
504
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
505
+ });
506
+ const counts = { created: 0, modified: 0, unchanged: 0, removed_upstream: 0 };
507
+ for (const f of files)
508
+ counts[f.action] += 1;
509
+ return {
510
+ schema_version: UPGRADE_CHECK_SCHEMA_VERSION,
511
+ rea_version: getPkgVersion(),
512
+ target_root: resolvedRoot,
513
+ bootstrap: isBootstrap,
514
+ counts,
515
+ files,
516
+ };
517
+ }
518
+ /**
519
+ * Render the plan as a human-readable summary block. Designed for
520
+ * terminal consumption — counts table on top, then a per-file section
521
+ * with the diff body indented.
522
+ */
523
+ export function renderUpgradeCheck(plan) {
524
+ const lines = [];
525
+ lines.push(`rea upgrade --check (rea v${plan.rea_version})`);
526
+ lines.push(` target: ${plan.target_root}`);
527
+ if (plan.bootstrap) {
528
+ lines.push(` bootstrap mode: no install-manifest found yet`);
529
+ }
530
+ lines.push('');
531
+ const totalChanges = plan.counts.created + plan.counts.modified + plan.counts.removed_upstream;
532
+ lines.push(`Summary — ${String(totalChanges)} planned change(s):`);
533
+ lines.push(` created: ${String(plan.counts.created)}`);
534
+ lines.push(` modified: ${String(plan.counts.modified)}`);
535
+ lines.push(` removed-upstream: ${String(plan.counts.removed_upstream)}`);
536
+ lines.push(` unchanged: ${String(plan.counts.unchanged)}`);
537
+ lines.push('');
538
+ if (totalChanges === 0) {
539
+ lines.push('No changes — your install is already in sync with this rea version.');
540
+ lines.push('');
541
+ return lines.join('\n');
542
+ }
543
+ // Per-file detail — skip `unchanged` entries.
544
+ for (const file of plan.files) {
545
+ if (file.action === 'unchanged')
546
+ continue;
547
+ const marker = file.action === 'created' ? '+' : file.action === 'removed_upstream' ? '-' : '~';
548
+ const label = file.action === 'removed_upstream' ? 'removed-upstream' : file.action;
549
+ const syntheticTag = file.synthetic !== undefined ? ` [${file.synthetic}]` : '';
550
+ lines.push(`${marker} ${file.path}${syntheticTag} — ${label}`);
551
+ if (file.note !== undefined)
552
+ lines.push(` note: ${file.note}`);
553
+ if (file.old_sha !== undefined && file.new_sha !== undefined) {
554
+ lines.push(` sha: ${file.old_sha.slice(0, 12)}… → ${file.new_sha.slice(0, 12)}…`);
555
+ }
556
+ else if (file.new_sha !== undefined) {
557
+ lines.push(` sha: (new) → ${file.new_sha.slice(0, 12)}…`);
558
+ }
559
+ else if (file.old_sha !== undefined) {
560
+ lines.push(` sha: ${file.old_sha.slice(0, 12)}… → (removed)`);
561
+ }
562
+ if (file.diff_truncated === true) {
563
+ lines.push(` diff: (suppressed — file exceeds ${String(CHECK_DIFF_SIZE_CAP_BYTES)} bytes; inspect manually)`);
564
+ }
565
+ else if (file.diff !== undefined && file.diff.length > 0) {
566
+ lines.push(' diff:');
567
+ for (const dl of file.diff.split('\n')) {
568
+ // Trailing empty from split — preserve only if non-empty.
569
+ if (dl.length > 0)
570
+ lines.push(` ${dl}`);
571
+ }
572
+ }
573
+ lines.push('');
574
+ }
575
+ lines.push('No changes were written. Run `rea upgrade` (without --check) to apply.');
576
+ lines.push('');
577
+ return lines.join('\n');
578
+ }
579
+ /**
580
+ * Commander entrypoint for `rea upgrade --check`. Always exits 0 —
581
+ * `--check` is a preview, not a gate.
582
+ */
583
+ export async function runUpgradeCheck(options = {}) {
584
+ const baseDir = process.cwd();
585
+ if (!fs.existsSync(path.join(baseDir, '.rea'))) {
586
+ err('no .rea/ directory — run `rea init` first.');
587
+ process.exit(1);
588
+ }
589
+ const plan = await computeUpgradeCheck({
590
+ baseDir,
591
+ includeDiffs: options.noDiff !== true,
592
+ });
593
+ if (options.json === true) {
594
+ process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
595
+ return;
596
+ }
597
+ process.stdout.write(renderUpgradeCheck(plan));
598
+ log(`upgrade --check complete — no changes were written.`);
599
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",