@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.
@@ -1264,18 +1264,74 @@ function defaultPython3PyYamlReachable(baseDir) {
1264
1264
  return false;
1265
1265
  }
1266
1266
  }
1267
+ /**
1268
+ * 0.42.0 codex round 5 P2 (2026-05-16) — execution probe for the
1269
+ * python3 list-walker. Mirrors `defaultPython3PyYamlReachable` exactly
1270
+ * but swaps the `import yaml` for `import json` (the actual stdlib
1271
+ * module the shim's list-walker branch imports). Spawning the
1272
+ * interpreter end-to-end catches the broken-symlink / unreachable-
1273
+ * shim case that a bare PATH check misses.
1274
+ */
1275
+ function defaultPython3ListWalkerReachable(baseDir) {
1276
+ try {
1277
+ const probeEnv = { ...process.env, PYTHONSAFEPATH: '1' };
1278
+ delete probeEnv['PYTHONPATH'];
1279
+ delete probeEnv['PYTHONHOME'];
1280
+ delete probeEnv['PYTHONSTARTUP'];
1281
+ // Same sys.path scrub as the production loader, applied before
1282
+ // `import json`. `json` is stdlib so a malicious `./json.py`
1283
+ // attack would matter the same way `./yaml.py` does.
1284
+ const probeBody = [
1285
+ 'import sys',
1286
+ 'import os',
1287
+ '_cwd = os.getcwd()',
1288
+ '_cwd_real = os.path.realpath(_cwd)',
1289
+ 'sys.path[:] = [p for p in sys.path if p not in ("", ".", _cwd, _cwd_real)]',
1290
+ 'import json',
1291
+ 'sys.stdout.write("ok")',
1292
+ ].join('\n');
1293
+ const res = spawnSync('python3', ['-c', probeBody], {
1294
+ cwd: baseDir,
1295
+ env: probeEnv,
1296
+ timeout: 5_000,
1297
+ stdio: ['ignore', 'pipe', 'ignore'],
1298
+ });
1299
+ if (res.status !== 0)
1300
+ return false;
1301
+ return (res.stdout?.toString().trim() ?? '') === 'ok';
1302
+ }
1303
+ catch {
1304
+ return false;
1305
+ }
1306
+ }
1267
1307
  const DEFAULT_PROBES = {
1268
1308
  cliDistExists: defaultCliDistExists,
1269
1309
  cliInvokable: defaultCliInvokable,
1270
1310
  python3OnPath: () => resolveBinaryOnPath('python3'),
1271
1311
  python3PyYamlReachable: defaultPython3PyYamlReachable,
1312
+ python3ListWalkerReachable: defaultPython3ListWalkerReachable,
1272
1313
  awkOnPath: () => resolveBinaryOnPath('awk'),
1273
1314
  jqOnPath: () => resolveBinaryOnPath('jq'),
1274
1315
  };
1275
1316
  function resolveProbes(probes) {
1276
1317
  if (probes === undefined)
1277
1318
  return DEFAULT_PROBES;
1278
- return { ...DEFAULT_PROBES, ...probes };
1319
+ // 0.42.0 codex round 5 P2 (2026-05-16): when a caller (typically
1320
+ // a unit test) stubs `python3OnPath` but does NOT stub the new
1321
+ // `python3ListWalkerReachable` execution probe, derive a faithful
1322
+ // fallback from `python3OnPath` instead of falling through to the
1323
+ // real `defaultPython3ListWalkerReachable` (which would spawn a
1324
+ // python3 subprocess and break test determinism). The convention
1325
+ // matches `python3PyYamlReachable` in the existing test suite:
1326
+ // stubs that say "python3 is present" want both downstream probes
1327
+ // to report reachable, and stubs that say "python3 is absent" want
1328
+ // both downstream probes to report unreachable.
1329
+ const overrides = { ...probes };
1330
+ if (overrides.python3ListWalkerReachable === undefined && overrides.python3OnPath !== undefined) {
1331
+ const stubbedPython3OnPath = overrides.python3OnPath;
1332
+ overrides.python3ListWalkerReachable = () => stubbedPython3OnPath() !== null;
1333
+ }
1334
+ return { ...DEFAULT_PROBES, ...overrides };
1279
1335
  }
