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
23 changes: 23 additions & 0 deletions .changeset/check-peers-outdated-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@openzeppelin/ui-dev-cli': minor
---

`check-peers` now warns when an adapter's declared `@openzeppelin/ui-*` range does not admit
the version installed beside it.

The check compared the *minimum* a range admits against the installed version, mirroring
`validatePeerVersions` in `@openzeppelin/ui-utils`. That is right for the failure it was
built for — an installed peer that is too old — but it left the opposite drift invisible: an
adapter declaring `^2.0.0` against an installed `4.0.1` satisfies its own runtime check and
still cannot be installed by a consumer app, because a package manager reads the declared
range and not the baked minimum. Nothing anywhere reported it, which is how the
`openzeppelin-adapters` packages carried v2 ranges for `ui-components`, `ui-react` and
`ui-utils` across two majors of the kit.

Such pairs are now reported as a `warning` with code `outdated-range`, naming the bump that
fixes each one. `AdapterPeerPair` gains `range` (the range as declared) and `rangeSatisfied`
(whether the range itself admits the installed version, or `null` for range syntax this check
does not model, which is left unreported rather than guessed at). `ok` and the exit code
remain driven by errors alone, so adding this to an existing pipeline does not change its
result; `printAdapterPeerResult` prints warnings on the passing path, where they would
otherwise be invisible.
14 changes: 11 additions & 3 deletions packages/dev-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,21 @@ It scans `node_modules/@openzeppelin` at the repository root and next to every w
package declared in `pnpm-workspace.yaml`, so it works whether dependencies are hoisted to
the root or installed under an app. For each installed adapter it compares the *minimum* of
each declared `@openzeppelin/ui-*` range against the `ui-*` version actually installed,
matching `validatePeerVersions` semantics — a peer newer than the range is fine, because the
adapter only requires `>=` the minimum.
matching `validatePeerVersions` semantics — a peer newer than the range does not fail,
because the adapter only requires `>=` the minimum.

That floor-only comparison hides the opposite drift, so a peer newer than the declared range
is reported as a **warning** (`outdated-range`) instead of passing silently. An adapter
declaring `^2.0.0` against an installed `4.0.1` satisfies its own runtime check and still
cannot be installed by a consumer app, because a package manager reads the declared range and
not the baked minimum. Ranges whose syntax the check does not model are left unreported rather
than guessed at.

Exit code is `1` when a peer is stale, and also when nothing could be checked (no
`@openzeppelin` packages, no adapters installed, or no peers resolved). Those are treated as
failures on purpose: in CI they mean the install did not run or `--project` points at the
wrong root, and passing would be a false green.
wrong root, and passing would be a false green. An `outdated-range` warning does **not** fail
the command, so adding this to an existing pipeline does not change its exit code.

Run it after install in CI:

Expand Down
136 changes: 134 additions & 2 deletions packages/dev-cli/src/lib/adapterPeers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
collectPeerDeclaringManifests,
compareSemver,
minimumVersionOf,
rangeAdmits,
} from './adapterPeers';

const tempRoots: string[] = [];
Expand Down Expand Up @@ -187,6 +188,42 @@ describe('collectOverriddenPeers', () => {
});
});

describe('rangeAdmits', () => {
it('respects the caret upper bound the range minimum ignores', () => {
expect(rangeAdmits('^2.0.0', '2.9.9')).toBe(true);
expect(rangeAdmits('^2.0.0', '4.0.1')).toBe(false);
expect(rangeAdmits('^2.0.0', '1.9.9')).toBe(false);
});

it('pins the leftmost non-zero segment for 0.x carets, as npm does', () => {
expect(rangeAdmits('^0.5.0', '0.5.9')).toBe(true);
expect(rangeAdmits('^0.5.0', '0.6.0')).toBe(false);
expect(rangeAdmits('^0.0.3', '0.0.3')).toBe(true);
expect(rangeAdmits('^0.0.3', '0.0.4')).toBe(false);
});

it('reads tildes, inequalities, exact pins and wildcards', () => {
expect(rangeAdmits('~3.5.0', '3.5.9')).toBe(true);
expect(rangeAdmits('~3.5.0', '3.6.0')).toBe(false);
expect(rangeAdmits('>=2.0.0', '4.0.1')).toBe(true);
expect(rangeAdmits('<3.0.0', '4.0.1')).toBe(false);
expect(rangeAdmits('3.5.0', '3.5.0')).toBe(true);
expect(rangeAdmits('3.5.0', '3.5.1')).toBe(false);
expect(rangeAdmits('*', '4.0.1')).toBe(true);
});

it('admits a version matching any alternative of a union', () => {
expect(rangeAdmits('^18.0.0 || ^19.0.0', '19.2.0')).toBe(true);
expect(rangeAdmits('^18.0.0 || ^19.0.0', '20.0.0')).toBe(false);
});

it('returns null for range syntax it does not model, so nothing is guessed at', () => {
expect(rangeAdmits('workspace:^', '4.0.1')).toBeNull();
expect(rangeAdmits('>=2.0.0 <5.0.0', '4.0.1')).toBeNull();
expect(rangeAdmits('^2.0.0 || workspace:*', '4.0.1')).toBeNull();
});
});

