Skip to content

Commit 077b75f

Browse files
fix(metadata-core): the protocol handshake reads an engines.protocol || union instead of refusing it on its first alternative (#24051)
Fixes #24029 Clause-②: no ## What was wrong `rangeAdmitsMajor` (`packages/metadata-core/src/protocol-handshake.ts`) had no branch for `||`. A union failed the compound-comparator test and the hyphen test, reached the caret branch, and was read as a caret on its first alternative. Measured on `origin/main` at `b74434c58` through `checkProtocolCompat`: `engines.protocol: '^17.7.0 || ^18.0.0'` on protocol `18.0.0` answered `status: 'incompatible'` (`OS_PROTOCOL_INCOMPATIBLE`, `targetMajor: 17`). The function's own doc and every caller's comment promise that a range is never refused unless it positively excludes the runtime major, so this is the implementation failing its declared contract (`Clause-②: no`: no accept set the contract publishes moves). ## The fix - `rangeAdmitsMajor` splits the trimmed range on `||` and reads each alternative with the unchanged single-range body (now `alternativeAdmitsMajor`). Any alternative admits, so the union admits. Else any alternative is unrecognized (an empty one included), so the union answers `null` (`unparsed-range`: admitted with the existing `[protocol]` warning). Else `false`. A range with no `||` is its own single alternative, so every existing single-range verdict is unchanged: the existing pins pass unchanged, plus a CONTROL pin on a plain caret, a hyphen range, `x` and `*`. The 128-character bound still runs before the split. - Two sibling readers of the same range read a union as its first alternative too, both in the claimed surface, and now read it the same way: - `checkProtocolCompat`'s `targetMajor` (and so `migrateCommand`): a refused union targets its lowest alternative's major. Before: `'^17 || ^16'` refused on 18 gave `--from 17` and `'^16 || ^17'` gave `--from 16`. After: both give `--from 16`, the floor as the upgrade skill reads it. - `parseRangeFloor` (`artifact-forward-conversion.ts`): a union's floor is its lowest alternative's, and `null` when any alternative is unreadable. Measured on `origin/main` against the installed spec label `18.0.0-next.2`: an artifact declaring `'^18.0.0 || ^17.1.0'` read floor `18.0.0`, took verdict `converted-major-step`, and left a step-11 retired `http_request` flow node unconverted. The same artifact spelled `'^17.1.0 || ^18.0.0'` read `17.1.0`, took `converted-forward`, and converted the node. After: both spellings read `17.1.0` and take `converted-forward`. - One `patch` changeset for `@objectstack/metadata-core`. ## Readers census (acceptance 3) `rangeAdmitsMajor` has exactly one caller, `checkProtocolCompat` (`git grep` over the tree). Every other reader of `engines.protocol` was read: | Reader | Reads `\|\|` itself? | Disposition | |---|---|---| | `checkProtocolCompat`: `targetMajor` / `migrateCommand` | yes, as its first alternative | fixed here (lowest alternative), pinned | | `parseRangeFloor` (`artifact-forward-conversion.ts`) | yes, as its first alternative | fixed here (lowest alternative; unreadable alternative gives `null`), pinned | | `resolveDeclaredRange` | no: picks the key and trims, never parses | none | | `service-package` hydration, `cloud-connection` install-local and rehydrate, `metadata-protocol` `installPackage`, `runtime` (`artifact-reference.ts`, `domains/packages.ts`, `app-plugin.ts`) | no: delegate to `checkProtocolCompat` / `assertProtocolCompat` | inherit the fix; not edited | | `objectql` `engine.ts` | no: `resolveDeclaredRange(...) === null` presence test only | none | | CLI `protocol-version-gap.ts` (doctor/validate/compile advisory), `migrate/meta.ts` `planProtocolRange` | no: read the verdict and `diagnostic.targetMajor` | inherit the fix; not edited | | CLI `lint.ts` `protocol/missing-engines-range` | no: `typeof … === 'string'` presence test only | none | | `@objectstack/core` `SemanticVersionManager.satisfies` (`dependency-resolver.ts`) | yes, its own grammar: caret on the first alternative, `false` for an unrecognized shape | not a reader of `engines.protocol` and not a caller of `rangeAdmitsMajor`; plugin dependency constraints; zero in-tree callers outside `core`. Acceptance note below | Docs: ADR-0087 D1 states no range grammar. No page under `content/docs/` or `skills/` says unions are unsupported. The upgrade skill reads `--from` as "the floor ... never the upper bound", which the `targetMajor` change follows. Nothing is falsified and no doc is edited. ## Edge readings (measured on the built `dist/`, before and after) | Range | Major | `origin/main` | This PR | |---|---|---|---| | `'^17.7.0 \|\| ^18.0.0'` | 18 / 17 / 19 | `false` / `true` / `false` | `true` / `true` / `false` | | `'^16 \|\| ^17'` | 18 | `false` | `false` | | `'^17 \|\| foo'` | 18 | `false` | `null` | | `'foo \|\| ^18'` | 18 | `null` | `true` | | `'^17 \|\|'` | 18 / 17 | `false` / `true` | `null` / `true` | | `'\|\| ^18'` | 18 / 17 | `null` / not measured | `true` / `null` | | `'\|\|'` | 18 | `null` | `null` | | `'^17 \|\| \|\| ^18'` | 18 | `false` | `true` | | `'17 - 18 \|\| ^20'` | 18 / 20 | `true` / `false` (the hyphen split read `18 \|\| ^20` as its upper bound) | `true` / `true` | | `'^17 \| ^18'` (single bar) | 18 / 17 | `false` / `true` | unchanged: `false` / `true` | No empty-alternative edge reads `false`. The single `|` is not split, as dispatched, and reads exactly as on `origin/main`. See Acceptance notes. ## Evidence The red leg and the ablations ran at `27f22bd99`, the fix commit. The package test and typecheck ran again at `b62e33f71`, after `origin/main` was merged. That merge touches no file in this diff. - **Red leg** (BASE `b74434c58`'s two source files restored into the tree, the new pins kept): `Tests 9 failed | 227 passed (236)`. The two that stay green on BASE are the `'^16 || ^17'` refusal pin (`origin/main` refuses it too) and the CONTROL. Restore proven by blob hash equal to HEAD and an empty `git diff HEAD`. - **Ablations** (`scripts/ablation-replace.mjs`, wrapped mode; the subject is imported from `src/` by relative path, so no `dist/` leg is involved). Each landed with anchor 1 → 0 and a changed blob, and each restore was proven by blob equal to HEAD and an empty `git diff HEAD`. 1. Drop the union split in `rangeAdmitsMajor` (`rangeAlternatives(r)` replaced by `[r]`): `5 failed | 231 passed`. Red: the bridging range, unreadable-beside-refusing, the per-alternative grammar, the empty alternative, and the boundary throw. 2. A lenient union that never refuses (`return verdict;` replaced by `return r.includes('||') && verdict === false ? null : verdict;`): `5 failed | 231 passed`, including the `'^16 || ^17'` refusal pin and the `19` refusal. 3. Drop the split in `declaredFloorMajor`: `1 failed` (the `targetMajor` pin). 4. Drop the split in `parseRangeFloor`: `3 failed` (the three floor/window pins). - **Package:** `pnpm --filter @objectstack/metadata-core test`: `Test Files 18 passed (18)`, `Tests 761 passed (761)`. `pnpm --filter @objectstack/metadata-core typecheck` (`tsc --noEmit && tsc --noEmit -p tsconfig.test.json`): exit 0. `--listFiles` of `tsconfig.test.json` lists both touched test files. - **Gates** at head `b62e33f71`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 63 commands (with no paths: the change set comes from the merge base). All 63 ran with their exit codes recorded, and all exited 0. `--ran` reconciliation: `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`. `check:dual-build-cjs-loads` and `check:lean-entry-closure` first answered `PREREQUISITE NOT MET` (exit 3, unbuilt workspace). `check:type-check-debt` then built the workspace closure, and both were re-run to exit 0. `check:dts-closure` and `check:sourcemap-no-sources-content` were re-run after that build (74 and 71 built packages swept). The four artifact-roster families whose roster sits in a directory this diff touches (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) also ran: exit 0. The `test:repo` vitest project and the declared wide-population families are CI's. ## Acceptance notes - **Single `|`** (`'^17 | ^18'`): not a union in npm semver, and not split here, as dispatched. It reads as `^17` because the caret and tilde branches read only the leading integer (`leadingMajor`) and ignore trailing text. The same leniency reads `'^17 garbage'` as `^17`. So a mistyped union is refused on 18 rather than answered `unparsed-range`. Not measured at a public door; carrier: none. - **`@objectstack/core` `SemanticVersionManager.satisfies`** parses its own grammar for plugin dependency constraints: caret on the first alternative of a union, `false` for an unrecognized shape. It has zero in-tree callers outside `core`, and it does not read `engines.protocol`. Carrier: none. - **`parseRangeFloor('<18')`** reads `18.0.0` although `<18` admits everything below 18. This predates this PR, is not about unions, and is inside the function's documented scope ("every range the tooling stamps or the docs teach leads with its floor"). Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62f959d commit 077b75f

5 files changed

Lines changed: 184 additions & 7 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/metadata-core": patch
3+
---
4+
5+
fix(metadata-core): the protocol handshake reads an `engines.protocol` `||` union, so `'^17.7.0 || ^18.0.0'` loads on protocol 18 instead of being refused as incompatible
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** `rangeAdmitsMajor` had no branch for `||`. A union reached the caret branch and was read as a caret on its first alternative, so `'^17.7.0 || ^18.0.0'` answered `OS_PROTOCOL_INCOMPATIBLE` on protocol 18. Every door that runs the ADR-0087 D1 handshake refused it: `sys_packages` boot rehydration, the marketplace install-local POST and rehydrate, and `protocol.installPackage`.
10+
- **What it does now.** A union admits a major when any alternative admits it. When none does and any alternative is unrecognized (an empty one included, `'^17 ||'`), the union answers `unparsed-range`: the package loads with the existing `[protocol]` warning and is never refused. A union whose every alternative refuses is refused as before. Each alternative is read with the unchanged single-range grammar (caret, tilde, hyphen, comparators, `x`, `*`), so a range without `||` reads exactly as it did.
11+
- **The refusal's `targetMajor`.** For a refused union it is the lowest alternative's major, whatever order the alternatives are written in, so `'^17 || ^16'` and `'^16 || ^17'` both name `objectstack migrate meta --from 16`. It used to read the first alternative.
12+
- **The artifact forward-conversion floor.** `parseRangeFloor` reads a union's floor as its lowest alternative's (`'^18.0.0 || ^17.1.0'` → `17.1.0`, as for `'^17.1.0 || ^18.0.0'`), and `null` when an alternative is unreadable. It used to read the first alternative, so a union written high-first opened a narrower conversion window than the same range written low-first.

‎packages/metadata-core/src/artifact-forward-conversion.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,4 +1087,23 @@ describe('parseRangeFloor — the range spellings artifacts actually carry', ()
10871087
expect(parseRangeFloor('latest')).toBeNull();
10881088
expect(parseRangeFloor('x'.repeat(200))).toBeNull();
10891089
});
1090+
1091+
it('[#24029] a `||` union floors at its lowest alternative, in whatever order it is written', () => {
1092+
expect(parseRangeFloor('^17.1.0 || ^18.0.0')).toEqual([17, 1, 0]);
1093+
expect(parseRangeFloor('^18.0.0 || ^17.1.0')).toEqual([17, 1, 0]);
1094+
expect(parseRangeFloor('>=17.2 <18 || ^17.1.0')).toEqual([17, 1, 0]);
1095+
});
1096+
1097+
it('[#24029] a union with an alternative it cannot read has no known floor: null, like an undeclared range', () => {
1098+
expect(parseRangeFloor('^17 || workspace:*')).toBeNull();
1099+
expect(parseRangeFloor('^17 ||')).toBeNull();
1100+
});
1101+
1102+
it('[#24029] the window opens for a descending union exactly as for the same range written ascending', () => {
1103+
for (const range of ['^17.1.0 || ^18.0.0', '^18.0.0 || ^17.1.0']) {
1104+
const result = applyArtifactForwardConversions(legacyPermissionDefinition(range), { runtimeSpecVersion: '18.0.0' });
1105+
expect(result.verdict).toBe('converted-forward');
1106+
expect(result.authoredFloor).toBe('17.1.0');
1107+
}
1108+
});
10901109
});

