@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
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
1403
|
-
* threaded through
|
|
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 (
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
|
1455
|
-
// catastrophic "no tier at all" case.
|
|
1456
|
-
// AND no python3 AND no awk means
|
|
1457
|
-
// fail-closed silently — distinct from the
|
|
1458
|
-
// no-CLI-no-python-no-awk shape, and worth a precise
|
|
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:
|
|
1464
|
-
'flow-form scalars, but `policy_reader_get_list`
|
|
1465
|
-
'(e.g. `blocked_paths: [.env, ...]`)
|
|
1466
|
-
'resulting JSON arrays.
|
|
1467
|
-
'`blocked-paths-
|
|
1468
|
-
|
|
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
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
// but
|
|
1549
|
-
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|