Skip to content

Commit 804a2b0

Browse files
fix(metadata-core): an exclusive protocol comparator ('>18', '<18') names the major it admits in the refusal (#26595)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XCr4VkQzTohFoSHcnQxd1WY)_ Fixes #26546 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:包的 `engines.protocol` 用排他比较符时,`OS_PROTOCOL_INCOMPATIBLE` 拒绝里的目标主版本按比较方向读。在 18 的运行时上,`'>18'` 现在算“包比运行时新”,提示升级运行时到 19,不再给 `migrate meta`;`'<18'` 现在提示 `migrate meta --from 17`,不再是 `--from 18`。 - **为什么改**:原来把 `>`、`<` 当成 `^` 剥掉,两种写法都得到 `--from 18`,也就是“从运行时自己的主版本迁移”,运维照做没有用。 - **风险与回滚**:低。哪些范围被接受、哪些被拒绝完全不变(新旧读法在 17.8 万组范围与主版本组合上对照,准入结论零差异);只改被拒绝时的目标主版本和提示。附带两处 CLI 读者跟着变对:`objectstack migrate meta` 迁到 18 时会把 `'<18'` 改写成 `'^18'`(与 `'<=17'` 一致),版本差提示也会为它出声。回滚即 revert 本 PR。 - **先看**:`packages/metadata-core/src/protocol-handshake.ts`(`alternativeMajorSpan`、`comparatorMajorSpan`、`declaredTargetMajor`);`packages/metadata-core/src/protocol-handshake.test.ts` 末尾的 `[#26546]` 用例。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-core exec vitest run src/protocol-handshake.test.ts`,预期 46/46 通过,其中 `[#26546]` 九条。 - **需要你做的**:无,席位落地。 ## What changes `checkProtocolCompat` (`@objectstack/metadata-core`) names a refused range's target major (`diagnostic.targetMajor`, also `error.details.targetMajor` on both install doors) and, for a package behind the runtime, `objectstack migrate meta --from N`. `declaredFloorMajor` found that major by peeling every leading `^ ~ < > =` and reading the first number, so a comparator's direction was lost. The single-range grammar is now read once, as a span of admitted majors: - `comparatorMajorSpan` (was `comparatorAdmitsMajor`) returns the interval one comparator admits (examples with 18): `'>=18'` is 18 and up, bare `'>18'` is 19 and up, `'>18.0.0'` is 18 and up, `'<=18'` is 18 and below, `'<18'` / `'<18.0.0'` is 17 and below, `'<18.5'` is 18 and below. These are exactly the cases the old `switch` encoded. - `alternativeMajorSpan` (was the body of `alternativeAdmitsMajor`) returns one `||` alternative's span: compound ranges intersect their comparators, hyphen ranges are `[lo, hi]`, `^`, `~`, wildcard and exact pin one major, `*` is open both ways. - `alternativeAdmitsMajor` asks whether the span holds the runtime major. `rangeAdmitsMajor` is unchanged. - `declaredTargetMajor` (was `declaredFloorMajor`) reads the same span: its floor, or for a range open below, its ceiling. A `||` union still takes its lowest alternative. A span that holds no major (`'>=19 <15'`, `'<0'`) names none (`targetMajor: null`, bare `objectstack migrate meta`). `checkProtocolCompat`'s ahead/behind split from #26530 is untouched; it now receives the right major. Answers on an 18 runtime: | Range | Before | After | |---|---|---| | `'>18'` | 18 · `--from 18` | 19 · no `migrateCommand`, "Upgrade the runtime to protocol 19" | | `'<18'`, `'<18.0.0'`, `'<18.0'` | 18 · `--from 18` | 17 · `--from 17` | | `'<18 >=15'` | 18 · `--from 18` | 15 · `--from 15` | | `'>15 <18'` | 15 · `--from 15` | 16 · `--from 16` | | `'<17 \|\| >19'` | 17 · `--from 17` | 16 · `--from 16` | | `'>18 \|\| ^20'` | 18 · `--from 18` | 19 · no `migrateCommand` | | `'>=19 <15'` (admits nothing) | 19 · no `migrateCommand` | null · `objectstack migrate meta` | | `'>=19'`, `'<=17'`, `'^17'`, `'~17.2'`, `'17.x'`, `'17'`, `'15 - 17'`, `'>=15 <18'`, `'^17 \|\| ^16'` | unchanged | unchanged | On a 17 runtime `'>18'` used to say "Upgrade the runtime to protocol 18", which `'>18'` does not admit; it now says 19. Partial bounds keep their major: `'<18.5'` on 19 targets 18 and `'>18.1'` on 17 targets 18, as before. The comments on `ProtocolIncompatibleDiagnostic.targetMajor` and `.migrateCommand` now describe the span reading. Changeset: `@objectstack/metadata-core` patch. ## Why Card #26546, ruled by triage `6436651479`: on an 18 runtime, `'>18'` (the package is ahead) and `'<18'` (the package is behind) were both refused with `migrate meta --from 18`, which cannot apply in either case. Reading the target from the same span the admit verdict uses means the verdict and the prescription cannot read one comparator two ways. ## Tests Red first on `main` at `7dd5a2ffa` (#26530 merged), the new `[#26546]` block in `protocol-handshake.test.ts`: **7 failed, 38 passed (45)**. The two inclusive controls (`'>=19'`, `'<=17'`) passed on `main`, as the ruling expects. The failures were `expected 18 to be 19` (`'>18'`), `expected 18 to be 17` (`'<18'`), `expected 18 to be 15` (`'<18 >=15'`), `expected 17 to be 16` (union), and the `'>18'` HTTP answer carrying `migrateCommand`. After the fix: **46/46** (one more case added after the red run: a range that admits no major names none). Ablation (fix committed, `scripts/ablation-replace.mjs`): `const major = empty ? null : (span.lo ?? span.hi);` in `declaredTargetMajor` replaced by the old peel, `leadingMajor(alternative.replace(/^[\^~<>=\s]+/, ''))`. Anchor 1 → 0, blob `d918d6077d87` → `10fd32563699`; **8 failed, 38 passed**, every control green. Restored by the tool; `git diff HEAD` empty afterwards. Admit verdicts do not move: a one-off differential (scratch script, not committed) loaded the `main` copy and this copy of `protocol-handshake.ts` side by side and compared `rangeAdmitsMajor` and `checkProtocolCompat(...).status` over 8,103 ranges (every operator × 15 version tokens, comma and space compounds of every comparator pair, and `||` unions) on runtime majors 0 to 21: **178,266 cases, 0 differences**. Whole package and dependents: - `@objectstack/metadata-core`: `pnpm test` 836/836 (20 files); `pnpm typecheck` (`tsc --noEmit` and `-p tsconfig.test.json`) exit 0. - `@objectstack/cli` (unit): `src/utils/protocol-version-gap.test.ts` and `test/migrate-meta-protocol-range.test.ts`, 35/35. - `@objectstack/runtime`: `src/domains/packages-install-protocol-incompatible.test.ts` and `src/app-plugin.test.ts`, 44/44. - `@objectstack/cloud-connection`: `src/marketplace-install-local-protocol-handshake.test.ts`, 13/13. - `eslint --no-inline-config` on the two touched `.ts` files: exit 0. ## Acceptance notes - **Two CLI readers of `targetMajor` change answer for `'<18'`-style ranges, measured** (throwaway probe in `packages/cli`, not committed; cli's vitest aliases metadata-core to `src`). `planProtocolRange` (`objectstack migrate meta`) used to leave `'<18'` alone when migrating to 18 (target 18 is not below 18, by code reading at `main`); it now rewrites it to `'^18'` from `--from 17`, and with `--from 18` reports `behind-from` naming 17, the same answers it gives `'<=17'` and `'^17'`. `checkProtocolVersionGap` used to stay silent for `'<18'` on an 18 install and now says it "targets protocol v17". `'>18'` stays silent in both. The runtime's artifact boot refusal (`artifact-reference.ts`) names `targetMajor` in its "run a runtime image for protocol N" line, which now reads 19 for `'>18'`. None of these readers' tests moved. - **Declarations.** `Clause-②: no`: no range moves between admitted and refused (the differential above), no exported type changes, and `migrateCommand` was already optional after #26530. `targetMajor` changes value only on refusals, and for the cases where it was wrong; the changeset names every changed reading. `Metadata-protocol: none`: no spec key, schema or stored shape is involved. - **Spec comment, not this lane.** The `OS_PROTOCOL_INCOMPATIBLE` row comment in `packages/spec/src/api/error-code-ledger.zod.ts` lists `migrateCommand` among `error.details` members; it is absent in the ahead case since #26530, and `'>N'` on an N runtime now reaches that case. ADR-0087 D1 describes the refusal carrying "the exact replay command". Both are under the spec seat and are left as they are. - **No open PR touches `protocol-handshake.ts`**: checked the files of all 23 open PRs before opening this one. ## Verification <details><summary>Derived gate families (dispatch-gates.mjs --commands, run at b17e3c3)</summary> 59 derived, 58 run with exit 0, 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3 (prerequisite not met: it needs a full workspace build; CI runs it). `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 because the shallow clone could not read its pinned fixture commit `621a4876`; after `git fetch origin 621a487…` it exited 0 ("48 cases pass"). `dispatch-gates.mjs --ran`: "59 derived famil(ies) accounted for — 58 run, 1 NOT-MEASURED". The final head `504665209` differs from `b17e3c33` only in one prose line of the changeset. On the final head, with this body as the event: `check-adr-0087-registration --base origin/main` exit 0 ("this PR adds no declared-breaking changeset"); `check-changeset-no-major --base origin/main --event` exit 0 (`Metadata-protocol: none`, `patch`); `check-empty-changeset --base origin/main` exit 0; `check:nul-bytes` exit 0; `check-closing-keyword-parity --body` binds `Fixes #26546` only. </details> ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Scoped to the touched files; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5p97A4ysuQjSwrSaWT --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5p97A4ysuQjSwrSaWT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb91e97 commit 804a2b0

3 files changed

Lines changed: 184 additions & 36 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
---
4+
5+
fix(metadata-core): an exclusive protocol comparator (`'>18'`, `'<18'`) names the major it admits in the `OS_PROTOCOL_INCOMPATIBLE` refusal
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The refusal for a package whose `engines.protocol` excludes this runtime names a target major (`targetMajor` in the diagnostic and in `error.details`) and, for a package behind the runtime, `objectstack migrate meta --from N`. It used to find the target by peeling every leading operator off the range as if it were `^`, so on a protocol-18 runtime both `'>18'` (19 and up) and `'<18'` (17 and below) answered `migrate meta --from 18`, a migration from the runtime's own major.
11+
12+
Now the target is read from the majors the range admits, with the same grammar that decides whether it admits the runtime:
13+
14+
- **`'>18'` on an 18 runtime** targets 19. The package is ahead, so the answer is the upgrade remedy from the previous release: "Upgrade the runtime to protocol 19", and no `migrateCommand`. On a 17 runtime it now names protocol 19 too, where it used to name 18, which `'>18'` does not admit.
15+
- **`'<18'` and `'<18.0.0'`** target 17: `migrate meta --from 17`.
16+
- **A compound range** targets its floor in either order: `'<18 >=15'` targets 15 (it named 18), and `'>15 <18'` targets 16 (it named 15).
17+
- **A `||` union** still targets its lowest alternative, now read the same way: `'<17 || >19'` targets 16.
18+
- **A range that admits no major** (`'>=19 <15'`, `'<0'`) has `targetMajor: null` and the bare `objectstack migrate meta`.
19+
20+
`^`, `~`, `>=`, `<=`, wildcards, exact versions, hyphen ranges and partial bounds (`'>18.1'`, `'<18.5'`) answer as before. No range changes between admitted and refused: only the refusal's target and prescription change.
21+
22+
Two CLI readers of `targetMajor` follow: `objectstack migrate meta` now rewrites a `'<18'` range to `'^18'` when it migrates a stack to protocol 18 (it used to leave it for the load to refuse), and the protocol-version advisory now speaks for such a range.

‎packages/metadata-core/src/protocol-handshake.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,3 +455,92 @@ describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to
455455
expect(refuse('^10 || ^12').migrateCommand).toBe('objectstack migrate meta --from 10');
456456
});
457457
});
458+
459+
describe('[#26546] the refusal reads the comparator direction, so an exclusive bound names the major it admits', () => {
460+
// A bare `'>N'` admits N+1 and up, and `'<N'` admits N-1 and below. Peeling
461+
// the operator off as if it were `^` named N for both, and on an N runtime
462+
// that is `migrate meta --from N`, a migration from the runtime's own major.
463+
const refuse = (range: string, runtime = '18.0.0') => {
464+
const r = checkProtocolCompat({ id: 'com.acme.pkg', engines: { protocol: range } }, runtime);
465+
expect(r.status).toBe('incompatible');
466+
if (r.status !== 'incompatible') throw new Error(`'${range}' was not refused under ${runtime}`);
467+
return r.diagnostic;
468+
};
469+
const expectAhead = (range: string, target: number, runtime = '18.0.0') => {
470+
const d = refuse(range, runtime);
471+
expect(d.targetMajor).toBe(target);
472+
expect(d).not.toHaveProperty('migrateCommand');
473+
expect(d.message).not.toContain('migrate meta');
474+
expect(d.message).toContain(`Upgrade the runtime to protocol ${target}`);
475+
};
476+
const expectBehind = (range: string, target: number, runtime = '18.0.0') => {
477+
const d = refuse(range, runtime);
478+
expect(d.targetMajor).toBe(target);
479+
expect(d.migrateCommand).toBe(`objectstack migrate meta --from ${target}`);
480+
expect(d.message.endsWith(`Run: objectstack migrate meta --from ${target}`)).toBe(true);
481+
};
482+
483+
it("'>18' on an 18 runtime is a package ahead of it: protocol 19, no migrateCommand", () => {
484+
expectAhead('>18', 19);
485+
});
486+
487+
it("'<18' on an 18 runtime is a package behind it: --from 17", () => {
488+
expectBehind('<18', 17);
489+
});
490+
491+
it("CONTROL: the inclusive '>=19' and '<=17' answer as before", () => {
492+
expectAhead('>=19', 19);
493+
expectBehind('<=17', 17);
494+
});
495+
496+
it("names the right upgrade when the runtime is further behind: '>18' on 17 needs protocol 19, not 18", () => {
497+
expectAhead('>18', 19, '17.0.0');
498+
});
499+
500+
it('an explicit floor bound reads as the bare one, and a partial bound keeps its major', () => {
501+
expectBehind('<18.0.0', 17);
502+
expectBehind('<18.0', 17);
503+
// `'<18.5'` admits 18.0 to 18.4, `'>18.1'` admits 18.2 and up: major 18 either way.
504+
expectBehind('<18.5', 18, '19.0.0');
505+
expectAhead('>18.1', 18, '17.0.0');
506+
});
507+
508+
it('a bounded range targets its floor, whichever order its comparators are written in', () => {
509+
expectBehind('>=15 <18', 15);
510+
expectBehind('<18 >=15', 15);
511+
expectBehind('>15 <18', 16);
512+
});
513+
514+
it('a union still targets its lowest alternative', () => {
515+
expectBehind('<17 || >19', 16);
516+
expectBehind('^17 || ^16', 16);
517+
expectAhead('>18 || ^20', 19);
518+
});
519+
520+
it('a range that admits no major names none, so neither remedy points at a wrong one', () => {
521+
for (const range of ['>=19 <15', '<0']) {
522+
const d = refuse(range);
523+
expect(d.targetMajor).toBeNull();
524+
expect(d.migrateCommand).toBe('objectstack migrate meta');
525+
}
526+
});
527+
528+
it('the HTTP answer for the ahead case carries no migrateCommand', () => {
529+
let thrown: unknown;
530+
try {
531+
assertProtocolCompat({ id: 'com.acme.pkg', engines: { protocol: '>18' } }, '18.0.0', () => {});
532+
} catch (e) {
533+
thrown = e;
534+
}
535+
expect(isProtocolIncompatibleError(thrown)).toBe(true);
536+
const answer = protocolIncompatibleAnswer(thrown as ProtocolIncompatibleError);
537+
expect(answer.status).toBe(422);
538+
expect(answer.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
539+
expect(answer.details).toEqual({
540+
requiredRange: '>18',
541+
rangeSource: 'engines.protocol',
542+
protocolVersion: '18.0.0',
543+
targetMajor: 19,
544+
});
545+
});
546+
});

‎packages/metadata-core/src/protocol-handshake.ts‎

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,18 @@ export interface ProtocolIncompatibleDiagnostic {
8080
runtimeMajor: number;
8181
/**
8282
* The declared major the package targets, when a single major is
83-
* determinable: the range's leading version token, and for a `||` union the
84-
* lowest of its alternatives', whatever order they are written in.
83+
* determinable: the lowest major the range admits, or for a range open
84+
* below (`'<18'`), the highest (#26546). An exclusive bound reads its
85+
* direction: on an 18 runtime `'>18'` targets 19 and `'<18'` targets 17. For
86+
* a `||` union it is the lowest of its alternatives', whatever order they
87+
* are written in.
8588
*/
8689
targetMajor: number | null;
8790
/**
8891
* The command that resolves the refusal (wired end-to-end by ADR-0087 P2),
89-
* present only when there is metadata to move FORWARD: the declared floor
90-
* major is at or below the runtime's, or is not determinable. A package
91-
* whose floor major is ABOVE the runtime's is ahead of it, and
92+
* present only when there is metadata to move FORWARD: `targetMajor` is at
93+
* or below the runtime's, or is not determinable. A package whose
94+
* `targetMajor` is ABOVE the runtime's is ahead of it, and
9295
* `migrate meta` cannot help (#26480): the member is absent and `message`
9396
* names the two remedies that apply, upgrading the runtime or installing a
9497
* build of the package for this runtime's protocol.
@@ -322,78 +325,112 @@ export function rangeAdmitsMajor(range: string, runtimeMajor: number): boolean |
322325
return verdict;
323326
}
324327

325-
/** One `||` alternative: the single-range grammar {@link rangeAdmitsMajor} combines. */
328+
/** One `||` alternative: does its {@link alternativeMajorSpan} hold `runtimeMajor`? */
326329
function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null {
330+
const span = alternativeMajorSpan(r);
331+
if (span === null) return null;
332+
return (span.lo === null || runtimeMajor >= span.lo) && (span.hi === null || runtimeMajor <= span.hi);
333+
}
334+
335+
/**
336+
* The majors one `||` alternative admits, as an inclusive interval; `null` at
337+
* an end leaves that end open (`'<18'` is `{ lo: null, hi: 17 }`, `'*'` is
338+
* open at both).
339+
*/
340+
interface MajorSpan {
341+
lo: number | null;
342+
hi: number | null;
343+
}
344+
345+
/**
346+
* The single-range grammar, read once: {@link alternativeAdmitsMajor} asks
347+
* whether the span holds a major, and {@link declaredTargetMajor} names the
348+
* major a refused range targets from the same span, so the verdict and the
349+
* prescription cannot read one comparator two ways (#26546). `null` when the
350+
* shape is not recognized.
351+
*/
352+
function alternativeMajorSpan(r: string): MajorSpan | null {
327353
if (r === '') return null;
328-
if (r === '*' || r === 'x' || r === 'latest') return true;
354+
if (r === '*' || r === 'x' || r === 'latest') return { lo: null, hi: null };
329355

330-
// Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined).
356+
// Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined):
357+
// the intersection of its comparators' spans.
331358
const parts = r.split(COMPARATOR_SEPARATOR).filter(Boolean);
332359
if (parts.length > 1 && parts.every((p) => /^[<>]=?/.test(p))) {
333-
let ok = true;
360+
let lo: number | null = null;
361+
let hi: number | null = null;
334362
for (const p of parts) {
335-
const admits = comparatorAdmitsMajor(p, runtimeMajor);
336-
if (admits === null) return null;
337-
ok = ok && admits;
363+
const span = comparatorMajorSpan(p);
364+
if (span === null) return null;
365+
if (span.lo !== null) lo = lo === null ? span.lo : Math.max(lo, span.lo);
366+
if (span.hi !== null) hi = hi === null ? span.hi : Math.min(hi, span.hi);
338367
}
339-
return ok;
368+
return { lo, hi };
340369
}
341370

342371
// Hyphen range: "11.0.0 - 12.0.0".
343372
const hyphenParts = hyphenRangeEnds(r);
344373
if (hyphenParts) {
345374
const lo = leadingMajor(hyphenParts[0]);
346375
const hi = leadingMajor(hyphenParts[1]);
347-
if (lo === null || hi === null) return null;
348-
return runtimeMajor >= lo && runtimeMajor <= hi;
376+
return lo === null || hi === null ? null : { lo, hi };
349377
}
350378

351379
// Single comparator.
352-
if (/^[<>]=?/.test(r)) return comparatorAdmitsMajor(r, runtimeMajor);
380+
if (/^[<>]=?/.test(r)) return comparatorMajorSpan(r);
353381

354382
// Caret: `^N` / `^N.x.y` pins the major (for N >= 1, which all protocol
355383
// majors are).
356384
if (r.startsWith('^')) {
357385
const maj = leadingMajor(r.slice(1));
358-
return maj === null ? null : runtimeMajor === maj;
386+
return maj === null ? null : { lo: maj, hi: maj };
359387
}
360388

361389
// Tilde: `~N.x.y` also pins the major for the handshake's purposes.
362390
if (r.startsWith('~')) {
363391
const maj = leadingMajor(r.slice(1));
364-
return maj === null ? null : runtimeMajor === maj;
392+
return maj === null ? null : { lo: maj, hi: maj };
365393
}
366394

367395
// `N.x` / `N.*` wildcard.
368396
const wildcard = r.match(/^(\d+)\.(?:x|\*)/i);
369-
if (wildcard) return runtimeMajor === Number.parseInt(wildcard[1]!, 10);
397+
if (wildcard) {
398+
const maj = Number.parseInt(wildcard[1]!, 10);
399+
return { lo: maj, hi: maj };
400+
}
370401

371402
// Bare exact version or bare major: `11`, `11.0.0`.
372403
const exact = leadingMajor(r);
373-
if (exact !== null && /^\d+(\.\d+){0,2}$/.test(r)) return runtimeMajor === exact;
404+
if (exact !== null && /^\d+(\.\d+){0,2}$/.test(r)) return { lo: exact, hi: exact };
374405

375406
return null;
376407
}
377408

378409
/**
379-
* The major a refused range targets: the major of its leading version token,
380-
* its floor as the upgrade guidance reads it (`'^10'` → 10, `'>=15 <18'` → 15).
381-
* A `||` union targets its LOWEST alternative's, in whatever order the
382-
* alternatives are written: `'^16 || ^17'` and `'^17 || ^16'` are one range and
383-
* get one `migrate meta --from`. A floor above the runtime's major means the
384-
* package is ahead of the runtime, and gets no `migrate meta` at all.
410+
* The major a refused range targets, read from the majors it admits
411+
* ({@link alternativeMajorSpan}): its floor (`'^10'` → 10, `'>=15 <18'` → 15,
412+
* `'>18'` → 19), or, for a range open below, its ceiling (`'<18'` → 17,
413+
* `'<=17'` → 17), the newest major it was written for. A `||` union targets its
414+
* LOWEST alternative's, in whatever order the alternatives are written:
415+
* `'^16 || ^17'` and `'^17 || ^16'` are one range and get one
416+
* `migrate meta --from`. A target above the runtime's major means the package
417+
* is ahead of the runtime, and gets no `migrate meta` at all.
385418
*/
386-
function declaredFloorMajor(range: string): number | null {
419+
function declaredTargetMajor(range: string): number | null {
387420
let lowest: number | null = null;
388421
for (const alternative of rangeAlternatives(range)) {
389-
const major = leadingMajor(alternative.replace(/^[\^~<>=\s]+/, ''));
390-
if (major === null) return null;
422+
const span = alternativeMajorSpan(alternative);
423+
// A span that holds no major at all (`'<0'`, `'>=19 <15'`) has none to name.
424+
const empty = span === null || (span.lo !== null && span.hi !== null && span.lo > span.hi);
425+
const major = empty ? null : (span.lo ?? span.hi);
426+
if (major === null || major < 0) return null;
391427
if (lowest === null || major < lowest) lowest = major;
392428
}
393429
return lowest;
394430
}
395431

396-
function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolean | null {
432+
/** The majors one comparator admits: an interval open at one end. */
433+
function comparatorMajorSpan(comparator: string): MajorSpan | null {
397434
const peeled = peelComparatorOperator(comparator);
398435
if (peeled === null) return null;
399436
const { op, rest } = peeled;
@@ -413,17 +450,17 @@ function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolea
413450
switch (op) {
414451
case '>=':
415452
// `>=maj.*` admits major maj and up.
416-
return runtimeMajor >= maj;
453+
return { lo: maj, hi: null };
417454
case '>':
418455
// bare `>maj` → `>=maj+1` (excludes maj); `>maj.x.y` still admits maj.
419-
return isBare ? runtimeMajor > maj : runtimeMajor >= maj;
456+
return { lo: isBare ? maj + 1 : maj, hi: null };
420457
case '<=':
421458
// `<=maj.*` admits major maj and below.
422-
return runtimeMajor <= maj;
459+
return { lo: null, hi: maj };
423460
case '<':
424461
// `<maj.0.0` (bare or explicit floor) excludes all of major maj;
425462
// `<maj.5.0` still admits maj.
426-
return atMajorFloor ? runtimeMajor < maj : runtimeMajor <= maj;
463+
return { lo: null, hi: atMajorFloor ? maj - 1 : maj };
427464
default:
428465
return null;
429466
}
@@ -451,9 +488,9 @@ export function checkProtocolCompat(
451488
}
452489

453490
const packageId = manifest.id ?? '<unknown>';
454-
const targetMajor = declaredFloorMajor(declared.range);
491+
const targetMajor = declaredTargetMajor(declared.range);
455492
// [#26480] `migrate meta` replays metadata forward from an OLDER major. A
456-
// floor above this runtime's major is a package built for a newer protocol:
493+
// target above this runtime's major is a package built for a newer protocol:
457494
// nothing here can migrate it, so the refusal names what can apply instead.
458495
const ahead = targetMajor !== null && targetMajor > runtimeMajor;
459496
const migrateCommand = ahead

0 commit comments

Comments
 (0)