‎packages/metadata-core/src/artifact-forward-conversion.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
* ## The policy
3131
*
3232
* Let `floor` be the lowest version the artifact's declared protocol range
33-
* admits (the leading version token of `engines.protocol`), and `runtime` the
33+
* admits (the leading version token of `engines.protocol`; of a `||` union,
34+
* the lowest alternative's — {@link parseRangeFloor}), and `runtime` the
3435
* `@objectstack/spec` version this process actually runs — its package label.
3536
* The window is decided PER ENTRY: a registry entry E is replayed when
3637
*
@@ -326,10 +327,26 @@ export interface ArtifactReplayedRetirement {
326327
* tooling stamps or the docs teach leads with its floor. A range this cannot
327328
* read returns `null`, and the caller treats an unreadable floor like an
328329
* undeclared one — the never-false-reject direction.
330+
*
331+
* A `||` union admits what any alternative admits (the handshake's reading),
332+
* so its floor is the LOWEST alternative's, in whatever order they are written
333+
* (`^18.0.0 || ^17.1.0` → 17.1.0, as `^17.1.0 || ^18.0.0`). One alternative
334+
* this cannot read, an empty one included, leaves that floor unknown: `null`.
329335
*/
330336
export function parseRangeFloor(range: string): [number, number, number] | null {
331337
const r = range.trim();
332338
if (r === '' || r.length > 128) return null;
339+
let lowest: [number, number, number] | null = null;
340+
for (const alternative of r.split('||')) {
341+
const floor = alternativeFloor(alternative.trim());
342+
if (floor === null) return null;
343+
if (lowest === null || compareTriples(floor, lowest) < 0) lowest = floor;
344+
}
345+
return lowest;
346+
}
347+
348+
/** The floor of one `||` alternative of a range (see {@link parseRangeFloor}). */
349+
function alternativeFloor(r: string): [number, number, number] | null {
333350
// Strip a leading range operator (`^`, `~`, `>=`, `>`, `=`, `v`).
334351
const m = r.match(/^[\^~>=<\s]*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
335352
if (!m) return null;

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,84 @@ describe('[#21762] protocolIncompatibleAnswer: the one answer every HTTP door gi
316316
expect(details).not.toHaveProperty('message');
317317
});
318318
});
319+
320+
describe('[#24029] a `||` union admits a major when any alternative admits it', () => {
321+
const compat = (range: string, major: number) =>
322+
checkProtocolCompat({ id: 'p', engines: { protocol: range } }, `${major}.0.0`);
323+
324+
it('the range bridging 17 to 18 admits both majors and refuses 19', () => {
325+
expect(compat('^17.7.0 || ^18.0.0', 18).status).toBe('ok');
326+
expect(compat('^17.7.0 || ^18.0.0', 17).status).toBe('ok');
327+
expect(compat('^17.7.0 || ^18.0.0', 19).status).toBe('incompatible');
328+
});
329+
330+
it('a union none of whose alternatives admits the major refuses it', () => {
331+
expect(rangeAdmitsMajor('^16 || ^17', 18)).toBe(false);
332+
expect(compat('^16 || ^17', 18).status).toBe('incompatible');
333+
});
334+
335+
it('an alternative the parser cannot read, beside one that refuses, answers unparsed-range in either order', () => {
336+
expect(compat('^16 || workspace:*', 18).status).toBe('unparsed-range');
337+
expect(compat('workspace:* || ^16', 18).status).toBe('unparsed-range');
338+
// An alternative that admits outranks an unreadable one.
339+
expect(compat('workspace:* || ^18', 18).status).toBe('ok');
340+
});
341+
342+
it('each alternative reads with the single-range grammar: hyphen, comparators, x and *', () => {
343+
expect(rangeAdmitsMajor('15 - 16 || ^18', 18)).toBe(true);
344+
expect(rangeAdmitsMajor('^18 || 15 - 16', 16)).toBe(true);
345+
// A hyphen range first: its upper bound is `18`, never the text after it.
346+
expect(rangeAdmitsMajor('17 - 18 || ^20', 20)).toBe(true);
347+
expect(rangeAdmitsMajor('17 - 18 || ^20', 19)).toBe(false);
348+
expect(rangeAdmitsMajor('>=17 <18 || ^19', 19)).toBe(true);
349+
expect(rangeAdmitsMajor('>=17 <18 || ^19', 18)).toBe(false);
350+
expect(rangeAdmitsMajor('^16 || 18.x', 18)).toBe(true);
351+
expect(rangeAdmitsMajor('^16 || *', 18)).toBe(true);
352+
expect(rangeAdmitsMajor('^16 || x', 18)).toBe(true);
353+
expect(rangeAdmitsMajor('^17||^18', 18)).toBe(true);
354+
});
355+
356+
it('CONTROL: a range with no `||` reads as it always has (caret, hyphen, x and *)', () => {
357+
expect(rangeAdmitsMajor('^18', 18)).toBe(true);
358+
expect(rangeAdmitsMajor('^18', 17)).toBe(false);
359+
expect(rangeAdmitsMajor('17.0.0 - 18.0.0', 18)).toBe(true);
360+
expect(rangeAdmitsMajor('16 - 17', 18)).toBe(false);
361+
expect(rangeAdmitsMajor('x', 18)).toBe(true);
362+
expect(rangeAdmitsMajor('*', 18)).toBe(true);
363+
expect(rangeAdmitsMajor('17.x', 18)).toBe(false);
364+
});
365+
366+
it('an empty alternative is unrecognized, never a refusal', () => {
367+
expect(rangeAdmitsMajor('^17 ||', 18)).toBeNull();
368+
expect(rangeAdmitsMajor('^17 ||', 17)).toBe(true);
369+
expect(rangeAdmitsMajor('|| ^18', 18)).toBe(true);
370+
expect(rangeAdmitsMajor('|| ^18', 17)).toBeNull();
371+
expect(rangeAdmitsMajor('||', 18)).toBeNull();
372+
});
373+
374+
it('a refused union targets its lowest alternative, in whatever order it is written', () => {
375+
for (const range of ['^16 || ^17', '^17 || ^16']) {
376+
const r = compat(range, 18);
377+
expect(r.status).toBe('incompatible');
378+
if (r.status !== 'incompatible') continue;
379+
expect(r.diagnostic.targetMajor).toBe(16);
380+
expect(r.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 16');
381+
}
382+
});
383+
384+
it('at the boundary: the bridging range loads silently on 18 and is refused with the 422 envelope on 19', () => {
385+
const warn = vi.fn();
386+
expect(() => assertProtocolCompat({ id: 'p', engines: { protocol: '^17.7.0 || ^18.0.0' } }, '18.0.0', warn)).not.toThrow();
387+
expect(warn).not.toHaveBeenCalled();
388+
389+
let thrown: unknown;
390+
try {
391+
assertProtocolCompat({ id: 'p', engines: { protocol: '^17.7.0 || ^18.0.0' } }, '19.0.0', warn);
392+
} catch (e) {
393+
thrown = e;
394+
}
395+
expect(isProtocolIncompatibleError(thrown)).toBe(true);
396+
expect((thrown as ProtocolIncompatibleError).code).toBe('OS_PROTOCOL_INCOMPATIBLE');
397+
expect((thrown as ProtocolIncompatibleError).status).toBe(422);
398+
});
399+
});

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

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ export interface ProtocolIncompatibleDiagnostic {
6565
*/
6666
protocolVersion: string;
6767
runtimeMajor: number;
68-
/** The declared major the package targets, when a single major is determinable. */
68+
/**
69+
* The declared major the package targets, when a single major is
70+
* determinable: the range's leading version token, and for a `||` union the
71+
* lowest of its alternatives', whatever order they are written in.
72+
*/
6973
targetMajor: number | null;
7074
/** The command that resolves the refusal (wired end-to-end by ADR-0087 P2). */
7175
migrateCommand: string;
@@ -216,22 +220,49 @@ function leadingMajor(token: string): number | null {
216220
return m ? Number.parseInt(m[1]!, 10) : null;
217221
}
218222

223+
/**
224+
* The `||` alternatives of a range, each trimmed. A range with no `||` is its
225+
* own single alternative, so no single-range reading changes through this.
226+
*/
227+
function rangeAlternatives(range: string): string[] {
228+
return range.split('||').map((alternative) => alternative.trim());
229+
}
230+
219231
/**
220232
* Decide whether a SemVer-ish range admits `runtimeMajor`.
221233
*
222234
* Returns `true`/`false` on a positive determination, or `null` when the range
223235
* shape is not recognized (caller admits with a warning rather than refusing).
224236
* Protocol compatibility is major-grained by design, so every supported form
225237
* reduces to "which majors does this admit".
238+
*
239+
* A `||` union (`'^17.7.0 || ^18.0.0'`, the spelling a package bridging two
240+
* protocol majors writes) admits a major when ANY alternative admits it.
241+
* When none does and any alternative is unrecognized (an empty one included:
242+
* `'^17 ||'`), the union is unrecognized too and answers `null`, because the
243+
* alternative this cannot read might be the one that admits. Only a union
244+
* whose every alternative refuses answers `false`.
226245
*/
227246
export function rangeAdmitsMajor(range: string, runtimeMajor: number): boolean | null {
228247
const r = range.trim();
229-
if (r === '' ) return null;
230248
// A real SemVer range is short. Bounding the length here defuses any
231-
// pathological input before it reaches a regex (a manifest's `engines`
232-
// string is externally authored, so treat it as untrusted): an overlong
233-
// string is simply unrecognized (admit-with-warning), never a slow scan.
249+
// pathological input before it reaches a split or a regex (a manifest's
250+
// `engines` string is externally authored, so treat it as untrusted): an
251+
// overlong string is simply unrecognized (admit-with-warning), never a slow
252+
// scan.
234253
if (r.length > 128) return null;
254+
let verdict: boolean | null = false;
255+
for (const alternative of rangeAlternatives(r)) {
256+
const admits = alternativeAdmitsMajor(alternative, runtimeMajor);
257+
if (admits === true) return true;
258+
if (admits === null) verdict = null;
259+
}
260+
return verdict;
261+
}
262+
263+
/** One `||` alternative: the single-range grammar {@link rangeAdmitsMajor} combines. */
264+
function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null {
265+
if (r === '') return null;
235266
if (r === '*' || r === 'x' || r === 'latest') return true;
236267

237268
// Compound comparator range, e.g. ">=11.0.0 <13.0.0" (space- or comma-joined).
@@ -284,6 +315,23 @@ export function rangeAdmitsMajor(range: string, runtimeMajor: number): boolean |
284315
return null;
285316
}
286317

318+
/**
319+
* The major a refused range targets: the major of its leading version token,
320+
* its floor as the upgrade guidance reads it (`'^10'` → 10, `'>=15 <18'` → 15).
321+
* A `||` union targets its LOWEST alternative's, in whatever order the
322+
* alternatives are written: `'^16 || ^17'` and `'^17 || ^16'` are one range and
323+
* get one `migrate meta --from`.
324+
*/
325+
function declaredFloorMajor(range: string): number | null {
326+
let lowest: number | null = null;
327+
for (const alternative of rangeAlternatives(range)) {
328+
const major = leadingMajor(alternative.replace(/^[\^~<>=\s]+/, ''));
329+
if (major === null) return null;
330+
if (lowest === null || major < lowest) lowest = major;
331+
}
332+
return lowest;
333+
}
334+
287335
function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolean | null {
288336
// Peel the operator off by fixed prefix rather than a `\s*(.+)` match, whose
289337
// whitespace/any-char overlap is a polynomial-ReDoS shape on untrusted input.
@@ -352,7 +400,7 @@ export function checkProtocolCompat(
352400
}
353401

354402
const packageId = manifest.id ?? '<unknown>';
355-
const targetMajor = leadingMajor(declared.range.replace(/^[\^~<>=\s]+/, ''));
403+
const targetMajor = declaredFloorMajor(declared.range);
356404
const migrateCommand =
357405
targetMajor !== null
358406
? `objectstack migrate meta --from ${targetMajor}`

0 commit comments

Comments
 (0)