From c17e60d42eb4bec3636ca902d8708fdda2c31599 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 12:05:57 -0600 Subject: [PATCH 1/3] fix(release): apply npm's caret rule to a pre-1.0 dependency The cohort admission check read a caret range as "same major, floor at or below the installed version". npm reads a pre-1.0 caret more narrowly: it locks 0.x to its minor and 0.0.z to its patch. The check therefore admitted 0.10.0 under ^0.9.0, which is the second-copy shape this guard exists to refuse. It also read a prerelease version through split('.'), which made the patch NaN and passed the floor comparison by accident. The peer range shape was also unconditional: caretPeerRange returned a caret for any version, including a pre-1.0 version that states no additive promise. Both scripts now derive the shape and the admission from one module, so the rule has one definition in this repository. agent-interface 1.0.0 and agent-eval 0.145.21 derive the ranges package.json already declares, so the released cohort is unchanged. --- scripts/lib/peer-range.mjs | 66 ++++++++++++++++++++++++++ scripts/verify-official-optimizers.mjs | 8 ++-- scripts/verify-package.mjs | 33 +++---------- 3 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 scripts/lib/peer-range.mjs diff --git a/scripts/lib/peer-range.mjs b/scripts/lib/peer-range.mjs new file mode 100644 index 0000000..e34b4ff --- /dev/null +++ b/scripts/lib/peer-range.mjs @@ -0,0 +1,66 @@ +/** + * The range shape a dependency earns from its own versioning, and npm's rule + * for which versions that shape admits. + * + * From 1.0.0 a package states that a minor is additive, a patch is a fix, and + * only a major removes or narrows, so a caret range holds one installed copy + * across every later minor. A pre-1.0 package states no such promise: npm locks + * a 0.x caret to its minor and a 0.0.z caret to its patch, so the range for a + * pre-1.0 dependency stops at the next minor instead. + */ + +function readVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version) + if (match === null) { + throw new Error(`cannot read version ${version}`) + } + return match.slice(1, 4).map(Number) +} + +function compareVersion(left, right) { + for (let index = 0; index < left.length; index += 1) { + const difference = left[index] - right[index] + if (difference !== 0) return difference + } + return 0 +} + +export function exactMinorPeerRange(version) { + const [major, minor] = readVersion(version) + return `>=${version} <${major}.${minor + 1}.0` +} + +export function expectedPeerRange(version) { + return readVersion(version)[0] >= 1 ? `^${version}` : exactMinorPeerRange(version) +} + +/** The exclusive upper bound of a caret floor, under npm's rule. */ +export function caretUpperBound([major, minor, patch]) { + if (major > 0) return [major + 1, 0, 0] + if (minor > 0) return [0, minor + 1, 0] + return [0, 0, patch + 1] +} + +/** + * A caret range admits an installed version. + * + * The version is compared by its release part, so a prerelease of an admitted + * version counts as admitted: the guards that call this assert one physical + * copy of a contract package, and a prerelease build of that copy speaks the + * same surface. + */ +export function caretAdmits(range, version) { + const caret = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range) + if (caret === null) return false + let installed + try { + installed = readVersion(version) + } catch { + return false + } + const floor = caret.slice(1).map(Number) + return ( + compareVersion(installed, floor) >= 0 && + compareVersion(installed, caretUpperBound(floor)) < 0 + ) +} diff --git a/scripts/verify-official-optimizers.mjs b/scripts/verify-official-optimizers.mjs index 1252011..4a0071c 100644 --- a/scripts/verify-official-optimizers.mjs +++ b/scripts/verify-official-optimizers.mjs @@ -10,6 +10,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { expectedPeerRange } from './lib/peer-range.mjs' const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const sourcePackage = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) @@ -17,8 +18,7 @@ const agentEvalVersion = sourcePackage.devDependencies?.['@tangle-network/agent- if (!/^\d+\.\d+\.\d+$/.test(agentEvalVersion)) { throw new Error('@tangle-network/agent-eval must have one exact development pin') } -const [agentEvalMajor, agentEvalMinor] = agentEvalVersion.split('.').map(Number) -const expectedEvalPeerRange = `>=${agentEvalVersion} <${agentEvalMajor}.${agentEvalMinor + 1}.0` +const expectedEvalPeerRange = expectedPeerRange(agentEvalVersion) if (sourcePackage.peerDependencies?.['@tangle-network/agent-eval'] !== expectedEvalPeerRange) { throw new Error( `@tangle-network/agent-eval peer range must be ${expectedEvalPeerRange} to match the development pin`, @@ -28,9 +28,7 @@ const agentInterfaceVersion = sourcePackage.devDependencies?.['@tangle-network/a if (!/^\d+\.\d+\.\d+$/.test(agentInterfaceVersion)) { throw new Error('@tangle-network/agent-interface must have one exact development pin') } -// agent-interface states that a minor is additive and only a major removes or -// narrows, so the peer is a caret range on the lowest version this package uses. -const expectedInterfacePeerRange = `^${agentInterfaceVersion}` +const expectedInterfacePeerRange = expectedPeerRange(agentInterfaceVersion) if (sourcePackage.peerDependencies?.['@tangle-network/agent-interface'] !== expectedInterfacePeerRange) { throw new Error( `@tangle-network/agent-interface peer range must be ${expectedInterfacePeerRange} to match the development pin`, diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index bf04450..44fe4dc 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { caretAdmits, expectedPeerRange } from './lib/peer-range.mjs' const packageName = '@tangle-network/agent-knowledge' const publicImports = [ @@ -66,9 +67,9 @@ const agentInterfacePackage = '@tangle-network/agent-interface' const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const sourcePackage = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) const agentEvalVersion = exactDevelopmentPin(sourcePackage, agentEvalPackage) -const agentEvalPeerRange = exactMinorPeerRange(agentEvalVersion) +const agentEvalPeerRange = expectedPeerRange(agentEvalVersion) const agentInterfaceVersion = exactDevelopmentPin(sourcePackage, agentInterfacePackage) -const agentInterfacePeerRange = caretPeerRange(agentInterfaceVersion) +const agentInterfacePeerRange = expectedPeerRange(agentInterfaceVersion) assertRequiredPeer(sourcePackage, agentEvalPackage, agentEvalPeerRange) assertRequiredPeer(sourcePackage, agentInterfacePackage, agentInterfacePeerRange) assertNoAgentStackOverrides(sourcePackage) @@ -277,35 +278,13 @@ function exactDevelopmentPin(packageManifest, packageName) { return version } -function exactMinorPeerRange(version) { - const [major, minor] = version.split('.').map(Number) - return `>=${version} <${major}.${minor + 1}.0` -} - -// agent-interface states that a minor is additive and only a major removes or -// narrows, so the peer is a caret range on the lowest version this package uses. -function caretPeerRange(version) { - return `^${version}` -} - // A cohort package declares a caret range, not the resolved version, so the two -// are compared by admission: same major, and a floor at or below the version -// that is actually installed. +// are compared by admission under npm's caret rule. function assertCaretAdmits(declaredRange, version, description) { - const declared = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(declaredRange) - if (declared === null) { + if (!/^\^(\d+)\.(\d+)\.(\d+)$/.test(declaredRange)) { throw new Error(`${description} must declare a caret range, received ${declaredRange}`) } - const [declaredMajor, declaredMinor, declaredPatch] = declared.slice(1).map(Number) - const [major, minor, patch] = version.split('.').map(Number) - if (declaredMajor !== major) { - throw new Error( - `${description} declares ${declaredRange}, which does not admit installed ${version}`, - ) - } - const declaredFloor = declaredMinor * 1_000_000 + declaredPatch - const installed = minor * 1_000_000 + patch - if (declaredFloor > installed) { + if (!caretAdmits(declaredRange, version)) { throw new Error( `${description} declares ${declaredRange}, which does not admit installed ${version}`, ) From b892f43182c18a4b8975bc5b0abf3256135054b0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 12:10:32 -0600 Subject: [PATCH 2/3] refactor(release): keep the peer-range module surface to its two consumers exactMinorPeerRange and caretUpperBound are called only from inside the module. The two verify scripts import expectedPeerRange and caretAdmits. --- scripts/lib/peer-range.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/lib/peer-range.mjs b/scripts/lib/peer-range.mjs index e34b4ff..ae9261f 100644 --- a/scripts/lib/peer-range.mjs +++ b/scripts/lib/peer-range.mjs @@ -25,7 +25,7 @@ function compareVersion(left, right) { return 0 } -export function exactMinorPeerRange(version) { +function exactMinorPeerRange(version) { const [major, minor] = readVersion(version) return `>=${version} <${major}.${minor + 1}.0` } @@ -35,7 +35,7 @@ export function expectedPeerRange(version) { } /** The exclusive upper bound of a caret floor, under npm's rule. */ -export function caretUpperBound([major, minor, patch]) { +function caretUpperBound([major, minor, patch]) { if (major > 0) return [major + 1, 0, 0] if (minor > 0) return [0, minor + 1, 0] return [0, 0, patch + 1] From 0778bd0d6a80e0747f77bb6d20732a137ac80812 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 16 Aug 2026 15:01:23 -0600 Subject: [PATCH 3/3] test(release): pin the caret rule and the peer-range shape The admission rule and the range shape had no test: coverage was transitive through verify:package and verify:official-optimizers, which run against the one live pin, so a rule that is wrong for another version reaches a release unseen. That is how the pre-1.0 caret defect survived. The table is npm's rule, read from semver 7.8.5 with includePrerelease. It holds the four cases the fix turns over: ^0.9.0 refuses 0.10.0, ^0.0.3 refuses 0.0.4, a prerelease reads by its release part instead of a NaN patch, and a pre-1.0 version earns the next-minor window. --- scripts/lib/peer-range.test.mjs | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 scripts/lib/peer-range.test.mjs diff --git a/scripts/lib/peer-range.test.mjs b/scripts/lib/peer-range.test.mjs new file mode 100644 index 0000000..07f86f2 --- /dev/null +++ b/scripts/lib/peer-range.test.mjs @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { caretAdmits, expectedPeerRange } from './peer-range.mjs' + +// The expected column is npm's own rule, read from semver 7.8.5 with +// includePrerelease. A caret keeps the leftmost non-zero place: ^1.2.3 holds +// major 1, ^0.9.0 holds minor 0.9, and ^0.0.3 holds patch 0.0.3. +describe('caretAdmits', () => { + const table = [ + ['^1.0.0', '1.0.0', true], + ['^1.0.0', '1.4.2', true], + ['^1.0.0', '2.0.0', false], + ['^1.0.0', '0.9.9', false], + ['^1.2.0', '1.1.0', false], + ['^1.2.0', '1.2.0', true], + ['^1.2.0', '1.99.99', true], + ['^0.9.0', '0.9.0', true], + ['^0.9.0', '0.9.3', true], + ['^0.9.0', '0.10.0', false], + ['^0.9.0', '0.8.9', false], + ['^0.9.0', '1.0.0', false], + ['^0.0.3', '0.0.3', true], + ['^0.0.3', '0.0.4', false], + ['^0.0.3', '0.0.2', false], + ['^0.0.3', '0.1.0', false], + ['^0.145.21', '0.145.21', true], + ['^0.145.21', '0.145.99', true], + ['^0.145.21', '0.146.0', false], + ] + + for (const [range, version, admitted] of table) { + it(`${range} ${admitted ? 'admits' : 'refuses'} ${version}`, () => { + expect(caretAdmits(range, version)).toBe(admitted) + }) + } + + // A version is compared by its release part, so a prerelease of an admitted + // release is admitted. This is wider than npm at one point: npm orders + // 1.0.0-rc.1 below 1.0.0 and refuses it under ^1.0.0, and this admits it. The + // callers assert one physical copy of a contract package, and a prerelease + // build of that copy carries the same surface. + it('reads a prerelease by its release part', () => { + expect(caretAdmits('^1.0.0', '1.2.0-develop.1')).toBe(true) + expect(caretAdmits('^0.9.0', '0.9.1-rc.1')).toBe(true) + expect(caretAdmits('^0.9.0', '0.10.0-rc.1')).toBe(false) + expect(caretAdmits('^1.0.0', '1.0.0-rc.1')).toBe(true) + }) + + it('refuses a range that is not a plain caret', () => { + for (const range of ['>=1.0.0', '1.0.0', '~1.0.0', '*', '^1.0', '']) { + expect(caretAdmits(range, '1.0.0')).toBe(false) + } + }) + + it('refuses a version it cannot read', () => { + for (const version of ['', '1.0', 'latest', 'v1.0.0']) { + expect(caretAdmits('^1.0.0', version)).toBe(false) + } + }) +}) + +describe('expectedPeerRange', () => { + const table = [ + ['1.0.0', '^1.0.0'], + ['1.4.2', '^1.4.2'], + ['8.0.5', '^8.0.5'], + ['0.9.0', '>=0.9.0 <0.10.0'], + ['0.27.1', '>=0.27.1 <0.28.0'], + ['0.145.21', '>=0.145.21 <0.146.0'], + ] + + for (const [version, range] of table) { + it(`${version} earns ${range}`, () => { + expect(expectedPeerRange(version)).toBe(range) + }) + } + + it('refuses a version it cannot read', () => { + expect(() => expectedPeerRange('1.0')).toThrow('cannot read version 1.0') + }) +})