diff --git a/.changeset/react-runtime-peer-upper-bound-3741.md b/.changeset/react-runtime-peer-upper-bound-3741.md new file mode 100644 index 000000000..a38a7ef87 --- /dev/null +++ b/.changeset/react-runtime-peer-upper-bound-3741.md @@ -0,0 +1,13 @@ +--- +'@object-ui/react-runtime': patch +--- + +Give `@object-ui/react-runtime`'s React peer range an upper bound: `peerDependencies.react` narrows from `>=18` to `^18.0.0 || ^19.0.0`, the spelling the other 30 react peers in the fixed version group already declare (objectui#3741). + +An unbounded range is a promise that grows on its own. `>=18` satisfies React 20 the day React 20 is published — a major this package has never been built against, let alone tested — and it makes that claim from an already-published manifest, with no commit and no review to point at. This package is the least appropriate place in the workspace for such a promise: it vendors react-runner and evaluates author-supplied JSX against the *host's* React, so a host on an untested major does not fail at this package's boundary, it fails somewhere inside the page it rendered. + +Nothing in the package wanted the wider range. Its entire React surface is `Component`, `createElement`, `isValidElement`, `ReactElement` and `ReactNode`, all unchanged in React 19 and none of them touching anything React 19 removed. The range was never a stated constraint either: `>=18` was written when the package was created on 2026-06-30 (`d23d6ebfa`, PR #2105) and no commit since revisited the line. The workspace pins React to `19.2.8` through the root `pnpm.overrides`, so 19 is the only major this package's tests have ever exercised — the unbounded upper end was untested by construction. + +The README sentence restating the range moves with it, and the doc gate that compares the two (`doc-version-claims.test.ts`, objectui#3717) keeps them in step from here. + +A new pin, `scripts/__tests__/react-peer-range-norm-3741.test.ts`, now asserts the norm across every workspace manifest, because this was the third package born off-norm and the first two were each corrected in isolation: `plugin-dashboard` was born narrow and fixed on 2026-05-08 (`d2b6ecec6`), `plugin-report` was born narrow and fixed in objectui#3690 (PR #3727) after a React 19 consumer hit `ERESOLVE` on install, and this one was born unbounded. The existing doc gate could not have caught any of them — it checks a README against its own manifest, and react-runtime's two sides agreed with each other while both said `>=18`. diff --git a/packages/react-runtime/README.md b/packages/react-runtime/README.md index e062177db..0d5903e59 100644 --- a/packages/react-runtime/README.md +++ b/packages/react-runtime/README.md @@ -24,7 +24,7 @@ lazy-load it behind a capability flag. npm install @object-ui/react-runtime ``` -`react >= 18` is a peer dependency. +`react ^18.0.0 || ^19.0.0` is a peer dependency. ## Usage diff --git a/packages/react-runtime/package.json b/packages/react-runtime/package.json index f3d2c8c5d..c9bab01ee 100644 --- a/packages/react-runtime/package.json +++ b/packages/react-runtime/package.json @@ -28,7 +28,7 @@ "sucrase": "^3.35.0" }, "peerDependencies": { - "react": ">=18" + "react": "^18.0.0 || ^19.0.0" }, "devDependencies": {} } diff --git a/scripts/__tests__/doc-version-claims.test.ts b/scripts/__tests__/doc-version-claims.test.ts index 457b581c2..41727a232 100644 --- a/scripts/__tests__/doc-version-claims.test.ts +++ b/scripts/__tests__/doc-version-claims.test.ts @@ -56,9 +56,14 @@ import { fileURLToPath } from 'node:url'; * untouched, and both directions of the ratchet report green over a README that now * misstates the range. objectui#3690 was exactly that shape, found by a human census * (there the README was right and the MANIFEST lagged it — the assertion is symmetric, it - * names the disagreement, not the guilty side). objectui#3741, still open, proposes - * narrowing react-runtime's manifest range; the day someone does, this test goes red until - * that README follows. + * names the disagreement, not the guilty side). objectui#3741 was the prediction this + * paragraph used to carry as future tense: it narrowed react-runtime's manifest from the + * unbounded `>=18` to the group's `^18.0.0 || ^19.0.0`, and this test did exactly what was + * written here — it went red until that README followed, in the same change. Worth keeping + * as the worked example, because it is also the case that shows what this assertion does + * NOT do: both sides said `>=18` and AGREED, so nothing here objected for the five weeks + * the unbounded range sat in a published manifest. Agreement is not correctness. The norm + * itself is asserted separately, in `react-peer-range-norm-3741.test.ts`. * * Two boundaries, both deliberate, both pinned below: * @@ -554,9 +559,9 @@ const KNOWN_CLAIMS: KnownClaim[] = [ { file: 'packages/react/README.md', claim: PEER_18_19, kind: 'restatement', why: PEER_RESTATEMENT_OK }, { file: 'packages/react-runtime/README.md', - claim: 'react >= 18', + claim: 'react ^18.0.0', kind: 'restatement', - why: 'Restates this package peerDependencies.react, which is literally ">=18" — the one README whose looser spelling is the manifest spelling.', + why: 'Restates this package peerDependencies.react verbatim, in the prose spelling rather than a peer block. Until objectui#3741 both sides read ">=18": the manifest was the last react peer in the workspace with no upper bound, so the README faithfully restated a range that would have claimed React 20 the day it shipped. Narrowing the manifest to the group norm brought this line with it — the restatement was never the defect, the range it restated was.', }, { file: 'packages/plugin-chatbot/README.md', @@ -594,7 +599,7 @@ const RANGE_OPENS = /^[\^~><=\d]/; * - a bullet in a peer-dependency list — 11 of the 12 entries, one spelling between them: * - `react` ^18.0.0 || ^19.0.0 * - one code span read as prose, `packages/react-runtime/README.md` alone: - * `react >= 18` is a peer dependency. + * `react ^18.0.0 || ^19.0.0` is a peer dependency. * * Note what the range is here and is not in the inventory key: the key stops at the first * version token (`^18.0.0`), this reads the range to end of line (`^18.0.0 || ^19.0.0`). @@ -689,19 +694,28 @@ function peerBlockBullets(rel: string): PeerBullet[] | null { /** * Range equality: VERBATIM, up to whitespace INSIDE the range. * - * That one-space normalisation is not a softening, and it exists for a measured reason. - * `packages/react-runtime/README.md` writes `react >= 18` while its manifest writes - * `>=18`. Nothing is drifted there — the two spell the same single comparator and npm - * parses them identically — so byte-strict equality would paint that entry red on a tree - * where nothing is wrong. The only two answers to such a red are to rewrite one side for - * the gate's benefit, or to declare the entry uncovered: a cosmetic edit, or lost - * coverage, in exchange for nothing. + * HISTORY — why the normalisation was written. `packages/react-runtime/README.md` wrote + * `react >= 18` while its manifest wrote `>=18`. Nothing was drifted there — the two + * spell the same single comparator and npm parses them identically — so byte-strict + * equality would have painted that entry red on a tree where nothing was wrong. The only + * two answers to such a red are to rewrite one side for the gate's benefit, or to declare + * the entry uncovered: a cosmetic edit, or lost coverage, in exchange for nothing. * - * Everything this assertion exists to catch survives, because whitespace is the only - * thing dropped: a bumped major, a `||` arm added or removed, `^` turning into `~`, a - * vanished upper bound all still compare unequal. Pinned by its own test below — a - * normaliser that quietly grew to strip operators would make the whole assertion vacuous - * while every other test in this file stayed green. + * CURRENT STATE — that specimen is gone. objectui#3741 narrowed react-runtime's manifest + * to the `^18.0.0 || ^19.0.0` group norm (it was the last react peer in the workspace with + * no upper bound) and brought the README sentence with it, so both sides now spell the + * range identically. Measured on the tree at that change: 21 peer statements resolve to a + * manifest counterpart and ZERO of them differ by whitespace alone. The normaliser is + * therefore exercised only by its own unit test below, not by the corpus. + * + * It stays anyway, and deliberately: the two spellings it equates are equally correct npm + * ranges, so the day a README writes `>= 19` beside a `>=19` manifest the gate should stay + * green rather than demand a cosmetic edit. Tightening it to bytes would buy no new defect + * class — everything this assertion exists to catch survives the normalisation, because + * whitespace is the only thing dropped: a bumped major, a `||` arm added or removed, `^` + * turning into `~`, a vanished upper bound all still compare unequal. Pinned by its own + * test below — a normaliser that quietly grew to strip operators would make the whole + * assertion vacuous while every other test in this file stayed green. */ const sameRange = (a: string, b: string): boolean => a.replace(/\s+/g, '') === b.replace(/\s+/g, ''); @@ -1152,6 +1166,15 @@ describe('doc version claims - the peer-line assertion', () => { dep: 'react-router-dom', range: '^6.0.0 || ^7.0.0', }); + // react-runtime's prose sentence, in the spelling it carries since objectui#3741. + expect(parsePeerStatement(ticked('react ^18.0.0 || ^19.0.0') + ' is a peer dependency.')).toEqual({ + dep: 'react', + range: '^18.0.0 || ^19.0.0', + }); + // The same shape carrying a single comparator. No longer in the corpus — it WAS + // react-runtime's spelling until objectui#3741 narrowed it — but kept because + // RANGE_OPENS admits `>` and a prose line is the shape most likely to be written + // that way again. expect(parsePeerStatement(ticked('react >= 18') + ' is a peer dependency.')).toEqual({ dep: 'react', range: '>= 18', @@ -1175,8 +1198,10 @@ describe('doc version claims - the peer-line assertion', () => { }); it('treats whitespace inside a range as insignificant, and nothing else', () => { - // The one pair the normalisation exists for, and the only place in the corpus where - // README and manifest differ by anything at all. + // The pair the normalisation was written for. It is no longer a corpus pair — since + // objectui#3741 every one of the 21 resolved statements matches its manifest byte for + // byte — so these two cases are now the ONLY thing keeping the normalisation honest. + // See `sameRange` for why it is kept rather than tightened to bytes. expect(sameRange('>= 18', '>=18')).toBe(true); expect(sameRange('^18.0.0 || ^19.0.0', '^18.0.0||^19.0.0')).toBe(true); diff --git a/scripts/__tests__/react-peer-range-norm-3741.test.ts b/scripts/__tests__/react-peer-range-norm-3741.test.ts new file mode 100644 index 000000000..41f5c2d99 --- /dev/null +++ b/scripts/__tests__/react-peer-range-norm-3741.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * objectui#3741: `packages/react-runtime/package.json` declared + * `peerDependencies.react` as `>=18` — no upper bound — while every other react peer + * in the workspace declared `^18.0.0 || ^19.0.0`. An unbounded range does not merely + * look untidy: the day React 20 ships, that manifest starts claiming compatibility with + * a major nobody here has built against, let alone tested, and it makes the claim + * silently, in a published package, with no commit and no review to point at. + * + * Why this is a gate and not just the one-line fix it accompanies: react-runtime is the + * THIRD package born off-norm, and the first two were each corrected in isolation with + * nothing left behind to stop a fourth. + * + * - `plugin-dashboard` was born narrow (`^18.0.0` alone) and corrected on 2026-05-08 + * (`d2b6ecec6`) inside an unrelated build fix. + * - `plugin-report` was born narrow on 2026-02-06 (`1e557cbda`) and corrected in + * objectui#3690 / PR #3727 — by then a React 19 consumer installing the published + * package hit an `ERESOLVE` while every sibling installed clean. + * - `react-runtime` was born unbounded on 2026-06-30 (`d23d6ebfa`, PR #2105) and is + * corrected here. + * + * Three hand-authored manifests, three different wrong spellings, one norm they were all + * supposed to copy. That is an authoring mistake with a pattern, which is the case for + * enforcing the norm mechanically rather than trusting the next author to look. + * + * ## Why the existing peer gate could not catch it + * + * `doc-version-claims.test.ts` already pins peer ranges (objectui#3717, widened in + * #3750) — but it compares a README's peer line to ITS OWN manifest. That check is blind + * to this defect in two independent ways, and react-runtime tripped both: + * + * 1. It judges the two sides against EACH OTHER, not against the norm. react-runtime's + * README said `react >= 18` and its manifest said `>=18`; they agreed, so the gate + * was green for the five weeks the unbounded range sat there. A wrong range spelled + * consistently on both sides is invisible to it by construction. + * 2. It can only see a manifest that has a README peer line beside it. 11 of 39 + * packages carry a peer block; the other 28 declare peers no doc restates, so their + * ranges are judged by nothing at all. + * + * So the two gates ask genuinely different questions. That one asks "does the doc tell + * the truth about the manifest"; this one asks "does the manifest state the range the + * fixed version group agreed on". Neither subsumes the other. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * The one spelling. Not derivable from anything in the tree — it is a convention about + * which React majors this repo builds and tests against — so it is written here as the + * single place that states it, and this test is what makes "declared" mean "enforced". + * + * Both halves of the range are load-bearing and for different reasons. The `^18.0.0` arm + * is the floor the packages actually support. The `^19.0.0` arm is the CEILING: it is + * what stops a published manifest from promising a major that does not exist yet. The + * workspace resolves React through a root `pnpm.overrides` pin (`19.2.8` at the time of + * writing), so 19 is also the only major any of these packages has ever been exercised + * against — an upper bound above it would be a claim with no test behind it. + * + * Moving the norm (adding a `^20.0.0` arm when React 20 is genuinely supported) is + * deliberately a repo-wide act: every declaration moves together or this test goes red. + * That is the intent. A fixed version group that ships 39 packages from one release + * cannot have per-package React support windows without the difference being a lie + * somewhere. + */ +const REACT_PEER_NORM = '^18.0.0 || ^19.0.0'; + +/** The React packages the norm governs. Both were widened together in objectui#3690. */ +const GOVERNED = ['react', 'react-dom'] as const; + +interface WorkspacePackage { + name: string; + dir: string; + peerDependencies: Record; +} + +/** + * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded so a new + * workspace root is covered the day it is added — and throwing rather than skipping on a + * shape it does not understand, because a guard that quietly stops looking at part of the + * workspace keeps reporting success over a shrinking surface. Same reasoning, and the + * same two supported shapes (`dir/*` and a bare `dir`), as + * `workspace-peer-dependency-edges.test.ts`. + */ +function workspaceGlobs(): string[] { + const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); + const lines = yaml.split('\n'); + const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); + expect(start, '`pnpm-workspace.yaml` must still declare a top-level `packages:` key').toBeGreaterThan(-1); + + const globs: string[] = []; + for (const line of lines.slice(start + 1)) { + if (/^\s*(#.*)?$/.test(line)) continue; + // A non-indented line ends the `packages:` block. + if (!/^\s/.test(line)) break; + const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); + if (!match) { + throw new Error( + `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`, + ); + } + globs.push(match[1]); + } + return globs; +} + +function readWorkspacePackages(): WorkspacePackage[] { + const found: WorkspacePackage[] = []; + for (const glob of workspaceGlobs()) { + let dirs: string[]; + if (glob.endsWith('/*')) { + const parent = path.join(repoRoot, glob.slice(0, -2)); + dirs = fs.existsSync(parent) + ? fs + .readdirSync(parent) + .map((d) => path.join(parent, d)) + .filter((d) => fs.statSync(d).isDirectory()) + : []; + } else if (!glob.includes('*')) { + dirs = [path.join(repoRoot, glob)]; + } else { + throw new Error(`Unsupported workspace glob ${JSON.stringify(glob)} — teach this guard how to expand it.`); + } + + for (const dir of dirs) { + const pkgPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgPath)) continue; + const json = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!json.name) continue; + found.push({ + name: json.name, + dir: path.relative(repoRoot, dir), + peerDependencies: json.peerDependencies ?? {}, + }); + } + } + return found; +} + +const packages = readWorkspacePackages(); + +interface Declaration { + pkg: WorkspacePackage; + dep: string; + range: string; +} + +const declarations: Declaration[] = packages.flatMap((pkg) => + GOVERNED.filter((dep) => pkg.peerDependencies[dep] !== undefined).map((dep) => ({ + pkg, + dep, + range: pkg.peerDependencies[dep], + })), +); + +describe('React peer ranges state the fixed version group norm', () => { + it('discovers the workspace and its React peers (guard cannot pass by finding nothing)', () => { + // Without these floors a broken glob parser or a moved directory would turn the + // assertions below into a vacuous pass over an empty list — the failure mode that + // makes a green gate worthless. + expect(packages.length, 'the workspace globs resolved to implausibly few packages').toBeGreaterThan(30); + + // Measured on the objectui#3741 branch: 31 `react` + 25 `react-dom` = 56. A floor + // rather than a pin, because this legitimately shrinks when a package stops needing + // the host to supply React. + expect( + declarations.length, + 'implausibly few React peer declarations were found - the manifest read collapsed and ' + + 'this gate is now green over nothing', + ).toBeGreaterThanOrEqual(50); + }); + + it('declares every React peer as the norm, with no exemptions', () => { + const violations = declarations + .filter((d) => d.range !== REACT_PEER_NORM) + .map((d) => `${d.pkg.name} (${d.pkg.dir}/package.json) declares "${d.dep}": ${JSON.stringify(d.range)}`); + + expect( + violations, + [ + `Every React peer range in this workspace must read exactly ${JSON.stringify(REACT_PEER_NORM)}:`, + '', + ...violations, + '', + 'There is deliberately NO exemption list here, and there should not become one: at the', + 'time of writing all 56 declarations satisfy this, so an exemption would be', + 'indistinguishable from the bug it was added to hide (the same reasoning as', + 'workspace-peer-dependency-edges.test.ts).', + '', + 'An unbounded range (`>=18`) is the specific defect objectui#3741 fixed - it claims a', + 'major that does not exist yet. A narrow one (`^18.0.0` alone) is objectui#3690 - it', + 'makes a React 19 consumer hit ERESOLVE on install. Both spellings have shipped from', + 'this repo before; neither is a matter of taste.', + '', + 'If React 20 support is real, widen the norm above and every declaration in the same', + 'change, and say so in a changeset - the version group releases as one.', + ].join('\n'), + ).toEqual([]); + }); + + it('pins the package fixed in objectui#3741', () => { + // The general assertion above catches a regression here too, but naming it means a + // revert points straight at the issue that explains why the bound exists, rather than + // at a rule someone has to go and read. + const pkg = packages.find((p) => p.name === '@object-ui/react-runtime'); + expect(pkg, '@object-ui/react-runtime must exist in the workspace').toBeDefined(); + expect( + pkg!.peerDependencies.react, + 'react-runtime declared an unbounded `>=18` from its creation (PR #2105) until ' + + 'objectui#3741. It vendors react-runner and evaluates JSX against the host React, ' + + 'so it is exactly the package with no business promising a major it has never seen.', + ).toBe(REACT_PEER_NORM); + }); +});