@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.
- package/README.md +153 -36
- package/dist/cli/audit-summary.d.ts +145 -0
- package/dist/cli/audit-summary.js +479 -0
- 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 +149 -0
- package/dist/cli/upgrade-check.js +599 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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)",
|