diff --git a/CHANGELOG.md b/CHANGELOG.md index 336f873..8a53d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - The canonical `@deepseek-ai/dsh-*` peer range now admits the `0.1.6-alpha.2` tuple (`|| >=0.1.6-0 <0.2.0`). The `peer-range` tripwire has been failing on `master` since that tuple was published, because semver's prerelease rule gives every new tuple its own clause; the three peers this package declares are re-pinned in the same commit, the lockfile's specifiers follow, and the `sync-peer-range` fixture's "higher-floor" example was raised above the new floor. +- `scripts/sync-peer-range.mjs` no longer discards a real higher floor when the clause counts differ. `targetRange()` returned the canonical range as soon as `current.length !== canonical.length`, which was harmless only while canonical never changed length - and adding the `0.1.6-0` clause changed exactly that. A repo declaring `>=0.1.2-rc.1 <0.2.0 || >=0.1.5-rc.1 <0.2.0` (a genuinely higher 0.1.5 floor) was silently rewritten down to canonical's `>=0.1.5-alpha.1`; the same held for a single-clause `>=0.1.5-rc.1 <0.2.0`. Clauses now pair by `[major, minor, patch]` tuple when the counts differ, so the higher floor survives while the new clause is added, and a current clause in a tuple canonical does not carry is kept only when its floor is above every canonical floor. `test/sync-peer-range.test.mjs` covers the two-clause and single-clause cases, and the end-to-end fixture now exercises the merge instead of the raised-floor bypass. ## [0.1.9] - 2026-09-12 ### Added diff --git a/data/peer-range.json b/data/peer-range.json index c3923c0..93a6f89 100644 --- a/data/peer-range.json +++ b/data/peer-range.json @@ -1,5 +1,5 @@ { "canonicalRange": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0", - "updatedAt": "2026-09-19", - "note": "Canonical peerDependencies range for @deepseek-ai/dsh-* across the ecosystem. The OR form is required by semver's prerelease rule: a prerelease version only satisfies a comparator set when a comparator in the same [major, minor, patch] tuple carries a prerelease, so 0.1.5-rc.1 is NOT admitted by the bare '>=0.1.2-rc.1 <0.2.0'. Every new upstream prerelease tuple needs its own clause - run `node scripts/check-peer-range-latest.mjs` to detect a missing one (it reads the published dist-tags and prints the clause to append). Run `node scripts/sync-peer-range.mjs --dir --write` after updating this file to re-pin every repo, then let the shared Renovate preset open the update PRs." + "updatedAt": "2026-09-20", + "note": "Canonical peerDependencies range for @deepseek-ai/dsh-* across the ecosystem. The OR form is required by semver's prerelease rule: a prerelease version only satisfies a comparator set when a comparator in the same [major, minor, patch] tuple carries a prerelease, so 0.1.5-rc.1 is NOT admitted by the bare '>=0.1.2-rc.1 <0.2.0'. Every new upstream prerelease tuple needs its own clause - run `node scripts/check-peer-range-latest.mjs` to detect a missing one (it reads the published dist-tags and prints the clause to append). The third clause covers the 0.1.6 tuple with a '-0' floor, the same shape the tripwire suggests (never the bare '>=0.1.6' form): it admits every 0.1.6 prerelease including 0.1.6-alpha.2. Run `node scripts/sync-peer-range.mjs --dir --write` after updating this file to re-pin every repo, then let the shared Renovate preset open the update PRs." } diff --git a/scripts/sync-peer-range.mjs b/scripts/sync-peer-range.mjs index 208557c..ad40df9 100644 --- a/scripts/sync-peer-range.mjs +++ b/scripts/sync-peer-range.mjs @@ -130,21 +130,49 @@ export function rangeStatus(current, canonical) { return maxOf(cur, 'floor') >= maxOf(can, 'floor') ? 'ok-higher' : 'drift-low' } +/** `0.1.6-0` / `0.1.5-alpha.1` -> `0.1.6` / `0.1.5` (the [major, minor, patch] tuple). */ +function floorTuple(floor) { + return String(floor).split('-')[0] +} + /** * Target range for a drifting key: the canonical clause set, keeping the - * higher of each pairwise floor. Clause counts that differ fall back to the - * canonical range as-is (a clause was added or removed upstream). + * higher of each floor. Equal-length sets merge pairwise by position; when + * the counts differ, clauses pair up by [major, minor, patch] tuple instead, + * so a repo that carries a real higher floor in one tuple (e.g. + * `>=0.1.5-rc.1` where canonical says `>=0.1.5-alpha.1`) keeps it even when + * canonical gains a clause - the old `length !== length` early return + * silently dropped such floors back to canonical. A current clause whose + * tuple canonical does not carry is preserved only when its floor is above + * every canonical floor (a genuinely higher requirement); anything below is + * drift-low and is raised to the canonical clause set. */ export function targetRange(current, canonical) { const cur = parseRangeSet(current) const can = parseRangeSet(canonical) - if (!cur || !can || cur.length !== can.length) return canonical - return formatRangeSet( - can.map((c, i) => ({ - floor: cur[i].floor > c.floor ? cur[i].floor : c.floor, - upper: c.upper, - })), - ) + if (!cur || !can) return canonical + if (cur.length === can.length) { + return formatRangeSet( + can.map((c, i) => ({ + floor: cur[i].floor > c.floor ? cur[i].floor : c.floor, + upper: c.upper, + })), + ) + } + const curByTuple = new Map(cur.map((c) => [floorTuple(c.floor), c])) + const merged = can.map((c) => { + const pair = curByTuple.get(floorTuple(c.floor)) + if (pair) curByTuple.delete(floorTuple(c.floor)) + return pair + ? { floor: pair.floor > c.floor ? pair.floor : c.floor, upper: c.upper } + : c + }) + const maxFloor = maxOf(can, 'floor') + const upper = maxOf(can, 'upper') + for (const extra of curByTuple.values()) { + if (extra.floor > maxFloor) merged.push({ floor: extra.floor, upper }) + } + return formatRangeSet(merged) } /** diff --git a/test/check-peer-range-latest.test.mjs b/test/check-peer-range-latest.test.mjs index e4614f5..f40f7a3 100644 --- a/test/check-peer-range-latest.test.mjs +++ b/test/check-peer-range-latest.test.mjs @@ -8,7 +8,7 @@ import { suggestedClause, } from '../scripts/check-peer-range-latest.mjs' -const CANONICAL = '>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0' +const CANONICAL = '>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0' describe('parseVersion / compareVersions', () => { it('parses release and prerelease versions', () => { @@ -54,8 +54,14 @@ describe('satisfiesRange', () => { expect(satisfiesRange('0.1.6', CANONICAL)).toBe(true) }) + it('admits the 0.1.6 tuple through its own -0 clause', () => { + expect(satisfiesRange('0.1.6-rc.1', CANONICAL)).toBe(true) + expect(satisfiesRange('0.1.6-alpha.0', CANONICAL)).toBe(true) + expect(satisfiesRange('0.1.6-alpha.2', CANONICAL)).toBe(true) + }) + it('does NOT admit a future prerelease tuple (the reason the tripwire exists)', () => { - expect(satisfiesRange('0.1.6-rc.1', CANONICAL)).toBe(false) + expect(satisfiesRange('0.1.7-rc.1', CANONICAL)).toBe(false) }) it('rejects unparseable input instead of throwing', () => { diff --git a/test/sync-peer-range.test.mjs b/test/sync-peer-range.test.mjs index fd176a7..62e037e 100644 --- a/test/sync-peer-range.test.mjs +++ b/test/sync-peer-range.test.mjs @@ -74,6 +74,21 @@ describe('OR-form range sets', () => { expect(targetRange('>=0.1.2-rc.1 <0.2.0', OR)).toBe(OR) expect(targetRange('>=0.1.1-rc.2 <0.2.0', OR)).toBe(OR) }) + + it('merges per-tuple floors when clause counts differ (R3 fix)', () => { + const THREE = '>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0' + // the old two-clause canonical gains the new tuple clause + expect(targetRange('>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0', THREE)).toBe(THREE) + // a current two-clause range with a real higher floor keeps it while the + // third clause is added - the old early return silently lost the rc.1 + expect(targetRange('>=0.1.2-rc.1 <0.2.0 || >=0.1.5-rc.1 <0.2.0', THREE)).toBe( + '>=0.1.2-rc.1 <0.2.0 || >=0.1.5-rc.1 <0.2.0 || >=0.1.6-0 <0.2.0', + ) + // even a single-clause higher floor is preserved while the set is raised + expect(targetRange('>=0.1.5-rc.1 <0.2.0', THREE)).toBe( + '>=0.1.2-rc.1 <0.2.0 || >=0.1.5-rc.1 <0.2.0 || >=0.1.6-0 <0.2.0', + ) + }) }) describe('rewritePeerRange', () => { @@ -139,13 +154,13 @@ describe('currentPeerRanges', () => { }) describe('CLI end-to-end', () => { - it('reports drift without --write and rewrites only drifting keys with --write', async () => { + it('reports drift without --write; --write adds the third clause and keeps a higher floor', async () => { const canonical = JSON.parse( await readFile(resolve(import.meta.dirname, '..', 'data', 'peer-range.json'), 'utf8'), ).canonicalRange - // above every canonical floor: a real per-package requirement, left alone - // (raised together with canonicalRange's newest tuple, 2026-09-19) - const highFloor = '>=0.1.6-rc.1 <0.2.0' + // two-clause current with a real higher floor in the 0.1.5 tuple: the + // rewrite must preserve it while adding the third canonical clause + const highFloor = '>=0.1.2-rc.1 <0.2.0 || >=0.1.5-rc.1 <0.2.0' const repoDir = join(dir, 'cli-repo') await mkdir(repoDir, { recursive: true }) const pkg = join(repoDir, 'package.json') @@ -158,11 +173,13 @@ describe('CLI end-to-end', () => { expect(report).toContain('cli-repo\tdrift') expect(report).toContain('@deepseek-ai/dsh-session: drift-low') const rewrite = execFileSync(process.execPath, [script, '--dir', dir, '--write'], { encoding: 'utf8' }) - expect(rewrite).toContain('rewritten (1 keys)') + expect(rewrite).toContain('rewritten (2 keys)') const after = await readFile(pkg, 'utf8') expect(after).toContain(`"@deepseek-ai/dsh-session": "${canonical}"`) - // higher-floor key untouched by the rewrite - expect(after).toContain(`"@deepseek-ai/dsh-projection": "${highFloor}"`) + // the higher 0.1.5 floor survives the rewrite and the third clause is added + expect(after).toContain( + `"@deepseek-ai/dsh-projection": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-rc.1 <0.2.0 || >=0.1.6-0 <0.2.0"`, + ) const second = execFileSync(process.execPath, [script, '--dir', dir], { encoding: 'utf8' }) expect(second).toContain('cli-repo\tok (1 higher-floor)') })