describe('checkAdapterPeers', () => {
describe('dependencies hoisted to the repository root', () => {
it('passes when every installed peer meets the declared minimum', () => {
Expand Down Expand Up @@ -235,15 +272,21 @@ describe('checkAdapterPeers', () => {
expect(checkAdapterPeers(projectRoot).ok).toBe(false);
});

it('accepts a peer newer than the range, mirroring validatePeerVersions', () => {
it('does not fail on a peer newer than the range, mirroring validatePeerVersions', () => {
const projectRoot = createProjectRoot();
installAdapter(projectRoot, '', 'adapter-evm', { '@openzeppelin/ui-utils': '^2.0.0' });
installPeer(projectRoot, '', 'ui-utils', '4.0.0');

const result = checkAdapterPeers(projectRoot);

expect(result.ok).toBe(true);
expect(result.pairs[0]).toMatchObject({ minimum: '2.0.0', installed: '4.0.0' });
expect(result.pairs[0]).toMatchObject({
minimum: '2.0.0',
installed: '4.0.0',
satisfied: true,
});
// Not fatal, but not silent either -- see the outdated declared ranges block.
expect(result.issues.map((issue) => issue.code)).toEqual(['outdated-range']);
});

it('ignores non-@openzeppelin/ui-* peers', () => {
Expand Down Expand Up @@ -377,6 +420,95 @@ describe('checkAdapterPeers', () => {
});
});

describe('outdated declared ranges', () => {
/**
* The openzeppelin-adapters shape as of this change: every adapter declared
* ui-components/react/utils on the v2 line against an installed v3/v4 kit. The
* adapters' own runtime check passed, so nothing anywhere reported it, and it
* survived two majors.
*/
function createOutdatedRangeProject(): string {
const projectRoot = createProjectRoot();
installAdapter(projectRoot, '', 'adapter-evm', {
'@openzeppelin/ui-components': '^2.0.0',
'@openzeppelin/ui-types': '^3.5.0',
});
installPeer(projectRoot, '', 'ui-components', '3.9.0');
installPeer(projectRoot, '', 'ui-types', '3.5.2');
return projectRoot;
}

it('warns without failing when the declared range excludes the installed version', () => {
const result = checkAdapterPeers(createOutdatedRangeProject());

expect(result.ok).toBe(true);
expect(result.issues).toEqual([
{
severity: 'warning',
code: 'outdated-range',
message:
'@openzeppelin/adapter-evm declares @openzeppelin/ui-components ^2.0.0, which does not admit the installed 3.9.0. ' +
"The adapter's own runtime check passes, but a package manager reads the range and refuses the install.",
},
]);
});

it('records the declared range alongside the minimum it admits', () => {
const result = checkAdapterPeers(createOutdatedRangeProject());

expect(result.pairs).toEqual([
expect.objectContaining({
peer: '@openzeppelin/ui-components',
range: '^2.0.0',
minimum: '2.0.0',
installed: '3.9.0',
satisfied: true,
rangeSatisfied: false,
}),
expect.objectContaining({
peer: '@openzeppelin/ui-types',
range: '^3.5.0',
installed: '3.5.2',
satisfied: true,
rangeSatisfied: true,
}),
]);
});

it('names the bump that fixes each outdated range', () => {
const guidance = checkAdapterPeers(createOutdatedRangeProject()).remediation.join('\n');

expect(guidance).toContain('@openzeppelin/ui-components ^2.0.0 -> ^3.9.0');
expect(guidance).not.toContain('@openzeppelin/ui-types');
});

it('reports a stale peer once, as the error it is, rather than also as drift', () => {
const projectRoot = createProjectRoot();
installAdapter(projectRoot, '', 'adapter-evm', { '@openzeppelin/ui-types': '^3.5.0' });
installPeer(projectRoot, '', 'ui-types', '3.3.0');

const result = checkAdapterPeers(projectRoot);

expect(result.ok).toBe(false);
expect(result.issues.map((issue) => issue.code)).toEqual(['stale-peer']);
expect(result.pairs[0]).toMatchObject({ satisfied: false, rangeSatisfied: false });
});

it('stays quiet on a range whose syntax it cannot model', () => {
const projectRoot = createProjectRoot();
installAdapter(projectRoot, '', 'adapter-evm', {
'@openzeppelin/ui-utils': '>=2.0.0 <5.0.0',
});
installPeer(projectRoot, '', 'ui-utils', '4.0.1');

const result = checkAdapterPeers(projectRoot);

expect(result.ok).toBe(true);
expect(result.issues).toEqual([]);
expect(result.pairs[0]).toMatchObject({ rangeSatisfied: null });
});
});

describe('remediation guidance', () => {
function createStaleProject(workspaceYaml: string): string {
const projectRoot = createProjectRoot();
Expand Down
Loading
Loading