Files
Tobias KoppersGitHubv-work-app[bot] <262237222+v-work-app[bot]@users.noreply.github.com>ClaudeTobias Koppers
a80e0d5668 Fix errors.json merge driver to handle gaps in error codes (#93829)
### What?

Fixes the `errors.json` git merge driver
(`scripts/merge-errors-json/merge.mjs`) so that it allocates new error
ids based on the largest existing numeric key rather than the entry
count. When the current file has gaps in its numeric keys (e.g. retired
error codes), the previous logic produced ids that collided with
existing entries and silently overwrote them during a rebase/merge.

### Why?

The driver computed the next id with:

```js
let nextKey = Object.keys(result).length + 1
```

This only works when ids are dense (`1..N` with no holes).
`packages/next/errors.json` has accumulated gaps over time — for example
missing ids around 1232–1234 and 1246 on `canary`. When a branch was
rebased onto `canary` and the driver replayed a commit that added new
entries, the computed `nextKey` (`length + 1`) pointed at an id that was
already in use higher up in the map. The replayed entries then clobbered
existing canary messages (e.g. "Route encountered %s during the initial
render" and "in a Client Component" at 1244/1245), corrupting
`errors.json` without raising a conflict.

Because the driver writes the result directly and exits 0, this kind of
corruption is invisible at rebase time and only surfaces later when the
displaced messages start producing wrong/duplicate codes at runtime.

### How?

- Introduce a `getMaxNumericKey(map)` helper that scans the keys of the
merged map, parses each as an integer, and returns the largest value (or
`0` if there are no numeric keys).
- Use `getMaxNumericKey(result) + 1` as the starting `nextKey` in
`mergeErrors`, so newly added messages are always appended above the
highest currently-used id, regardless of gaps.

Behavior for the dense / no-gap case is unchanged: `max + 1 === length +
1` when ids are `1..N` contiguous, so all three existing scenarios in
`test/scripts/merge-errors-json/merge-errors-json.test.ts` (basic
conflict, empty base, stacked PRs) continue to pass.

No changes to `errors.json` itself are included here; this PR only fixes
the driver going forward.

<!-- NEXT_JS_LLM_PR -->

Co-authored-by: v-work-app[bot] <262237222+v-work-app[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Tobias Koppers <[email protected]>
2026-05-25 20:29:01 +02:00

139 lines
3.8 KiB
JavaScript

// @ts-check
/**
* Git merge driver for packages/next/errors.json
*
* This script automatically resolves merge conflicts in the auto-generated
* errors.json file by reassigning error codes to avoid conflicts.
*
* Usage: node merge-errors-json.mjs <current> <base> <other> [<marker-size>]
*
* Arguments:
* - current: Path to the current version (our changes)
* - base: Path to the common ancestor version
* - other: Path to the other version (their changes)
* - marker-size: Size of conflict markers (optional, defaults to 7)
*
* Exit codes:
* - 0: Merge successful, result written to current file
* - 1: Merge failed, conflicts remain
*/
import { readFileSync, writeFileSync } from 'node:fs'
function main() {
const args = process.argv.slice(2)
if (args.length < 3) {
console.error(
'Usage: node merge-errors-json.mjs <current> <base> <other> [<marker-size>]'
)
process.exit(1)
}
const [currentPath, basePath, otherPath] = args
try {
const base = readJsonSync(basePath)
const current = readJsonSync(currentPath)
const other = readJsonSync(otherPath)
const merged = mergeErrors(base, current, other)
// Git expects the result to be written to the "current" file
writeJsonSync(currentPath, merged)
const addedCount = Object.keys(merged).length - Object.keys(current).length
console.error(
`merge-errors-json: added ${addedCount === 1 ? '1 new message' : `${addedCount} new messages`} to errors.json`
)
process.exit(0)
} catch (error) {
console.error('merge-errors-json: merge failed:', error.message)
console.error()
console.error(
[
'if this error persists, you can disable the merge driver by running',
'',
' scripts/merge-errors-json/uninstall',
'',
'or by manually removing the `[merge "errors-json"]` section from your .git/config.',
].join('\n')
)
process.exit(1)
}
}
/**
* @typedef {Record<string, string>} ErrorsMap
*/
/**
* Merge three versions of errors.json, resolving conflicts by assigning new sequential IDs
* @param {ErrorsMap} base - Base version (common ancestor)
* @param {ErrorsMap} current - Current version (our changes, or the state of the branch we're rebasing onto)
* @param {ErrorsMap} other - Other version (their changes, or the state the branch we're rebasing, a.k.a. "incoming")
* @returns {ErrorsMap}
*/
function mergeErrors(base, current, other) {
/** @type {ErrorsMap} */
const result = { ...current }
/** @type {Set<string>} */
const existingMessages = new Set(Object.values(result))
let nextKey = getMaxNumericKey(result) + 1
for (const message of getNewMessages(base, other)) {
if (existingMessages.has(message)) {
continue
}
const key = nextKey++
result[key] = message
existingMessages.add(message)
}
return result
}
/**
* Returns the largest numeric key in the map, or 0 if there are no numeric
* keys. Existing entries may have gaps (e.g. when error codes were retired),
* so we can't rely on `Object.keys(result).length` to derive the next id.
* @param {ErrorsMap} map
*/
function getMaxNumericKey(map) {
let max = 0
for (const key of Object.keys(map)) {
const n = Number(key)
if (Number.isInteger(n) && n > max) {
max = n
}
}
return max
}
function getNewMessages(
/** @type {ErrorsMap} */ prev,
/** @type {ErrorsMap} */ current
) {
const existing = new Set(Object.values(prev))
return Object.values(current).filter((msg) => !existing.has(msg))
}
function readJsonSync(/** @type {string} */ filePath) {
const content = readFileSync(filePath, 'utf8')
return JSON.parse(content)
}
function writeJsonSync(
/** @type {string} */ filePath,
/** @type {any} */ value
) {
const content = JSON.stringify(value, null, 2) + '\n'
writeFileSync(filePath, content, 'utf8')
}
main()