From 843e23e1efdb7a0974e8d3ea760dcb08cd5794ae Mon Sep 17 00:00:00 2001 From: John Smith Date: Wed, 23 Sep 2026 11:41:04 -0400 Subject: [PATCH] fix(ci): pin fork-divergence guard to a baseline commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork-divergence job has been red since upstream pushed on 2026-09-23: 191 modified paths vs 126 allowlisted, 65 "unexpected", plus one unexpected upstreamMissing entry. Root cause is not fork divergence. The guard compared the fork against the live refs/remotes/upstream/master (or the API's master tip), so every upstream push that touched a file the fork also carries read as newly introduced divergence. All 66 reported paths were byte-identical to the fork's previous upstream snapshot — the fork had never modified them. The one upstreamMissing entry came from an upstream commit that touched it in that same window. Compare against a baseline commit instead: * fork-divergence.json records `baselineCommit` (here the merge base of the last upstream sync, b9109cfbaa). The reported set now only moves when the fork's own content moves, which is the only signal a reviewer can act on. * Resolution falls back to `git merge-base HEAD upstream/master` when the field is absent, and the baseline tree is read either from a local commit or the GitHub trees API by SHA — still no ~1.7 GB clone in CI. * A baseline that is recorded but unreachable is a hard error (exit 2) rather than a silent fallback to live upstream, which would reintroduce exactly the false positives the baseline exists to remove. Re-anchoring also drops entries that have converged: 10 paths the fork once modified but which now match the baseline (upstream fixes adopted by the merge) are no longer divergence. Verified all 10 are byte-identical to the baseline and none is touched by a non-merge fork commit, so nothing is lost. Also updates MAINTENANCE.md, scripts/README.md, AGENTS.md, docs and the ci.yml comment to the corrected semantics and counts (180 paths: 116/57/7). Verified: guard exits 0 locally against upstream at a later HEAD; exits 0 in a depth-1 clone with no upstream ref against the API-by-SHA path; still exits 1 on a new modification, a stale entry, and a restored upstreamMissing file; exits 2 with an actionable message when no baseline can be resolved. --- .github/workflows/ci.yml | 8 +- AGENTS.md | 6 +- MAINTENANCE.md | 44 +++++- docs/HERETEK-ROADMAP.md | 2 +- fork-divergence.json | 13 +- scripts/README.md | 15 +- scripts/check-fork-divergence.js | 229 +++++++++++++++++++++++++------ 7 files changed, 245 insertions(+), 72 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc75bdbcb..6a1ee1c00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,12 @@ jobs: # listed path no longer diverges (stale entry). Without it, divergence only # surfaces as a merge conflict on the next upstream sync. # - # The upstream tree is read through the GitHub trees API rather than a - # clone: 4ian/GDevelop is ~1.7 GB and this job runs on every PR. + # The comparison is against the manifest's `baselineCommit`, not live + # `upstream/master` — otherwise every upstream push that touched a shared + # file turned this job red with no fork change to review. + # + # The baseline tree is read through the GitHub trees API by commit SHA + # rather than a clone: 4ian/GDevelop is ~1.7 GB and this job runs on every PR. runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/AGENTS.md b/AGENTS.md index 4b75bd258..66f593f9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -350,10 +350,12 @@ clang-tidy, ASan/UBSan, visual tests). ## Fork Divergence -**This fork differs from upstream in 164 paths out of ~6,700.** Expect merge conflicts there. +**This fork differs from upstream in 180 paths out of ~6,700.** Expect merge conflicts there. **`fork-divergence.json` is the authoritative allowlist** of every path the fork intentionally diverges -on, in three buckets (`modified` 110, `forkOnly` 54, `upstreamMissing` 7). It is generated and enforced by +on, in three buckets (`modified` 116, `forkOnly` 57, `upstreamMissing` 7), measured against the +`baselineCommit` the manifest records — not against live `upstream/master`, so upstream's own pushes do +not read as fork divergence. It is generated and enforced by `scripts/check-fork-divergence.js`: the `fork-divergence` job in `.github/workflows/ci.yml` fails when a path diverges that is not listed, or when a listed path stops diverging. Run `node scripts/check-fork-divergence.js` after any change that touches upstream-owned files, and diff --git a/MAINTENANCE.md b/MAINTENANCE.md index cd1cb830d..9e4c864e4 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -14,14 +14,15 @@ rather than trusting them if the tree has moved. ### What diverges -The fork diverges on **164 paths** out of ~6,700. `fork-divergence.json` is the -checked-in allowlist, in three buckets: +The fork diverges on **180 paths** out of ~6,700. `fork-divergence.json` is the +checked-in allowlist, in three buckets, measured against the baseline commit the +manifest records: | Bucket | Count | Meaning | |---|---:|---| -| `modified` | 110 | Path exists upstream but the fork's content differs | -| `forkOnly` | 54 | Path does not exist upstream at all | -| `upstreamMissing` | 7 | Path exists upstream but the fork deliberately does not carry it | +| `modified` | 116 | Existed upstream at the baseline and the fork's content differs | +| `forkOnly` | 57 | The fork created it; upstream had no such path at the baseline | +| `upstreamMissing` | 7 | Existed upstream at the baseline and the fork deliberately does not carry it | `upstreamMissing` is the dangerous bucket: an upstream merge re-introduces those files, and nothing else notices. The 7 are: @@ -59,6 +60,36 @@ git fetch --depth=1 upstream master The comparison includes uncommitted work, so `--update` describes the tree you actually have, not just the last commit. +### The baseline commit + +The guard measures against **the baseline commit recorded in the manifest** +(`baselineCommit`), not against live `upstream/master`. That distinction is what +makes it usable: + +- Measured against live upstream, every upstream push that touched a file the + fork also carries read as "new divergence" — a locale file upstream + regenerates, a platformer behavior upstream edits. On 2026-09-23 upstream + pushed 70 such files in one afternoon and the guard went red without the fork + having changed anything. A guard that fires on upstream's own activity gets + ignored, which is exactly when real divergence slips through. +- Measured against a fixed baseline, the reported set only moves when **the + fork's own content** moves. The 66 files above produced zero actionable + signal; they are upstream movement, not fork divergence. + +`baselineCommit` is the last upstream point the fork reconciled with — normally +the merge base of the upstream sync, so it advances when a sync PR merges and the +reported set re-derives from the newly-merged tree. Resolution order when the +field is absent: `git merge-base HEAD upstream/master`. Both paths are stable +across upstream pushing between syncs, which is the property the guard needs. + +`--update` records the resolved baseline, and additionally drops entries that +have *converged*: a path the fork once modified but which now matches the +baseline (an upstream fix adopted by the merge, say) is no longer divergence and +must leave the manifest, or the next `--update` keeps re-listing it. If the +guard reports the manifest baseline differing from the comparison baseline, a +bucket may look stale because the two describe different upstream points — run +`--update` to re-anchor. + ### Why the manifest excludes itself `fork-divergence.json` and `.sonarcloud-drift-baseline` are filtered out of the @@ -73,7 +104,8 @@ game, including files the fork added. commit message. A future sync reviewer reads that manifest, not the PR. Do not add an entry to silence the guard for something accidental. Revert it -instead — that is the entire point. +instead — that is the entire point. Note that `--update` regenerates the whole +manifest: review the diff for entries it *removed*, not just those it added. --- diff --git a/docs/HERETEK-ROADMAP.md b/docs/HERETEK-ROADMAP.md index 8f5a100f8..7cb66823a 100644 --- a/docs/HERETEK-ROADMAP.md +++ b/docs/HERETEK-ROADMAP.md @@ -16,7 +16,7 @@ each phase below cites them. | **Engine (culling / instancing)** | Scene-level 3D frustum culling, `Model3DInstancePool` + `useInstancing`, TileMap culling for non-Simple maps (nested maps fail safe to full draw) | | **Unlocked client features** | No watermark/splash clamp, network preview, debugger, Heretek release feed | | **Editor UX landed** | Delete key in Instances list, drag-drop image → Sprite on canvas | -| **Divergence hygiene** | 164 intentional paths allowlisted; `fork-divergence.json` + CI guard + daily PR-only upstream sync | +| **Divergence hygiene** | 180 intentional paths allowlisted against a pinned baseline; `fork-divergence.json` + CI guard + daily PR-only upstream sync | Direction A (own AI/BYOK) is substantially delivered as of cycle 34 / `ae815f489`. The work below is what remains to make it *trustworthy* and *maintained*. diff --git a/fork-divergence.json b/fork-divergence.json index faca514e7..c1b62c8d3 100644 --- a/fork-divergence.json +++ b/fork-divergence.json @@ -1,7 +1,8 @@ { "$comment": "Checked-in allowlist of every path this fork intentionally diverges from 4ian/GDevelop on. Regenerate with `node scripts/check-fork-divergence.js --update`. Enforced by `node scripts/check-fork-divergence.js` in CI. See MAINTENANCE.md.", "updatedAt": "2026-09-23", - "upstreamSource": "refs/remotes/upstream/master", + "baselineCommit": "b9109cfbaaaf19c31b5af4d315317fb67d8903c3", + "upstreamSource": "b9109cfbaaaf19c31b5af4d315317fb67d8903c3", "modified": [ ".github/workflows/build-storybook.yml", ".github/workflows/extract-translations.yml", @@ -99,25 +100,15 @@ "newIDE/app/src/Profile/Subscription/SubscriptionChecker.js", "newIDE/app/src/Profile/Subscription/SubscriptionContext.js", "newIDE/app/src/ProjectManager/LoadingScreenEditor.js", - "newIDE/app/src/PropertiesEditor/index.js", - "newIDE/app/src/ResourcesEditor/ResourcePropertiesEditor/index.js", - "newIDE/app/src/ResourcesEditor/Toolbar.js", - "newIDE/app/src/ResourcesEditor/index.js", - "newIDE/app/src/ResourcesList/index.js", "newIDE/app/src/SceneEditor/EditorsDisplay.flow.js", "newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js", "newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js", "newIDE/app/src/SceneEditor/index.js", - "newIDE/app/src/UI/InlineCheckbox.js", - "newIDE/app/src/UI/List.js", - "newIDE/app/src/UI/SortableVirtualizedItemList/ItemRow.js", - "newIDE/app/src/UI/SortableVirtualizedItemList/index.js", "newIDE/app/src/Utils/Analytics/EventSender.js", "newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js", "newIDE/app/src/Utils/GDevelopServices/Authentication.js", "newIDE/app/src/Utils/GDevelopServices/Generation.js", "newIDE/app/src/Utils/GDevelopServices/Usage.js", - "newIDE/app/src/stories/componentStories/ResourcesList/ResourcesList.stories.js", "newIDE/electron-app/app/main.js", "newIDE/electron-app/app/package-lock.json", "newIDE/electron-app/app/package.json", diff --git a/scripts/README.md b/scripts/README.md index bdeac2766..ddbaae8fc 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,12 +8,15 @@ Repository-wide tooling. Per-package scripts live in each package's own See [MAINTENANCE.md](../MAINTENANCE.md) for how these fit together. - **check-fork-divergence.js**: keeps the fork's divergence from upstream - `4ian/GDevelop` bounded. Compares this tree against upstream and checks the - result against the checked-in `fork-divergence.json` allowlist; exits 1 on a - path that diverges without being listed or a listed path that no longer - diverges. `--update` rewrites the allowlist, `--list` prints the divergence - set. Runs in `ci.yml` (`fork-divergence` job) and, informationally, in - `upstream-sync.yml`. + `4ian/GDevelop` bounded. Compares this tree against the **baseline commit** + recorded in `fork-divergence.json` (falling back to + `git merge-base HEAD upstream/master`) and checks the result against the + checked-in allowlist; exits 1 on a path that diverges without being listed + or a listed path that no longer diverges. Measuring against a fixed baseline + rather than live `upstream/master` is what keeps upstream's own pushes from + reading as fork divergence. `--update` rewrites the allowlist and records the + baseline, `--list` prints the divergence set. Runs in `ci.yml` + (`fork-divergence` job) and, informationally, in `upstream-sync.yml`. - **sonarcloud-drift.js**: compares the project's open SonarCloud issue count against `.sonarcloud-drift-baseline` and fails when growth exceeds the threshold. Self-seeds the baseline when it is missing and writes `count` / diff --git a/scripts/check-fork-divergence.js b/scripts/check-fork-divergence.js index c4d07b6ae..9481fdf88 100755 --- a/scripts/check-fork-divergence.js +++ b/scripts/check-fork-divergence.js @@ -25,12 +25,26 @@ * node scripts/check-fork-divergence.js --update # rewrite the allowlist * node scripts/check-fork-divergence.js --list # print the diff, exit 0 * - * Upstream tree lookup, in order of preference: - * 1. `git ls-tree` of a local upstream ref (`upstream/master`, then the - * `origin/upstream-sync-*` branches the sync workflow pushes), when one - * exists and is reachable; - * 2. the GitHub trees API (`GH_TOKEN` / `GITHUB_TOKEN`), which needs no - * clone of the ~1.7 GB upstream repository. + * The comparison is against the *baseline commit* recorded in the manifest, + * not against live `upstream/master`. Comparing against a moving ref made the + * guard fire on every upstream push that touched a shared file — upstream + * advancing a file the fork had never modified reads as "new divergence", + * which is not something a reviewer can act on. The baseline is the point the + * fork last reconciled with upstream, so the reported set only changes when + * the fork's own content does. + * + * Baseline resolution, in order of preference: + * 1. the manifest's `baselineCommit`, when set (the normal case); + * 2. `git merge-base HEAD upstream/master` — the fork merges upstream + * rather than rebasing, so this is the last reconciled point. Used once + * to seed a manifest that predates the field. + * When the baseline cannot be resolved, the guard falls back to live upstream + * and says so, rather than failing. + * + * Baseline tree lookup, in order of preference: + * 1. `git ls-tree` of the local commit, when it is available locally; + * 2. the GitHub trees API by commit SHA (`GH_TOKEN` / `GITHUB_TOKEN`), which + * needs no clone of the ~1.7 GB upstream repository. * * Exit codes: 0 clean, 1 divergence mismatch, 2 fatal error. */ @@ -106,38 +120,58 @@ function localTree() { return map; } -/** Upstream tree as { path: blobSha }, via a local ref when possible. */ -function upstreamTree() { - const candidates = ['refs/remotes/upstream/master', 'refs/heads/upstream/master']; - for (const ref of candidates) { - try { - git('rev-parse', '--verify', '--quiet', ref); - } catch (_) { - continue; - } - const out = git('ls-tree', '-r', '-z', ref); - const map = new Map(); - for (const entry of out.split('\0')) { - if (!entry) continue; - const tab = entry.indexOf('\t'); - if (tab === -1) continue; - const meta = entry.slice(0, tab).split(' '); - if (meta[1] === 'blob') map.set(entry.slice(tab + 1), meta[2]); - } - return { source: ref, tree: map }; +function revParse(ref) { + try { + return git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`).trim(); + } catch (_) { + return null; } +} - // Fall back to the GitHub trees API. No upstream clone required. - const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (!token) { - throw new Error( - 'No local upstream ref and no GH_TOKEN/GITHUB_TOKEN. Either run\n' + - ' git remote add upstream https://github.com/4ian/GDevelop.git\n' + - ' git fetch --depth=1 upstream master\n' + - 'or export GH_TOKEN.' - ); +/** Local tree of a commit-ish as { path: blobSha }, or null when absent. */ +function treeOfCommit(ref) { + // Probe first: `git ls-tree` on a missing object writes "fatal: not a tree + // object" to stderr, which is noise in CI where the baseline is expected to + // be absent and the API is the intended path. + if (!revParse(ref)) return null; + let out; + try { + out = git('ls-tree', '-r', '-z', ref); + } catch (_) { + return null; } - const url = `https://api.github.com/repos/${UPSTREAM_REPO}/git/trees/${UPSTREAM_REF}?recursive=1`; + const map = new Map(); + for (const entry of out.split('\0')) { + if (!entry) continue; + const tab = entry.indexOf('\t'); + if (tab === -1) continue; + const meta = entry.slice(0, tab).split(' '); + if (meta[1] === 'blob') map.set(entry.slice(tab + 1), meta[2]); + } + return map; +} + +/** The upstream commit the fork is being compared against. */ +function mergeBaseCommit() { + for (const ref of ['refs/remotes/upstream/master', 'refs/heads/upstream/master']) { + const refSha = revParse(ref); + if (!refSha) continue; + try { + const base = git('merge-base', 'HEAD', refSha).trim(); + if (base) return base; + } catch (_) { + // No shared history locally (shallow clone) — a baselineCommit in the + // manifest is the way out. + } + } + return null; +} + +/** GitHub trees API by commit SHA. No upstream clone required. */ +function apiTree(ref) { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) return null; + const url = `https://api.github.com/repos/${UPSTREAM_REPO}/git/trees/${ref}?recursive=1`; const body = execFileSync( 'curl', ['-fsSL', '-H', `Authorization: Bearer ${token}`, '-H', 'Accept: application/vnd.github+json', url], @@ -146,19 +180,100 @@ function upstreamTree() { const json = JSON.parse(body); if (json.truncated) { throw new Error( - `Upstream tree listing was truncated by the GitHub API; use a local upstream ref instead.` + `Upstream tree listing was truncated by the GitHub API; use a local upstream commit instead.` ); } const map = new Map(); for (const entry of json.tree) { if (entry.type === 'blob') map.set(entry.path, entry.sha); } - return { source: `api:${UPSTREAM_REPO}@${UPSTREAM_REF}`, tree: map }; + return map; } +/** + * Resolve the baseline commit and its tree. + * + * `manifest` may be null while seeding (`--update` on a manifest that has no + * baseline yet). The returned `source` is for reporting, and `note` carries + * any caveat worth surfacing (fallback to live upstream, missing baseline). + */ +function baselineTree(manifest) { + const wanted = (manifest && manifest.baselineCommit) || null; + + const commit = wanted || mergeBaseCommit(); + if (!commit) { + // Nothing to anchor on. Compare against live upstream so the guard still + // reports something, but be explicit that the number is upstream churn. + const refSha = revParse('refs/remotes/upstream/master'); + if (!refSha) { + throw new Error( + 'Cannot resolve an upstream baseline. Either set `baselineCommit` in\n' + + `${MANIFEST_NAME}, or provide upstream locally:\n` + + ' git remote add upstream https://github.com/4ian/GDevelop.git\n' + + ' git fetch --depth=1 upstream master\n' + + 'or export GH_TOKEN.' + ); + } + return { + source: `live:${UPSTREAM_REPO}@${UPSTREAM_REF}`, + commit: refSha, + tree: treeOfCommit(refSha), + note: + 'no baseline commit — compared against live upstream/master, so upstream\n' + + 'pushes that touch shared files appear below', + }; + } + + const local = treeOfCommit(commit); + if (local) return { source: commit, commit, tree: local }; + + const viaApi = apiTree(commit); + if (viaApi) return { source: `api:${commit}`, commit, tree: viaApi }; + + // Baseline recorded but unreachable. Falling back to live upstream would + // silently reintroduce the false-positive churn this baseline exists to + // avoid, so a recorded baseline that cannot be read is a hard error. + if (wanted) { + throw new Error( + `Baseline commit ${wanted} from ${MANIFEST_NAME} is not available locally\n` + + 'and the GitHub API could not be read. Fetch it or set GH_TOKEN.' + ); + } + return { + source: `live:${UPSTREAM_REPO}@${UPSTREAM_REF}`, + commit, + tree: treeOfCommit(revParse('refs/remotes/upstream/master')), + note: `baseline ${commit} unreachable — compared against live upstream/master`, + }; +} +/** + * Fork divergence, measured against the baseline commit. + * + * For each path, three trees matter: the baseline `B`, the fork `L`, and the + * current upstream `U`. + * + * L == U converged: the fork and upstream agree. Nothing to reconcile, + * and reporting it is what broke the guard (upstream moving a + * file the fork never touched). + * path absent from B the fork created it -> forkOnly + * path in B, absent from L the fork deleted it, still upstream -> upstreamMissing + * otherwise the fork changed an upstream-owned file -> modified + */ function computeDivergence() { const local = localTree(); - const { source, tree: upstream } = upstreamTree(); + let manifest = null; + try { + manifest = readManifest(); + } catch (_) { + // A malformed manifest should not stop `--list`; verification reports it + // through the normal unexpected/stale diff. + } + const { + source, + commit: baseline, + tree: upstream, + note, + } = baselineTree(manifest); const modified = []; const forkOnly = []; @@ -172,8 +287,12 @@ function computeDivergence() { for (const [filePath, sha] of local) { if (selfReferential.has(filePath)) continue; - if (!upstream.has(filePath)) forkOnly.push(filePath); - else if (upstream.get(filePath) !== sha) modified.push(filePath); + if (upstream.get(filePath) === sha) continue; // converged; nothing to reconcile + if (!upstream.has(filePath)) { + forkOnly.push(filePath); + } else if (sha !== upstream.get(filePath)) { + modified.push(filePath); + } } for (const filePath of upstream.keys()) { if (selfReferential.has(filePath)) continue; @@ -183,7 +302,7 @@ function computeDivergence() { modified.sort(); forkOnly.sort(); upstreamMissing.sort(); - return { source, modified, forkOnly, upstreamMissing }; + return { source, baseline, note, modified, forkOnly, upstreamMissing }; } function readManifest() { @@ -209,9 +328,13 @@ function main() { const list = argv.includes('--list'); const actual = computeDivergence(); + const baselineLine = `Baseline commit: ${actual.baseline}`; + const noteLine = actual.note ? `Note: ${actual.note}` : null; if (list) { console.log(`Upstream source: ${actual.source}`); + console.log(baselineLine); + if (noteLine) console.log(noteLine); console.log(`modified: ${actual.modified.length}`); console.log(`forkOnly: ${actual.forkOnly.length}`); console.log(`upstreamMissing: ${actual.upstreamMissing.length}`); @@ -229,6 +352,7 @@ function main() { '4ian/GDevelop on. Regenerate with `node scripts/check-fork-divergence.js --update`. ' + 'Enforced by `node scripts/check-fork-divergence.js` in CI. See MAINTENANCE.md.', updatedAt: new Date().toISOString().slice(0, 10), + baselineCommit: actual.baseline, upstreamSource: actual.source, modified: actual.modified, forkOnly: actual.forkOnly, @@ -244,12 +368,15 @@ function main() { } console.log( `Wrote ${path.relative(REPO_ROOT, MANIFEST_PATH)} ` + - `(${actual.modified.length} modified, ${actual.forkOnly.length} fork-only, ` + + `(baseline ${actual.baseline}, ${actual.modified.length} modified, ` + + `${actual.forkOnly.length} fork-only, ` + `${actual.upstreamMissing.length} upstream-missing)` + (Object.keys(counts).length ? ` delta vs previous: ${JSON.stringify(counts)}` : '') ); } else { - console.log(`Wrote ${path.relative(REPO_ROOT, MANIFEST_PATH)}`); + console.log( + `Wrote ${path.relative(REPO_ROOT, MANIFEST_PATH)} (baseline ${actual.baseline})` + ); } return; } @@ -264,6 +391,16 @@ function main() { } console.log(`Upstream source: ${actual.source}`); + console.log(baselineLine); + if (noteLine) console.log(noteLine); + if (manifest.baselineCommit && manifest.baselineCommit !== actual.baseline) { + console.error( + ` NOTE: manifest baseline ${manifest.baselineCommit} differs from the ` + + `comparison baseline ${actual.baseline}.\n` + + ` A bucket may look stale because the two describe different ` + + `upstream points.` + ); + } const buckets = ['modified', 'forkOnly', 'upstreamMissing']; let failed = false; @@ -298,7 +435,11 @@ function main() { '\nDivergence from upstream changed. Two acceptable resolutions:\n' + ' 1. The new divergence is intended -> record it:\n' + ' node scripts/check-fork-divergence.js --update && git add fork-divergence.json\n' + - ' 2. It was accidental -> revert it, so the file stays in sync with upstream.' + ' 2. It was accidental -> revert it, so the file stays in sync with upstream.\n' + + '\n' + + '`--update` also re-anchors the baseline and drops allowlist entries that\n' + + 'have converged with upstream; review the paths it removes, not just those\n' + + 'it adds.' ); process.exit(1); }