1280
1336
  /**
1281
1337
  * Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
@@ -1374,12 +1430,12 @@ export function checkPolicyReaderTier2(baseDir, probes) {
1374
1430
  * Practically always present (POSIX requirement).
1375
1431
  *
1376
1432
  * 0.40.0 charter item 2 — conditional verdict, refined by codex
1377
- * round 1 P2:
1433
+ * round 1 P2 (0.40.0) and round 2 P2 (0.42.0):
1378
1434
  * - awk present → `pass`
1379
1435
  * - awk absent AND Tier 2 reachable → `warn`
1380
1436
  * (Tier 2 implies python3, which is a list-walker)
1381
1437
  * - awk absent AND Tier 1 reachable AND a list walker
1382
- * (jq OR python3) is on PATH → `warn`
1438
+ * (jq OR full Tier-2 reachable) is usable → `warn`
1383
1439
  * - awk absent AND Tier 1 reachable BUT no list walker → `fail`
1384
1440
  * (codex round 1 P2 — list-valued policy reads silently
1385
1441
  * fail-closed even though scalar reads work, so the
@@ -1398,9 +1454,29 @@ export function checkPolicyReaderTier2(baseDir, probes) {
1398
1454
  * functional box that has python3 + jq + the rea CLI all wired but
1399
1455
  * happens to lack awk.
1400
1456
  *
1457
+ * List-iteration semantic (clarifying note for codex round 2 P2,
1458
+ * 2026-05-16): `policy_reader_get_list` in
1459
+ * `hooks/_lib/policy-reader.sh` walks the cached subtree JSON via
1460
+ * `jq` OR `python3` (stdlib-only — `json` module, no PyYAML import).
1461
+ * PyYAML is only needed for Tier 2 itself (YAML PARSING into JSON),
1462
+ * NOT for iterating the already-parsed JSON arrays at list-read time.
1463
+ *
1464
+ * Codex round 5 P2 (2026-05-16): the "list walker" predicate uses
1465
+ * `python3ListWalkerReachable` — an EXECUTION probe that actually
1466
+ * spawns `python3 -c "import json"` — instead of `python3OnPath`. A
1467
+ * PATH-only check passes for broken pyenv/asdf shims, dangling
1468
+ * symlinks, and sandboxed environments where the interpreter cannot
1469
+ * start; in those cases the shim's list-walker branch would actually
1470
+ * fail and `blocked_paths`/`protected_writes` enforcement would
1471
+ * silently break while doctor reported `warn`. The execution probe
1472
+ * mirrors `defaultPython3PyYamlReachable` exactly but swaps the
1473
+ * `import yaml` for `import json` so it's not gated on PyYAML
1474
+ * availability (which is irrelevant to list iteration).
1475
+ *
1401
1476
  * Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
1402
- * present + CLI invokable) and Tier 2's reachability. Probes are
1403
- * threaded through identically.
1477
+ * present + CLI invokable), Tier 2's reachability, and the
1478
+ * list-walker execution probe. All probes are threaded through
1479
+ * identically.
1404
1480
  */
