@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.
- package/MIGRATING.md +139 -0
- package/README.md +153 -36
- package/dist/cli/audit-summary.d.ts +160 -0
- package/dist/cli/audit-summary.js +535 -0
- package/dist/cli/doctor.d.ts +44 -4
- package/dist/cli/doctor.js +141 -37
- package/dist/cli/index.js +33 -0
- package/dist/cli/install/gitignore.d.ts +23 -1
- package/dist/cli/install/gitignore.js +33 -6
- package/dist/cli/install/unified-diff.d.ts +78 -0
- package/dist/cli/install/unified-diff.js +270 -0
- package/dist/cli/upgrade-check.d.ts +187 -0
- package/dist/cli/upgrade-check.js +685 -0
- package/dist/cli/upgrade.js +42 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|