From 1948f5d59b34591313aad2b32392d751eac5796f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:34:30 +0000 Subject: [PATCH 1/3] fix(metadata-core): an exclusive protocol comparator names the major it admits in the refusal '>18' on an 18 runtime targets 19 and takes the upgrade remedy; '<18' targets 17 and prescribes migrate meta --from 17. The admit verdict and the target major now read one span grammar. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5p97A4ysuQjSwrSaWT --- .../src/protocol-handshake.test.ts | 89 ++++++++++++++ .../metadata-core/src/protocol-handshake.ts | 109 ++++++++++++------ 2 files changed, 162 insertions(+), 36 deletions(-) diff --git a/packages/metadata-core/src/protocol-handshake.test.ts b/packages/metadata-core/src/protocol-handshake.test.ts index d4824f2fa2d..1c001fc1e37 100644 --- a/packages/metadata-core/src/protocol-handshake.test.ts +++ b/packages/metadata-core/src/protocol-handshake.test.ts @@ -455,3 +455,92 @@ describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to expect(refuse('^10 || ^12').migrateCommand).toBe('objectstack migrate meta --from 10'); }); }); + +describe('[#26546] the refusal reads the comparator direction, so an exclusive bound names the major it admits', () => { + // A bare `'>N'` admits N+1 and up, and `' { + const r = checkProtocolCompat({ id: 'com.acme.pkg', engines: { protocol: range } }, runtime); + expect(r.status).toBe('incompatible'); + if (r.status !== 'incompatible') throw new Error(`'${range}' was not refused under ${runtime}`); + return r.diagnostic; + }; + const expectAhead = (range: string, target: number, runtime = '18.0.0') => { + const d = refuse(range, runtime); + expect(d.targetMajor).toBe(target); + expect(d).not.toHaveProperty('migrateCommand'); + expect(d.message).not.toContain('migrate meta'); + expect(d.message).toContain(`Upgrade the runtime to protocol ${target}`); + }; + const expectBehind = (range: string, target: number, runtime = '18.0.0') => { + const d = refuse(range, runtime); + expect(d.targetMajor).toBe(target); + expect(d.migrateCommand).toBe(`objectstack migrate meta --from ${target}`); + expect(d.message.endsWith(`Run: objectstack migrate meta --from ${target}`)).toBe(true); + }; + + it("'>18' on an 18 runtime is a package ahead of it: protocol 19, no migrateCommand", () => { + expectAhead('>18', 19); + }); + + it("'<18' on an 18 runtime is a package behind it: --from 17", () => { + expectBehind('<18', 17); + }); + + it("CONTROL: the inclusive '>=19' and '<=17' answer as before", () => { + expectAhead('>=19', 19); + expectBehind('<=17', 17); + }); + + it("names the right upgrade when the runtime is further behind: '>18' on 17 needs protocol 19, not 18", () => { + expectAhead('>18', 19, '17.0.0'); + }); + + it('an explicit floor bound reads as the bare one, and a partial bound keeps its major', () => { + expectBehind('<18.0.0', 17); + expectBehind('<18.0', 17); + // `'<18.5'` admits 18.0 to 18.4, `'>18.1'` admits 18.2 and up: major 18 either way. + expectBehind('<18.5', 18, '19.0.0'); + expectAhead('>18.1', 18, '17.0.0'); + }); + + it('a bounded range targets its floor, whichever order its comparators are written in', () => { + expectBehind('>=15 <18', 15); + expectBehind('<18 >=15', 15); + expectBehind('>15 <18', 16); + }); + + it('a union still targets its lowest alternative', () => { + expectBehind('<17 || >19', 16); + expectBehind('^17 || ^16', 16); + expectAhead('>18 || ^20', 19); + }); + + it('a range that admits no major names none, so neither remedy points at a wrong one', () => { + for (const range of ['>=19 <15', '<0']) { + const d = refuse(range); + expect(d.targetMajor).toBeNull(); + expect(d.migrateCommand).toBe('objectstack migrate meta'); + } + }); + + it('the HTTP answer for the ahead case carries no migrateCommand', () => { + let thrown: unknown; + try { + assertProtocolCompat({ id: 'com.acme.pkg', engines: { protocol: '>18' } }, '18.0.0', () => {}); + } catch (e) { + thrown = e; + } + expect(isProtocolIncompatibleError(thrown)).toBe(true); + const answer = protocolIncompatibleAnswer(thrown as ProtocolIncompatibleError); + expect(answer.status).toBe(422); + expect(answer.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(answer.details).toEqual({ + requiredRange: '>18', + rangeSource: 'engines.protocol', + protocolVersion: '18.0.0', + targetMajor: 19, + }); + }); +}); diff --git a/packages/metadata-core/src/protocol-handshake.ts b/packages/metadata-core/src/protocol-handshake.ts index de811bf3863..d918d6077d8 100644 --- a/packages/metadata-core/src/protocol-handshake.ts +++ b/packages/metadata-core/src/protocol-handshake.ts @@ -80,15 +80,18 @@ export interface ProtocolIncompatibleDiagnostic { runtimeMajor: number; /** * The declared major the package targets, when a single major is - * determinable: the range's leading version token, and for a `||` union the - * lowest of its alternatives', whatever order they are written in. + * determinable: the lowest major the range admits, or for a range open + * below (`'<18'`), the highest (#26546). An exclusive bound reads its + * direction: on an 18 runtime `'>18'` targets 19 and `'<18'` targets 17. For + * a `||` union it is the lowest of its alternatives', whatever order they + * are written in. */ targetMajor: number | null; /** * The command that resolves the refusal (wired end-to-end by ADR-0087 P2), - * present only when there is metadata to move FORWARD: the declared floor - * major is at or below the runtime's, or is not determinable. A package - * whose floor major is ABOVE the runtime's is ahead of it, and + * present only when there is metadata to move FORWARD: `targetMajor` is at + * or below the runtime's, or is not determinable. A package whose + * `targetMajor` is ABOVE the runtime's is ahead of it, and * `migrate meta` cannot help (#26480): the member is absent and `message` * names the two remedies that apply, upgrading the runtime or installing a * build of the package for this runtime's protocol. @@ -322,21 +325,47 @@ export function rangeAdmitsMajor(range: string, runtimeMajor: number): boolean | return verdict; } -/** One `||` alternative: the single-range grammar {@link rangeAdmitsMajor} combines. */ +/** One `||` alternative: does its {@link alternativeMajorSpan} hold `runtimeMajor`? */ function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null { + const span = alternativeMajorSpan(r); + if (span === null) return null; + return (span.lo === null || runtimeMajor >= span.lo) && (span.hi === null || runtimeMajor <= span.hi); +} + +/** + * The majors one `||` alternative admits, as an inclusive interval; `null` at + * an end leaves that end open (`'<18'` is `{ lo: null, hi: 17 }`, `'*'` is + * open at both). + */ +interface MajorSpan { + lo: number | null; + hi: number | null; +} + +/** + * The single-range grammar, read once: {@link alternativeAdmitsMajor} asks + * whether the span holds a major, and {@link declaredTargetMajor} names the + * major a refused range targets from the same span, so the verdict and the + * prescription cannot read one comparator two ways (#26546). `null` when the + * shape is not recognized. + */ +function alternativeMajorSpan(r: string): MajorSpan | null { if (r === '') return null; - if (r === '*' || r === 'x' || r === 'latest') return true; + if (r === '*' || r === 'x' || r === 'latest') return { lo: null, hi: null }; - // Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined). + // Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined): + // the intersection of its comparators' spans. const parts = r.split(COMPARATOR_SEPARATOR).filter(Boolean); if (parts.length > 1 && parts.every((p) => /^[<>]=?/.test(p))) { - let ok = true; + let lo: number | null = null; + let hi: number | null = null; for (const p of parts) { - const admits = comparatorAdmitsMajor(p, runtimeMajor); - if (admits === null) return null; - ok = ok && admits; + const span = comparatorMajorSpan(p); + if (span === null) return null; + if (span.lo !== null) lo = lo === null ? span.lo : Math.max(lo, span.lo); + if (span.hi !== null) hi = hi === null ? span.hi : Math.min(hi, span.hi); } - return ok; + return { lo, hi }; } // Hyphen range: "11.0.0 - 12.0.0". @@ -344,56 +373,64 @@ function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null if (hyphenParts) { const lo = leadingMajor(hyphenParts[0]); const hi = leadingMajor(hyphenParts[1]); - if (lo === null || hi === null) return null; - return runtimeMajor >= lo && runtimeMajor <= hi; + return lo === null || hi === null ? null : { lo, hi }; } // Single comparator. - if (/^[<>]=?/.test(r)) return comparatorAdmitsMajor(r, runtimeMajor); + if (/^[<>]=?/.test(r)) return comparatorMajorSpan(r); // Caret: `^N` / `^N.x.y` pins the major (for N >= 1, which all protocol // majors are). if (r.startsWith('^')) { const maj = leadingMajor(r.slice(1)); - return maj === null ? null : runtimeMajor === maj; + return maj === null ? null : { lo: maj, hi: maj }; } // Tilde: `~N.x.y` also pins the major for the handshake's purposes. if (r.startsWith('~')) { const maj = leadingMajor(r.slice(1)); - return maj === null ? null : runtimeMajor === maj; + return maj === null ? null : { lo: maj, hi: maj }; } // `N.x` / `N.*` wildcard. const wildcard = r.match(/^(\d+)\.(?:x|\*)/i); - if (wildcard) return runtimeMajor === Number.parseInt(wildcard[1]!, 10); + if (wildcard) { + const maj = Number.parseInt(wildcard[1]!, 10); + return { lo: maj, hi: maj }; + } // Bare exact version or bare major: `11`, `11.0.0`. const exact = leadingMajor(r); - if (exact !== null && /^\d+(\.\d+){0,2}$/.test(r)) return runtimeMajor === exact; + if (exact !== null && /^\d+(\.\d+){0,2}$/.test(r)) return { lo: exact, hi: exact }; return null; } /** - * The major a refused range targets: the major of its leading version token, - * its floor as the upgrade guidance reads it (`'^10'` → 10, `'>=15 <18'` → 15). - * A `||` union targets its LOWEST alternative's, in whatever order the - * alternatives are written: `'^16 || ^17'` and `'^17 || ^16'` are one range and - * get one `migrate meta --from`. A floor above the runtime's major means the - * package is ahead of the runtime, and gets no `migrate meta` at all. + * The major a refused range targets, read from the majors it admits + * ({@link alternativeMajorSpan}): its floor (`'^10'` → 10, `'>=15 <18'` → 15, + * `'>18'` → 19), or, for a range open below, its ceiling (`'<18'` → 17, + * `'<=17'` → 17), the newest major it was written for. A `||` union targets its + * LOWEST alternative's, in whatever order the alternatives are written: + * `'^16 || ^17'` and `'^17 || ^16'` are one range and get one + * `migrate meta --from`. A target above the runtime's major means the package + * is ahead of the runtime, and gets no `migrate meta` at all. */ -function declaredFloorMajor(range: string): number | null { +function declaredTargetMajor(range: string): number | null { let lowest: number | null = null; for (const alternative of rangeAlternatives(range)) { - const major = leadingMajor(alternative.replace(/^[\^~<>=\s]+/, '')); - if (major === null) return null; + const span = alternativeMajorSpan(alternative); + // A span that holds no major at all (`'<0'`, `'>=19 <15'`) has none to name. + const empty = span === null || (span.lo !== null && span.hi !== null && span.lo > span.hi); + const major = empty ? null : (span.lo ?? span.hi); + if (major === null || major < 0) return null; if (lowest === null || major < lowest) lowest = major; } return lowest; } -function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolean | null { +/** The majors one comparator admits: an interval open at one end. */ +function comparatorMajorSpan(comparator: string): MajorSpan | null { const peeled = peelComparatorOperator(comparator); if (peeled === null) return null; const { op, rest } = peeled; @@ -413,17 +450,17 @@ function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolea switch (op) { case '>=': // `>=maj.*` admits major maj and up. - return runtimeMajor >= maj; + return { lo: maj, hi: null }; case '>': // bare `>maj` → `>=maj+1` (excludes maj); `>maj.x.y` still admits maj. - return isBare ? runtimeMajor > maj : runtimeMajor >= maj; + return { lo: isBare ? maj + 1 : maj, hi: null }; case '<=': // `<=maj.*` admits major maj and below. - return runtimeMajor <= maj; + return { lo: null, hi: maj }; case '<': // `'; - const targetMajor = declaredFloorMajor(declared.range); + const targetMajor = declaredTargetMajor(declared.range); // [#26480] `migrate meta` replays metadata forward from an OLDER major. A - // floor above this runtime's major is a package built for a newer protocol: + // target above this runtime's major is a package built for a newer protocol: // nothing here can migrate it, so the refusal names what can apply instead. const ahead = targetMajor !== null && targetMajor > runtimeMajor; const migrateCommand = ahead From b17e3c3388338b1866f3fd75cd79819006428756 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:45:22 +0000 Subject: [PATCH 2/3] chore(changeset): metadata-core patch for the exclusive comparator target Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5p97A4ysuQjSwrSaWT --- ...46-protocol-exclusive-comparator-target.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/26546-protocol-exclusive-comparator-target.md diff --git a/.changeset/26546-protocol-exclusive-comparator-target.md b/.changeset/26546-protocol-exclusive-comparator-target.md new file mode 100644 index 00000000000..ab42e0b2c26 --- /dev/null +++ b/.changeset/26546-protocol-exclusive-comparator-target.md @@ -0,0 +1,22 @@ +--- +'@objectstack/metadata-core': patch +--- + +fix(metadata-core): an exclusive protocol comparator (`'>18'`, `'<18'`) names the major it admits in the `OS_PROTOCOL_INCOMPATIBLE` refusal + +Clause-②: no +Metadata-protocol: none + +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. + +Now the target is read from the majors the range admits, with the same grammar that decides whether it admits the runtime: + +- **`'>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. +- **`'<18'` and `'<18.0.0'`** target 17: `migrate meta --from 17`. +- **A compound range** targets its floor in either order: `'<18 >=15'` targets 15 (it named 18), and `'>15 <18'` targets 16 (it named 15). +- **A `||` union** still targets its lowest alternative, now read the same way: `'<17 || >19'` targets 16. +- **A range that admits no major** (`'>=19 <15'`, `'<0'`) has `targetMajor: null` and the bare `objectstack migrate meta`. + +`^`, `~`, `>=`, `<=`, 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. + +Two CLI readers of `targetMajor` follow: `objectstack migrate meta` now rewrites a `' Date: Tue, 27 Oct 2026 08:51:08 +0000 Subject: [PATCH 3/3] chore(changeset): spell the CLI rewrite example with a concrete major Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5p97A4ysuQjSwrSaWT --- .changeset/26546-protocol-exclusive-comparator-target.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/26546-protocol-exclusive-comparator-target.md b/.changeset/26546-protocol-exclusive-comparator-target.md index ab42e0b2c26..668b5ba9dc1 100644 --- a/.changeset/26546-protocol-exclusive-comparator-target.md +++ b/.changeset/26546-protocol-exclusive-comparator-target.md @@ -19,4 +19,4 @@ Now the target is read from the majors the range admits, with the same grammar t `^`, `~`, `>=`, `<=`, 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. -Two CLI readers of `targetMajor` follow: `objectstack migrate meta` now rewrites a `'