1405
1481
  export function checkPolicyReaderTier3(baseDir, probes) {
1406
1482
  const label = 'policy-reader Tier 3 (awk)';
@@ -1420,22 +1496,25 @@ export function checkPolicyReaderTier3(baseDir, probes) {
1420
1496
  // ladder would actually do at runtime.
1421
1497
  const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
1422
1498
  const tier2 = p.python3OnPath() !== null && p.python3PyYamlReachable(baseDir);
1423
- // Codex round 1 P2 (2026-05-16): the downgrade-to-warn branch needs
1424
- // a list walker too. `policy_reader_get_list` (the helper that reads
1425
- // list-valued keys like `blocked_paths`) iterates the parsed JSON
1426
- // array via jq OR python3, falling back to Tier 3 awk for inline
1427
- // arrays. With awk gone AND no jq AND no python3, list-valued
1428
- // policy reads silently fail-closed even when Tier 1 is reachable
1429
- // `blocked-paths-bash-gate.sh` etc. would see an EMPTY blocked-paths
1430
- // set and stop enforcing entries the operator declared. Pre-fix
1431
- // this concrete shape (cliInvokable + no python3 + no jq + no awk)
1432
- // returned `warn` and the doctor exited 0 on a broken install.
1433
- // Post-fix the downgrade requires a list walker; otherwise we stay
1434
- // on `fail`. Tier 2 implies python3 on PATH (the interpreter that
1435
- // ran PyYAML), so Tier 2 always brings list-walker support — no
1436
- // additional check needed for the Tier-2 branch.
1437
- const py = p.python3OnPath();
1438
- const listWalker = p.jqOnPath() !== null || py !== null;
1499
+ // Codex round 1 P2 (0.40.0) + round 2 P2 corrected (0.42.0,
1500
+ // 2026-05-16) + round 5 P2 (0.42.0, 2026-05-16): the
1501
+ // downgrade-to-warn branch needs a list walker too.
1502
+ // `policy_reader_get_list` iterates the parsed JSON array via jq
1503
+ // OR python3. The python3 branch uses `json` from stdlib only
1504
+ // PyYAML is NOT required (it's only needed for Tier 2's YAML
1505
+ // parsing step, which has already run by the time list iteration
1506
+ // executes).
1507
+ //
1508
+ // Round 5 P2 hardening: the python3 leg of this predicate uses an
1509
+ // EXECUTION probe (`python3ListWalkerReachable`), not just a PATH
1510
+ // check. A `python3` symlink can resolve on PATH while the
1511
+ // interpreter itself fails to start (dangling pyenv/asdf shim,
1512
+ // sandboxed runner without dynamic libs, permission denied on the
1513
+ // resolved binary). PATH-only would let doctor declare `warn` on
1514
+ // a box where the shim's list walker would actually fail —
1515
+ // silently breaking `blocked_paths` / `protected_writes`
1516
+ // enforcement while doctor exits 0.
1517
+ const listWalker = p.jqOnPath() !== null || p.python3ListWalkerReachable(baseDir);
1439
1518
  if (tier2 || (tier1 && listWalker)) {
1440
1519
  const reachable = [];
1441
1520
  if (tier1)
@@ -1451,21 +1530,43 @@ export function checkPolicyReaderTier3(baseDir, probes) {
1451
1530
  'to restore the last-resort fallback.',
1452
1531
  };
1453
1532
  }
1454
- // Codex round 1 P2: separate "no list walker" diagnosis from the
1455
- // catastrophic "no tier at all" case. Tier 1 reachable but no jq
1456
- // AND no python3 AND no awk means list-valued policy reads
1457
- // fail-closed silently — distinct from the truly-empty
1458
- // no-CLI-no-python-no-awk shape, and worth a precise remediation.
1533
+ // Codex round 1 P2 (0.40.0) + round 2 P2 (0.42.0): separate "no list
1534
+ // walker" diagnosis from the catastrophic "no tier at all" case.
1535
+ // Tier 1 reachable but no jq AND no python3 AND no awk means
1536
+ // list-valued policy reads fail-closed silently — distinct from the
1537
+ // truly-empty no-CLI-no-python-no-awk shape, and worth a precise
1538
+ // remediation. The python3-as-list-walker signal is plain
1539
+ // `python3OnPath` (the `json` module is stdlib — PyYAML is NOT
1540
+ // required for list iteration).
1459
1541
  if (tier1) {
1542
+ // 0.42.0 codex round 6 P3 (2026-05-16): distinguish "python3 not
1543
+ // on PATH" from "python3 on PATH but execution fails". Pre-fix
1544
+ // this branch always reported "python3 is not on PATH" even when
1545
+ // a python3 binary was resolvable but a broken pyenv/asdf shim
1546
+ // or sandboxed interpreter failed the execution probe — that
1547
+ // sent operators toward the wrong remediation. Round 5 added
1548
+ // the execution probe specifically to surface this case; the
1549
+ // diagnostic needs to follow.
1550
+ const pythonOnPath = p.python3OnPath();
1551
+ const pythonState = pythonOnPath === null
1552
+ ? 'python3 is not on PATH'
1553
+ : `python3 at ${pythonOnPath} cannot execute \`import json\` (broken pyenv/asdf shim, ` +
1554
+ 'sandboxed interpreter, or permission-denied binary — fix the interpreter or ' +
1555
+ 'remove the shim)';
1556
+ const remediation = pythonOnPath === null
1557
+ ? 'Install awk OR jq OR python3 to restore list-iteration.'
1558
+ : `Install awk OR jq, or repair the python3 interpreter at ${pythonOnPath}, ` +
1559
+ 'to restore list-iteration.';
1460
1560
  return {
1461
1561
  label,
1462
1562
  status: 'fail',
1463
- detail: 'awk not on PATH AND neither jq nor python3 is on PATH Tier 1 (rea CLI) parses ' +
1464
- 'flow-form scalars, but `policy_reader_get_list` cannot iterate list-valued keys ' +
1465
- '(e.g. `blocked_paths: [.env, ...]`) without jq, python3, OR awk to walk the ' +
1466
- 'resulting JSON arrays. Affected hooks (`blocked-paths-bash-gate.sh`, ' +
1467
- '`blocked-paths-enforcer.sh`, …) see an EMPTY list and silently stop enforcing. ' +
1468
- 'Install awk OR jq OR python3 to restore list-iteration.',
1563
+ detail: `awk not on PATH AND jq is not on PATH AND ${pythonState} ` +
1564
+ 'Tier 1 (rea CLI) parses flow-form scalars, but `policy_reader_get_list` ' +
1565
+ 'cannot iterate list-valued keys (e.g. `blocked_paths: [.env, ...]`) ' +
1566
+ 'without jq OR python3 OR awk to walk the resulting JSON arrays. ' +
1567
+ 'Affected hooks (`blocked-paths-bash-gate.sh`, ' +
1568
+ `\`blocked-paths-enforcer.sh\`, …) see an EMPTY list and silently stop ` +
1569
+ `enforcing. ${remediation}`,
1469
1570
  };
1470
1571
  }
1471
1572
  return {
@@ -1542,11 +1643,14 @@ export function checkPolicyReaderTierSummary(baseDir, probes) {
1542
1643
  const tier2 = py !== null && p.python3PyYamlReachable(baseDir);
1543
1644
  const tier3 = p.awkOnPath() !== null;
1544
1645
  const jq = p.jqOnPath();
1545
- // List iteration after Tier 1/2 needs jq OR python3 to walk the
1546
- // JSON. Tier 2 implies python3 on PATH (the interpreter that ran
1547
- // the loader); so the only "lists broken" shape is Tier 1 reachable
1548
- // but neither jq nor python3 on PATH.
1549
- const listWalker = jq !== null || py !== null;
1646
+ // 0.42.0 codex round 5 P2 (2026-05-16): list iteration after Tier
1647
+ // 1/2 needs jq OR a python3 that can ACTUALLY execute (not just
1648
+ // resolve on PATH). The execution probe catches the broken-shim
1649
+ // case where `python3` resolves but the interpreter cannot start
1650
+ // PATH-only would falsely declare the list walker "usable" on a
1651
+ // box where the shim's python3 branch will fall through to Tier 3
1652
+ // and silently miss flow-form arrays.
1653
+ const listWalker = jq !== null || p.python3ListWalkerReachable(baseDir);
1550
1654
  const reachable = [];
1551
1655
  if (tier1)
1552
1656
  reachable.push('Tier 1 (CLI)');
package/dist/cli/index.js CHANGED
@@ -13,6 +13,8 @@ import { runServe } from './serve.js';
13
13
  import { runStatus } from './status.js';
14
14
  import { runTofuAccept, runTofuList } from './tofu.js';
15
15
  import { runUpgrade } from './upgrade.js';
16
+ import { runUpgradeCheck } from './upgrade-check.js';
17
+ import { registerAuditSummaryCommand } from './audit-summary.js';
16
18
  import { registerVerifyClaimCommand } from './verify-claim.js';
17
19
  import { err, getPkgVersion } from './utils.js';
18
20
  async function main() {
@@ -51,7 +53,34 @@ async function main() {
51
53
  .option('--dry-run', 'show what would change; write nothing')
52
54
  .option('-y, --yes', 'non-interactive — keep drifted files, skip removed-upstream')
53
55
  .option('--force', 'non-interactive — overwrite drift, delete removed-upstream')
56
+ // 0.41.0 — `--check` is a non-interactive, structured preview that
57
+ // emits unified diffs per modified file and exits 0 regardless of
58
+ // what would change. Distinct from `--dry-run`, which rehearses the
59
+ // FULL interactive flow with writes suppressed. Use `--check` in CI
60
+ // to surface the changes a `rea upgrade` PR would produce; use
61
+ // `--dry-run` locally to walk through the same prompts you'd see
62
+ // during a real upgrade.
63
+ .option('--check', '0.41.0 — preview-only mode: classify files, emit unified diffs, exit 0')
64
+ .option('--json', '(with --check) emit a single JSON document instead of the text summary')
65
+ .option('--no-diff', '(with --check) omit unified-diff bodies (counts + paths only)')
54
66
  .action(async (opts) => {
67
+ if (opts.check === true) {
68
+ await runUpgradeCheck({
69
+ json: opts.json === true,
70
+ noDiff: opts.diff === false,
71
+ });
72
+ return;
73
+ }
74
+ // Codex round-2 P1: `--json` / `--no-diff` are preview-only.
75
+ // Before this PR they were unknown flags and commander rejected
76
+ // them; now they exist on the command. Refuse them without
77
+ // `--check` rather than silently performing a real upgrade —
78
+ // a CI typo (`rea upgrade --json` without `--check`) must not
79
+ // rewrite `.claude/` / `.husky/` / managed fragments.
80
+ if (opts.json === true || opts.diff === false) {
81
+ err('`--json` / `--no-diff` are preview-only flags; pass `--check` to use them.');
82
+ process.exit(2);
83
+ }
55
84
  await runUpgrade({
56
85
  dryRun: opts.dryRun,
57
86
  yes: opts.yes,
@@ -111,6 +140,10 @@ async function main() {
111
140
  // records. Read-only; honors $CLAUDE_SESSION_ID for current-session
112
141
  // filtering. v1 omits --since / --session (deferred to 0.29.1).
113
142
  registerAuditSpecialistsSubcommand(audit);
143
+ // 0.41.0 — `rea audit summary [--since=DUR] [--json]` high-level
144
+ // overview reader. Counts events by tool_name, tier, session,
145
+ // status; samples chain integrity. Tier-Read; never mutates.
146
+ registerAuditSummaryCommand(audit);
114
147
  // Register `rea hook push-gate` — the stateless pre-push Codex gate
115
148
  // called by `.husky/pre-push` and `.git/hooks/pre-push`.
116
149
  registerHookCommand(program);
@@ -102,6 +102,28 @@ export interface EnsureGitignoreResult {
102
102
  addedEntries: string[];
103
103
  /** Non-fatal operator-facing messages (e.g. symlink refused, duplicate blocks). */
104
104
  warnings: string[];
105
+ /**
106
+ * 0.41.0 — when computed under `dryRun: true`, the full would-be
107
+ * on-disk content. Omitted in real-write mode (the file IS the
108
+ * authoritative copy). Callers like `rea upgrade --check` use this
109
+ * to render a unified diff against the current on-disk content.
110
+ */
111
+ previewContent?: string;
112
+ /**
113
+ * 0.41.0 — current on-disk content at planning time. `null` when
114
+ * the file does not exist. Omitted in real-write mode.
115
+ */
116
+ previousContent?: string | null;
117
+ }
118
+ export interface EnsureGitignoreOptions {
119
+ /**
120
+ * When `true`, compute the action + would-be content without
121
+ * writing. Returns `previewContent` and `previousContent`. Default
122
+ * `false` (write-on).
123
+ */
124
+ dryRun?: boolean;
125
+ /** Override the canonical entry list. Tests use this. */
126
+ entries?: readonly string[];
105
127
  }
106
128
  /**
107
129
  * Main entry point. Idempotent: calling twice in a row produces `unchanged`
@@ -111,4 +133,4 @@ export interface EnsureGitignoreResult {
111
133
  * init` and `rea upgrade` pass the default. Tests override to verify
112
134
  * reconciliation.
113
135
  */
114
- export declare function ensureReaGitignore(targetDir: string, entries?: readonly string[]): Promise<EnsureGitignoreResult>;
136
+ export declare function ensureReaGitignore(targetDir: string, optionsOrEntries?: EnsureGitignoreOptions | readonly string[]): Promise<EnsureGitignoreResult>;
@@ -271,7 +271,15 @@ async function writeAtomic(absPath, content) {
271
271
  * init` and `rea upgrade` pass the default. Tests override to verify
272
272
  * reconciliation.
273
273
  */
274
- export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTRIES) {
274
+ export async function ensureReaGitignore(targetDir, optionsOrEntries = {}) {
275
+ // Back-compat: pre-0.41.0 callers passed `entries` as the second
276
+ // positional argument. Detect the legacy shape and forward to the
277
+ // options form so we don't break existing call sites.
278
+ const options = Array.isArray(optionsOrEntries)
279
+ ? { entries: optionsOrEntries }
280
+ : optionsOrEntries;
281
+ const entries = options.entries ?? REA_GITIGNORE_ENTRIES;
282
+ const dryRun = options.dryRun === true;
275
283
  const absPath = path.resolve(targetDir, GITIGNORE);
276
284
  const warnings = [];
277
285
  let existing;
@@ -280,18 +288,26 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
280
288
  }
281
289
  catch (err) {
282
290
  warnings.push(err.message);
283
- return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
291
+ return {
292
+ path: absPath,
293
+ action: 'unchanged',
294
+ addedEntries: [],
295
+ warnings,
296
+ ...(dryRun ? { previousContent: null, previewContent: '' } : {}),
297
+ };
284
298
  }
285
299
  // Detect EOL so a CRLF repo stays CRLF and doesn't get torn. Codex F3.
286
300
  const eol = existing !== null && existing.includes('\r\n') ? '\r\n' : '\n';
287
301
  if (existing === null) {
288
302
  const content = buildManagedBlock(entries, '\n') + '\n';
289
- await writeAtomic(absPath, content);
303
+ if (!dryRun)
304
+ await writeAtomic(absPath, content);
290
305
  return {
291
306
  path: absPath,
292
307
  action: 'created',
293
308
  addedEntries: [...entries],
294
309
  warnings,
310
+ ...(dryRun ? { previousContent: null, previewContent: content } : {}),
295
311
  };
296
312
  }
297
313
  const lines = existing.split(/\r?\n/);
@@ -300,7 +316,13 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
300
316
  if (block === 'duplicate') {
301
317
  warnings.push(`${absPath} contains multiple '# === rea managed' blocks — refusing to modify. ` +
302
318
  `Consolidate the managed blocks manually and rerun.`);
303
- return { path: absPath, action: 'unchanged', addedEntries: [], warnings };
319
+ return {
320
+ path: absPath,
321
+ action: 'unchanged',
322
+ addedEntries: [],
323
+ warnings,
324
+ ...(dryRun ? { previousContent: existing, previewContent: existing } : {}),
325
+ };
304
326
  }
305
327
  if (block === null) {
306
328
  // No managed block. Append one after a blank-line separator (unless the
@@ -315,12 +337,14 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
315
337
  const separator = bodyLines.length === 0 ? [] : [''];
316
338
  const newLines = [...bodyLines, ...separator, buildManagedBlock(entries, eol)];
317
339
  const content = newLines.join(eol) + eol;
318
- await writeAtomic(absPath, content);
340
+ if (!dryRun)
341
+ await writeAtomic(absPath, content);
319
342
  return {
320
343
  path: absPath,
321
344
  action: 'updated',
322
345
  addedEntries: [...entries],
323
346
  warnings,
347
+ ...(dryRun ? { previousContent: existing, previewContent: content } : {}),
324
348
  };
325
349
  }
326
350
  // Managed block exists — reconcile body lines.
@@ -332,6 +356,7 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
332
356
  action: 'unchanged',
333
357
  addedEntries: [],
334
358
  warnings,
359
+ ...(dryRun ? { previousContent: existing, previewContent: existing } : {}),
335
360
  };
336
361
  }
337
362
  const newLines = [
@@ -342,11 +367,13 @@ export async function ensureReaGitignore(targetDir, entries = REA_GITIGNORE_ENTR
342
367
  let content = newLines.join(eol);
343
368
  if (hadTrailingNewline && !content.endsWith(eol))
344
369
  content += eol;
345
- await writeAtomic(absPath, content);
370
+ if (!dryRun)
371
+ await writeAtomic(absPath, content);
346
372
  return {
347
373
  path: absPath,
348
374
  action: 'updated',
349
375
  addedEntries: added,
350
376
  warnings,
377
+ ...(dryRun ? { previousContent: existing, previewContent: content } : {}),
351
378
  };
352
379
  }
@@ -0,0 +1,78 @@
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
+ /**
39
+ * Hard cap on the DP-table cell count. The O(m*n) LCS table is the
40
+ * dominant memory cost; with a 4-byte `Uint32Array` cell, this works
41
+ * out to ~16 MiB of allocation at the cap. We deliberately key off
42
+ * cell COUNT, not file bytes — codex round-1 P1 flagged that the
43
+ * 256 KiB byte cap callers enforce can still produce pathological
44
+ * matrices when files are mostly single-character lines (200 KiB of
45
+ * one-char lines = 200K lines = 40 GiB of cells).
46
+ *
47
+ * Exported so callers can detect the "too large to diff" verdict
48
+ * structurally instead of grepping the returned string.
49
+ */
50
+ export declare const MAX_LCS_CELLS = 4000000;
51
+ /**
52
+ * Sentinel returned in place of a real diff when the line counts
53
+ * would blow past `MAX_LCS_CELLS`. Wrapped in a recognizable comment
54
+ * shape so consumers grepping the diff body see a clear notice.
55
+ */
56
+ export declare const DIFF_TOO_LARGE_NOTICE = "# rea: diff suppressed \u2014 file pair too large for the LCS matrix budget\n";
57
+ export interface UnifiedDiffOptions {
58
+ /** Defaults to `'file'`. Appears after `--- a/` in the header. */
59
+ oldPath?: string;
60
+ /** Defaults to `oldPath`. Appears after `+++ b/` in the header. */
61
+ newPath?: string;
62
+ /** Lines of context around each change. Default 3 — matches `diff -u`. */
63
+ contextLines?: number;
64
+ }
65
+ /**
66
+ * Compute a unified diff between two text blobs.
67
+ *
68
+ * Returns the empty string when the two inputs are byte-identical. The
69
+ * caller decides whether to wrap that in a "no changes" notice.
70
+ *
71
+ * Splits on `\n` and drops a single trailing `\n` if present so the
72
+ * final line is not phantom-blank. A file that genuinely ends without
73
+ * a newline will appear identical to one that ends with a single
74
+ * newline — REA's canonical files all end with `\n`, so this is fine
75
+ * for our use case. Callers that need strict-EOL fidelity should
76
+ * normalize upstream.
77
+ */
78
+ export declare function diffUnified(oldText: string, newText: string, options?: UnifiedDiffOptions): string;