Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/26546-protocol-exclusive-comparator-target.md
Original file line number Diff line number Diff line change
@@ -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 `'<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.
89 changes: 89 additions & 0 deletions packages/metadata-core/src/protocol-handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `'<N'` admits N-1 and below. Peeling
// the operator off as if it were `^` named N for both, and on an N runtime
// that is `migrate meta --from N`, a migration from the runtime's own major.
const refuse = (range: string, runtime = '18.0.0') => {
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,
});
});
});
109 changes: 73 additions & 36 deletions packages/metadata-core/src/protocol-handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -322,78 +325,112 @@ 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".
const hyphenParts = hyphenRangeEnds(r);
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;
Expand All @@ -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 '<':
// `<maj.0.0` (bare or explicit floor) excludes all of major maj;
// `<maj.5.0` still admits maj.
return atMajorFloor ? runtimeMajor < maj : runtimeMajor <= maj;
return { lo: null, hi: atMajorFloor ? maj - 1 : maj };
default:
return null;
}
Expand Down Expand Up @@ -451,9 +488,9 @@ export function checkProtocolCompat(
}

const packageId = manifest.id ?? '<unknown>';
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
Expand Down
Loading