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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions data/peer-range.json
Original file line number Diff line number Diff line change
@@ -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 <reposDir> --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 <reposDir> --write` after updating this file to re-pin every repo, then let the shared Renovate preset open the update PRs."
}
46 changes: 37 additions & 9 deletions scripts/sync-peer-range.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
10 changes: 8 additions & 2 deletions test/check-peer-range-latest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
31 changes: 24 additions & 7 deletions test/sync-peer-range.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
Expand All @@ -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)')
})
Expand Down
Loading