@bookedsolid/rea 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Minimal LCS-based unified-diff renderer (0.41.0).
3
+ *
4
+ * Used by `rea upgrade --check` to emit a per-file preview of what the
5
+ * upgrade flow WOULD change. We deliberately ship our own implementation
6
+ * rather than pulling in the `diff` npm package — REA's dependency
7
+ * footprint is small and load-bearing, and the upgrade-check preview is
8
+ * the only consumer.
9
+ *
10
+ * Output shape mirrors `diff -u`:
11
+ *
12
+ * --- a/path/to/file
13
+ * +++ b/path/to/file
14
+ * @@ -1,3 +1,4 @@
15
+ * context line
16
+ * -removed line
17
+ * +added line
18
+ * context line
19
+ *
20
+ * Hunks are constructed greedily — adjacent changed regions within
21
+ * `contextLines` of each other are merged into a single hunk so a
22
+ * reviewer reading the output doesn't have to mentally stitch tiny
23
+ * back-to-back hunks together.
24
+ *
25
+ * Performance: classic O(n*m) LCS with two parallel `Uint32Array`s for
26
+ * the DP table. Files in the upgrade-check path are bounded at
27
+ * `DIFF_SIZE_CAP_BYTES` (256 KiB) by callers, so even the worst-case
28
+ * shape (256 KiB of single-character lines) stays inside the addressable
29
+ * range of `Uint32Array` indices (~4 GiB worth of cells). The caller is
30
+ * responsible for refusing to diff files larger than the cap; this
31
+ * module trusts its inputs.
32
+ *
33
+ * Newline handling: we split on `\n` only. A file with `\r\n` line
34
+ * endings will surface as one big changed block if compared against a
35
+ * `\n`-ending canonical, which is the right behavior — line endings
36
+ * differing IS a real difference. We do not normalize.
37
+ */
38
+ const DEFAULT_CONTEXT_LINES = 3;
39
+ /**
40
+ * Hard cap on the DP-table cell count. The O(m*n) LCS table is the
41
+ * dominant memory cost; with a 4-byte `Uint32Array` cell, this works
42
+ * out to ~16 MiB of allocation at the cap. We deliberately key off
43
+ * cell COUNT, not file bytes — codex round-1 P1 flagged that the
44
+ * 256 KiB byte cap callers enforce can still produce pathological
45
+ * matrices when files are mostly single-character lines (200 KiB of
46
+ * one-char lines = 200K lines = 40 GiB of cells).
47
+ *
48
+ * Exported so callers can detect the "too large to diff" verdict
49
+ * structurally instead of grepping the returned string.
50
+ */
51
+ export const MAX_LCS_CELLS = 4_000_000;
52
+ /**
53
+ * Sentinel returned in place of a real diff when the line counts
54
+ * would blow past `MAX_LCS_CELLS`. Wrapped in a recognizable comment
55
+ * shape so consumers grepping the diff body see a clear notice.
56
+ */
57
+ export const DIFF_TOO_LARGE_NOTICE = '# rea: diff suppressed — file pair too large for the LCS matrix budget\n';
58
+ /**
59
+ * Compute the LCS table for two line arrays. Cell (i, j) is the length
60
+ * of the longest common subsequence of `a[0..i)` and `b[0..j)`.
61
+ *
62
+ * Returns a row-major `Uint32Array` of size `(a.length + 1) * (b.length + 1)`.
63
+ */
64
+ function lcsTable(a, b) {
65
+ const m = a.length;
66
+ const n = b.length;
67
+ const rowStride = n + 1;
68
+ const table = new Uint32Array((m + 1) * rowStride);
69
+ for (let i = 1; i <= m; i += 1) {
70
+ const rowBase = i * rowStride;
71
+ const prevRowBase = (i - 1) * rowStride;
72
+ for (let j = 1; j <= n; j += 1) {
73
+ if (a[i - 1] === b[j - 1]) {
74
+ table[rowBase + j] = table[prevRowBase + (j - 1)] + 1;
75
+ }
76
+ else {
77
+ const up = table[prevRowBase + j];
78
+ const left = table[rowBase + (j - 1)];
79
+ table[rowBase + j] = up >= left ? up : left;
80
+ }
81
+ }
82
+ }
83
+ return table;
84
+ }
85
+ /**
86
+ * Walk the LCS table backwards to produce the ordered diff op sequence.
87
+ */
88
+ function backtrackOps(a, b, table) {
89
+ const ops = [];
90
+ const rowStride = b.length + 1;
91
+ let i = a.length;
92
+ let j = b.length;
93
+ while (i > 0 || j > 0) {
94
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
95
+ ops.push({ kind: 'context', text: a[i - 1] });
96
+ i -= 1;
97
+ j -= 1;
98
+ continue;
99
+ }
100
+ const up = i > 0 ? table[(i - 1) * rowStride + j] : -1;
101
+ const left = j > 0 ? table[i * rowStride + (j - 1)] : -1;
102
+ if (j > 0 && (i === 0 || left >= up)) {
103
+ ops.push({ kind: 'add', text: b[j - 1] });
104
+ j -= 1;
105
+ }
106
+ else {
107
+ ops.push({ kind: 'remove', text: a[i - 1] });
108
+ i -= 1;
109
+ }
110
+ }
111
+ ops.reverse();
112
+ return ops;
113
+ }
114
+ /**
115
+ * Group ops into hunks. A hunk covers a contiguous changed region plus
116
+ * `contextLines` of context on each side. Two adjacent changed regions
117
+ * separated by fewer than `2 * contextLines` context lines are merged
118
+ * into a single hunk (the shared context belongs to both).
119
+ */
120
+ function buildHunks(ops, contextLines) {
121
+ // First pass: collect indices of every change op.
122
+ const changedIndices = [];
123
+ for (let i = 0; i < ops.length; i += 1) {
124
+ if (ops[i].kind !== 'context')
125
+ changedIndices.push(i);
126
+ }
127
+ if (changedIndices.length === 0)
128
+ return [];
129
+ const windows = [];
130
+ for (const idx of changedIndices) {
131
+ const winStart = Math.max(0, idx - contextLines);
132
+ const winEnd = Math.min(ops.length, idx + 1 + contextLines);
133
+ const last = windows.length > 0 ? windows[windows.length - 1] : null;
134
+ if (last !== null && winStart <= last.end) {
135
+ last.end = Math.max(last.end, winEnd);
136
+ }
137
+ else {
138
+ windows.push({ start: winStart, end: winEnd });
139
+ }
140
+ }
141
+ // Convert windows to hunks with correct old/new line numbers.
142
+ // Track 1-based line cursors as we walk the full ops array.
143
+ const hunks = [];
144
+ let oldLine = 1;
145
+ let newLine = 1;
146
+ let opIdx = 0;
147
+ for (const win of windows) {
148
+ while (opIdx < win.start) {
149
+ const op = ops[opIdx];
150
+ if (op.kind === 'context') {
151
+ oldLine += 1;
152
+ newLine += 1;
153
+ }
154
+ else if (op.kind === 'remove') {
155
+ oldLine += 1;
156
+ }
157
+ else {
158
+ newLine += 1;
159
+ }
160
+ opIdx += 1;
161
+ }
162
+ const hunkOldStart = oldLine;
163
+ const hunkNewStart = newLine;
164
+ let hunkOldLines = 0;
165
+ let hunkNewLines = 0;
166
+ const hunkOps = [];
167
+ while (opIdx < win.end) {
168
+ const op = ops[opIdx];
169
+ hunkOps.push(op);
170
+ if (op.kind === 'context') {
171
+ hunkOldLines += 1;
172
+ hunkNewLines += 1;
173
+ oldLine += 1;
174
+ newLine += 1;
175
+ }
176
+ else if (op.kind === 'remove') {
177
+ hunkOldLines += 1;
178
+ oldLine += 1;
179
+ }
180
+ else {
181
+ hunkNewLines += 1;
182
+ newLine += 1;
183
+ }
184
+ opIdx += 1;
185
+ }
186
+ hunks.push({
187
+ oldStart: hunkOldStart,
188
+ oldLines: hunkOldLines,
189
+ newStart: hunkNewStart,
190
+ newLines: hunkNewLines,
191
+ ops: hunkOps,
192
+ });
193
+ }
194
+ return hunks;
195
+ }
196
+ /**
197
+ * Render hunks in unified-diff format. Header lines name the old and new
198
+ * paths via `--- a/<oldPath>` / `+++ b/<newPath>`, the same convention
199
+ * `diff -u` uses.
200
+ *
201
+ * When both files are identical, returns an empty string — the caller
202
+ * decides whether to emit a "no changes" notice.
203
+ */
204
+ function renderHunks(oldPath, newPath, hunks) {
205
+ if (hunks.length === 0)
206
+ return '';
207
+ const lines = [];
208
+ lines.push(`--- a/${oldPath}`);
209
+ lines.push(`+++ b/${newPath}`);
210
+ for (const hunk of hunks) {
211
+ // Unified-diff convention: a hunk that contains zero lines on one
212
+ // side renders `0,0` for that side's count. Empty single-line hunks
213
+ // render `N` without a comma (`@@ -5 +5,2 @@`). We always emit the
214
+ // comma form for simplicity — `diff -u` accepts it.
215
+ lines.push(`@@ -${String(hunk.oldStart)},${String(hunk.oldLines)} +${String(hunk.newStart)},${String(hunk.newLines)} @@`);
216
+ for (const op of hunk.ops) {
217
+ if (op.kind === 'context')
218
+ lines.push(` ${op.text}`);
219
+ else if (op.kind === 'add')
220
+ lines.push(`+${op.text}`);
221
+ else
222
+ lines.push(`-${op.text}`);
223
+ }
224
+ }
225
+ return lines.join('\n') + '\n';
226
+ }
227
+ /**
228
+ * Compute a unified diff between two text blobs.
229
+ *
230
+ * Returns the empty string when the two inputs are byte-identical. The
231
+ * caller decides whether to wrap that in a "no changes" notice.
232
+ *
233
+ * Splits on `\n` and drops a single trailing `\n` if present so the
234
+ * final line is not phantom-blank. A file that genuinely ends without
235
+ * a newline will appear identical to one that ends with a single
236
+ * newline — REA's canonical files all end with `\n`, so this is fine
237
+ * for our use case. Callers that need strict-EOL fidelity should
238
+ * normalize upstream.
239
+ */
240
+ export function diffUnified(oldText, newText, options = {}) {
241
+ if (oldText === newText)
242
+ return '';
243
+ const oldPath = options.oldPath ?? 'file';
244
+ const newPath = options.newPath ?? oldPath;
245
+ const contextLines = options.contextLines ?? DEFAULT_CONTEXT_LINES;
246
+ // Drop one trailing newline so split() doesn't produce a phantom empty
247
+ // line at the end. We compare the trailing-stripped forms; the diff
248
+ // header doesn't track EOL state because callers don't.
249
+ const oldNorm = oldText.endsWith('\n') ? oldText.slice(0, -1) : oldText;
250
+ const newNorm = newText.endsWith('\n') ? newText.slice(0, -1) : newText;
251
+ // Empty file → empty array of lines.
252
+ const oldLines = oldNorm.length === 0 ? [] : oldNorm.split('\n');
253
+ const newLines = newNorm.length === 0 ? [] : newNorm.split('\n');
254
+ // Codex round-1 P1: guard against pathological line-count blowups
255
+ // BEFORE allocating the DP table. Cell count grows as
256
+ // (m+1)*(n+1) — a 200 KiB file of one-character lines is well
257
+ // inside any reasonable byte cap but would allocate gigabytes of
258
+ // Uint32 cells. Return a sentinel comment the caller can detect
259
+ // and surface as "too large to render" instead of OOMing.
260
+ const cellCount = (oldLines.length + 1) * (newLines.length + 1);
261
+ if (cellCount > MAX_LCS_CELLS) {
262
+ return (`--- a/${oldPath}\n` +
263
+ `+++ b/${newPath}\n` +
264
+ DIFF_TOO_LARGE_NOTICE);
265
+ }
266
+ const table = lcsTable(oldLines, newLines);
267
+ const ops = backtrackOps(oldLines, newLines, table);
268
+ const hunks = buildHunks(ops, contextLines);
269
+ return renderHunks(oldPath, newPath, hunks);
270
+ }
@@ -0,0 +1,187 @@
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 { type CanonicalFile } from './install/canonical.js';
57
+ /** Hard cap on the diff input size. Mirrors `DIFF_SIZE_CAP_BYTES` in
58
+ * upgrade.ts so the two preview surfaces agree on the "too big to
59
+ * render" threshold. */
60
+ export declare const CHECK_DIFF_SIZE_CAP_BYTES: number;
61
+ /**
62
+ * Stable schema marker for the JSON document. Bumped when the shape
63
+ * changes in a non-additive way.
64
+ */
65
+ export declare const UPGRADE_CHECK_SCHEMA_VERSION = 1;
66
+ /**
67
+ * Tokens for hook commands that older rea versions registered into
68
+ * `.claude/settings.json` and that the upgrade flow PRUNES on every
69
+ * run. Mirrors `STALE_HOOK_COMMAND_TOKENS` in `upgrade.ts` — kept in
70
+ * sync because both modules want to anticipate the same prune set.
71
+ *
72
+ * Re-exported by upgrade.ts so the actual write path uses the same
73
+ * list.
74
+ */
75
+ export declare const UPGRADE_CHECK_STALE_HOOK_TOKENS: readonly string[];
76
+ export type UpgradeCheckAction = 'created' | 'modified' | 'unchanged' | 'removed_upstream';
77
+ export interface UpgradeCheckFile {
78
+ /** Repo-relative path of the file (POSIX-normalized). */
79
+ path: string;
80
+ action: UpgradeCheckAction;
81
+ /** Synthetic entries (settings.json subset hash, CLAUDE.md fragment,
82
+ * .gitignore managed block) are flagged so the renderer can label
83
+ * them in the table. */
84
+ synthetic?: 'settings' | 'claude-md' | 'gitignore';
85
+ /** SHA-256 of the on-disk content at preview time, when present. */
86
+ old_sha?: string;
87
+ /** SHA-256 of the would-be-installed content. */
88
+ new_sha?: string;
89
+ /** Unified diff body. Empty string for `unchanged`. May be omitted
90
+ * when the file exceeds `CHECK_DIFF_SIZE_CAP_BYTES` — `diff_truncated`
91
+ * is then `true`. */
92
+ diff?: string;
93
+ /** Set when the diff was suppressed for size. Operators inspect the
94
+ * files manually in this case. */
95
+ diff_truncated?: boolean;
96
+ /** Free-form notice the renderer should display alongside the file
97
+ * row (e.g. "removed-upstream — kept by default unless --force"). */
98
+ note?: string;
99
+ }
100
+ /**
101
+ * 0.42.0 charter item 2 — surface the same settings-schema validation
102
+ * the real upgrade flow runs. `runUpgrade` calls `validateSettings`
103
+ * on the merged result and refuses the write (throws) when it fails;
104
+ * pre-0.42.0 `rea upgrade --check` never invoked that check, so a
105
+ * preview could promise a write that the real upgrade would refuse.
106
+ *
107
+ * - `parsed: true` — schema validation succeeded; `errors` is empty.
108
+ * The real upgrade WOULD write the merged settings on demand.
109
+ * - `parsed: false` — schema validation failed. `errors` carries the
110
+ * same zod-issue strings `runUpgrade` would surface in its throw
111
+ * message. The real upgrade would refuse and leave settings.json
112
+ * untouched.
113
+ */
114
+ export interface UpgradeCheckSettingsValidation {
115
+ parsed: boolean;
116
+ errors: string[];
117
+ }
118
+ export interface UpgradeCheckPlan {
119
+ schema_version: typeof UPGRADE_CHECK_SCHEMA_VERSION;
120
+ rea_version: string;
121
+ target_root: string;
122
+ /** `true` when no install-manifest exists; the consumer is on a
123
+ * pre-G12 install and the first real upgrade will record SHAs
124
+ * for whatever is on disk. */
125
+ bootstrap: boolean;
126
+ counts: {
127
+ created: number;
128
+ modified: number;
129
+ unchanged: number;
130
+ removed_upstream: number;
131
+ };
132
+ files: UpgradeCheckFile[];
133
+ /** 0.42.0 — schema-validation outcome on the merged settings.json
134
+ * the real `rea upgrade` would write. `null` when the synthetic
135
+ * settings classification did not produce a merged result (should
136
+ * not happen in practice; defensive). */
137
+ settings_validation: UpgradeCheckSettingsValidation | null;
138
+ /**
139
+ * 0.42.0 codex round 3 P2 (2026-05-16) — top-level "preview = real"
140
+ * verdict. `true` when `rea upgrade` would actually start mutating
141
+ * the install; `false` when the new pre-flight (settings-validation)
142
+ * gate would refuse the upgrade before any file is written.
143
+ *
144
+ * Why this matters: `counts` + `files` still describe what WOULD be
145
+ * written if validation passed (operators want the diff so they can
146
+ * fix the underlying invalid setting and see the upgrade preview in
147
+ * one shot). But CI and automation consuming the JSON need a single
148
+ * unambiguous signal that the real upgrade will write nothing in
149
+ * the current state. Use `would_apply` as that signal; treat
150
+ * `files[]` + `counts` as conditional on `would_apply === true`.
151
+ */
152
+ would_apply: boolean;
153
+ }
154
+ export interface ComputeUpgradeCheckOptions {
155
+ /** Defaults to `process.cwd()`. */
156
+ baseDir?: string;
157
+ /** Tests can stub the canonical file enumeration; production reads
158
+ * from `PKG_ROOT`. */
159
+ canonicalFiles?: CanonicalFile[];
160
+ /** When `false`, skips the unified-diff computation (counts + paths
161
+ * only). Default `true`. Useful for very large repos previewed in
162
+ * CI where the diffs are not consumed. */
163
+ includeDiffs?: boolean;
164
+ }
165
+ /**
166
+ * Compute the full upgrade-check plan. Pure (filesystem reads only —
167
+ * no writes). All synthetic entries (CLAUDE.md fragment, settings
168
+ * subset hash, .gitignore managed block) are included alongside the
169
+ * canonical-file classifications.
170
+ */
171
+ export declare function computeUpgradeCheck(options?: ComputeUpgradeCheckOptions): Promise<UpgradeCheckPlan>;
172
+ /**
173
+ * Render the plan as a human-readable summary block. Designed for
174
+ * terminal consumption — counts table on top, then a per-file section
175
+ * with the diff body indented.
176
+ */
177
+ export declare function renderUpgradeCheck(plan: UpgradeCheckPlan): string;
178
+ export interface RunUpgradeCheckOptions {
179
+ json?: boolean;
180
+ /** Strip `diff` bodies from output (counts + paths only). */
181
+ noDiff?: boolean;
182
+ }
183
+ /**
184
+ * Commander entrypoint for `rea upgrade --check`. Always exits 0 —
185
+ * `--check` is a preview, not a gate.
186
+ */
187
+ export declare function runUpgradeCheck(options?: RunUpgradeCheckOptions): Promise<void>;