Skip to content

Commit 1639791

Browse files
Replace custom version handling with semver (#126)
* refactor version handling with semver * rm changeset * ci: apply automated fixes * re-add changset --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 0c62985 commit 1639791

7 files changed

Lines changed: 201 additions & 110 deletions

File tree

.changeset/fruity-hounds-attend.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/intent': patch
3+
---
4+
5+
Replace custom version parsing and comparison with `semver` for stale drift reporting and installed package variant selection.
6+
7+
This improves handling for prereleases, build metadata, coerced versions, invalid versions, and downgrades while preserving the existing `major`, `minor`, `patch`, or `null` stale drift output.

packages/intent/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@
3333
"dependencies": {
3434
"cac": "^6.7.14",
3535
"jsonc-parser": "^3.3.1",
36+
"semver": "^7.7.4",
3637
"yaml": "2.8.3"
3738
},
3839
"devDependencies": {
40+
"@types/semver": "^7.7.1",
3941
"@verdaccio/node-api": "6.0.0-6-next.76",
4042
"tsdown": "^0.19.0",
4143
"verdaccio": "^6.3.2"

packages/intent/src/scanner.ts

Lines changed: 11 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
22
import { createRequire } from 'node:module'
33
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4+
import semver from 'semver'
45
import {
56
createDependencyWalker,
67
createPackageRegistrar,
@@ -374,76 +375,24 @@ function getPackageDepth(packageRoot: string, projectRoot: string): number {
374375
return relative(projectRoot, packageRoot).split(sep).length
375376
}
376377

377-
interface ParsedSemver {
378-
major: number
379-
minor: number
380-
patch: number
381-
prerelease: Array<string | number>
382-
}
383-
384-
function parseSemver(version: string): ParsedSemver | null {
385-
const match =
386-
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
387-
version,
388-
)
389-
if (!match) return null
378+
function normalizeVersion(version: string): string | null {
379+
const validVersion = semver.valid(version)
380+
if (validVersion) return validVersion
390381

391-
const prerelease = match[4]
392-
? match[4].split('.').map((identifier) => {
393-
return /^\d+$/.test(identifier) ? Number(identifier) : identifier
394-
})
395-
: []
396-
397-
return {
398-
major: Number(match[1]),
399-
minor: Number(match[2]),
400-
patch: Number(match[3]),
401-
prerelease,
402-
}
403-
}
404-
405-
function comparePrereleaseIdentifiers(
406-
a: string | number | undefined,
407-
b: string | number | undefined,
408-
): number {
409-
if (a === undefined) return b === undefined ? 0 : 1
410-
if (b === undefined) return -1
411-
412-
if (typeof a === 'number' && typeof b === 'number') {
413-
return a - b
414-
}
415-
416-
if (typeof a === 'number') return -1
417-
if (typeof b === 'number') return 1
418-
419-
return a.localeCompare(b)
382+
return semver.coerce(version)?.version ?? null
420383
}
421384

422385
function comparePackageVersions(a: string, b: string): number {
423-
const parsedA = parseSemver(a)
424-
const parsedB = parseSemver(b)
386+
const versionA = normalizeVersion(a)
387+
const versionB = normalizeVersion(b)
425388

426-
if (!parsedA || !parsedB) {
427-
if (parsedA) return 1
428-
if (parsedB) return -1
389+
if (!versionA || !versionB) {
390+
if (versionA) return 1
391+
if (versionB) return -1
429392
return 0
430393
}
431394

432-
for (const key of ['major', 'minor', 'patch'] as const) {
433-
const diff = parsedA[key] - parsedB[key]
434-
if (diff !== 0) return diff
435-
}
436-
437-
const length = Math.max(parsedA.prerelease.length, parsedB.prerelease.length)
438-
for (let i = 0; i < length; i++) {
439-
const diff = comparePrereleaseIdentifiers(
440-
parsedA.prerelease[i],
441-
parsedB.prerelease[i],
442-
)
443-
if (diff !== 0) return diff
444-
}
445-
446-
return 0
395+
return semver.compare(versionA, versionB)
447396
}
448397

449398
function formatVariantWarning(

packages/intent/src/staleness.ts

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { existsSync, readFileSync } from 'node:fs'
2-
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
2+
import { isAbsolute, join, relative, resolve } from 'node:path'
3+
import semver from 'semver'
34
import { readIntentArtifacts } from './artifact-coverage.js'
4-
import { findSkillFiles, parseFrontmatter } from './utils.js'
5+
import { findSkillFiles, parseFrontmatter, toPosixPath } from './utils.js'
56
import type {
67
IntentArtifactSet,
78
IntentArtifactSkill,
@@ -26,19 +27,48 @@ function classifyVersionDrift(
2627
oldVer: string,
2728
newVer: string,
2829
): 'major' | 'minor' | 'patch' | null {
29-
if (oldVer === newVer) return null
30-
const oldParts = oldVer
31-
.replace(/[^0-9.]/g, '')
32-
.split('.')
33-
.map(Number)
34-
const newParts = newVer
35-
.replace(/[^0-9.]/g, '')
36-
.split('.')
37-
.map(Number)
38-
if ((newParts[0] ?? 0) > (oldParts[0] ?? 0)) return 'major'
39-
if ((newParts[1] ?? 0) > (oldParts[1] ?? 0)) return 'minor'
40-
if ((newParts[2] ?? 0) > (oldParts[2] ?? 0)) return 'patch'
41-
return null
30+
const oldVersion = normalizeVersion(oldVer)
31+
const newVersion = normalizeVersion(newVer)
32+
33+
if (!oldVersion || !newVersion) return null
34+
if (semver.eq(oldVersion, newVersion)) return null
35+
if (!semver.gt(newVersion, oldVersion)) return null
36+
37+
const oldParsed = semver.parse(oldVersion)
38+
const newParsed = semver.parse(newVersion)
39+
if (
40+
oldParsed &&
41+
newParsed &&
42+
oldParsed.major === newParsed.major &&
43+
oldParsed.minor === newParsed.minor &&
44+
oldParsed.patch === newParsed.patch &&
45+
oldParsed.prerelease.length > 0
46+
) {
47+
return 'patch'
48+
}
49+
50+
const drift = semver.diff(oldVersion, newVersion)
51+
switch (drift) {
52+
case 'major':
53+
case 'premajor':
54+
return 'major'
55+
case 'minor':
56+
case 'preminor':
57+
return 'minor'
58+
case 'patch':
59+
case 'prepatch':
60+
case 'prerelease':
61+
return 'patch'
62+
default:
63+
return null
64+
}
65+
}
66+
67+
function normalizeVersion(version: string): string | null {
68+
const validVersion = semver.valid(version)
69+
if (validVersion) return validVersion
70+
71+
return semver.coerce(version)?.version ?? null
4272
}
4373

4474
// ---------------------------------------------------------------------------
@@ -156,7 +186,14 @@ function readPackageJson(packageDir: string): Record<string, unknown> | null {
156186
// ---------------------------------------------------------------------------
157187

158188
function normalizeFilePath(path: string): string {
159-
return resolve(path).split(sep).join('/')
189+
return toPosixPath(resolve(path))
190+
}
191+
192+
function getRelativePackageDir(
193+
artifactRoot: string,
194+
packageDir: string,
195+
): string {
196+
return toPosixPath(relative(artifactRoot, packageDir))
160197
}
161198

162199
function normalizeList(values: Array<string> | undefined): Array<string> {
@@ -181,7 +218,7 @@ function artifactPackageMatches(
181218
packageName: string,
182219
artifactRoot: string,
183220
): boolean {
184-
const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/')
221+
const relPackageDir = getRelativePackageDir(artifactRoot, packageDir)
185222
if (!relPackageDir) return true
186223

187224
if (artifact.packages.includes(packageName)) return true
@@ -352,7 +389,7 @@ function artifactCoversPackage(
352389
packageName: string,
353390
artifactRoot: string,
354391
): boolean {
355-
const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/')
392+
const relPackageDir = getRelativePackageDir(artifactRoot, packageDir)
356393
return (
357394
artifact.packages.includes(packageName) ||
358395
artifact.packages.includes(relPackageDir) ||
@@ -368,7 +405,7 @@ function artifactIgnoresPackage(
368405
packageName: string,
369406
artifactRoot: string,
370407
): boolean {
371-
const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/')
408+
const relPackageDir = getRelativePackageDir(artifactRoot, packageDir)
372409
return artifacts.ignoredPackages.some(
373410
(ignored) =>
374411
ignored.packageName === packageName ||
@@ -416,7 +453,7 @@ export function buildWorkspaceCoverageSignals({
416453
],
417454
needsReview: true,
418455
packageName,
419-
packageRoot: relative(artifactRoot, packageDir).split(sep).join('/'),
456+
packageRoot: getRelativePackageDir(artifactRoot, packageDir),
420457
})
421458
}
422459

@@ -433,16 +470,16 @@ export async function checkStaleness(
433470
artifactRoot = packageDir,
434471
): Promise<StalenessReport> {
435472
const skillsDir = join(packageDir, 'skills')
436-
const library = packageName ?? 'unknown'
473+
const library = packageName ?? readPackageName(packageDir)
437474

438475
// Find all skills
439476
const skillFiles = findSkillFiles(skillsDir)
440477
const skillMetas: Array<SkillMeta> = skillFiles.map((filePath) => {
441478
const fm = parseFrontmatter(filePath)
442-
const relName = relative(skillsDir, filePath)
443-
.replace(/[/\\]SKILL\.md$/, '')
444-
.split(sep)
445-
.join('/')
479+
const relName = toPosixPath(relative(skillsDir, filePath)).replace(
480+
/[/\\]SKILL\.md$/,
481+
'',
482+
)
446483
return {
447484
name: typeof fm?.name === 'string' ? fm.name : relName,
448485
relName,
@@ -482,7 +519,7 @@ export async function checkStaleness(
482519
if (
483520
currentVersion &&
484521
skill.libraryVersion &&
485-
skill.libraryVersion !== currentVersion
522+
classifyVersionDrift(skill.libraryVersion, currentVersion) !== null
486523
) {
487524
reasons.push(
488525
`version drift (${skill.libraryVersion}${currentVersion})`,

packages/intent/tests/scanner.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,69 @@ describe('scanForIntents', () => {
10711071
expect(result.packages[0]!.version).toBe('5.0.0')
10721072
expect(result.packages[0]!.packageRoot).toBe(validDir)
10731073
})
1074+
1075+
it('uses semver coercion when comparing messy package versions', () => {
1076+
writeJson(join(root, 'package.json'), {
1077+
name: 'app',
1078+
private: true,
1079+
dependencies: {
1080+
'consumer-a': '1.0.0',
1081+
'consumer-b': '1.0.0',
1082+
},
1083+
})
1084+
1085+
const consumerADir = createDir(root, 'node_modules', 'consumer-a')
1086+
const consumerBDir = createDir(root, 'node_modules', 'consumer-b')
1087+
1088+
writeJson(join(consumerADir, 'package.json'), {
1089+
name: 'consumer-a',
1090+
version: '1.0.0',
1091+
dependencies: { '@tanstack/query': 'release-5.0.1' },
1092+
})
1093+
writeJson(join(consumerBDir, 'package.json'), {
1094+
name: 'consumer-b',
1095+
version: '1.0.0',
1096+
dependencies: { '@tanstack/query': '5.0.0' },
1097+
})
1098+
1099+
const messyDir = createDir(
1100+
consumerADir,
1101+
'node_modules',
1102+
'@tanstack',
1103+
'query',
1104+
)
1105+
const validDir = createDir(
1106+
consumerBDir,
1107+
'node_modules',
1108+
'@tanstack',
1109+
'query',
1110+
)
1111+
1112+
writeJson(join(messyDir, 'package.json'), {
1113+
name: '@tanstack/query',
1114+
version: 'release-5.0.1',
1115+
intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' },
1116+
})
1117+
writeJson(join(validDir, 'package.json'), {
1118+
name: '@tanstack/query',
1119+
version: '5.0.0',
1120+
intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' },
1121+
})
1122+
writeSkillMd(createDir(messyDir, 'skills', 'fetching'), {
1123+
name: 'fetching',
1124+
description: 'Messy version query skill',
1125+
})
1126+
writeSkillMd(createDir(validDir, 'skills', 'fetching'), {
1127+
name: 'fetching',
1128+
description: 'Valid version query skill',
1129+
})
1130+
1131+
const result = scanForIntents(root)
1132+
1133+
expect(result.packages).toHaveLength(1)
1134+
expect(result.packages[0]!.version).toBe('release-5.0.1')
1135+
expect(result.packages[0]!.packageRoot).toBe(messyDir)
1136+
})
10741137
})
10751138

10761139
describe('scanIntentPackageAtRoot', () => {

packages/intent/tests/staleness.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,14 @@ describe('checkStaleness', () => {
106106
expect(report.signals).toEqual([])
107107
})
108108

109-
it('defaults library to "unknown" when no name provided', async () => {
109+
it('uses package.json name when no package name is provided', async () => {
110+
writeFileSync(
111+
join(tmpDir, 'package.json'),
112+
JSON.stringify({ name: '@example/from-package-json' }),
113+
)
114+
110115
const report = await checkStaleness(tmpDir)
111-
expect(report.library).toBe('unknown')
116+
expect(report.library).toBe('@example/from-package-json')
112117
})
113118

114119
it('detects skills from SKILL.md files', async () => {
@@ -175,6 +180,30 @@ describe('checkStaleness', () => {
175180
expect(report.versionDrift).toBe('patch')
176181
})
177182

183+
it.each([
184+
['1.0.0', '2.0.0', 'major'],
185+
['1.0.0', '1.1.0', 'minor'],
186+
['1.0.0', '1.0.1', 'patch'],
187+
['1.0.0-beta.1', '1.0.0', 'patch'],
188+
['1.0.0+build.1', '1.0.0+build.2', null],
189+
['2.0.0', '1.0.0', null],
190+
] as const)(
191+
'classifies semver drift from %s to %s as %s',
192+
async (skillVersion, currentVersion, drift) => {
193+
writeSkill(tmpDir, 'core', {
194+
name: 'core',
195+
description: 'Core',
196+
library_version: skillVersion,
197+
})
198+
199+
mockFetchVersion(currentVersion)
200+
201+
const report = await checkStaleness(tmpDir, '@example/lib')
202+
expect(report.versionDrift).toBe(drift)
203+
expect(requireFirstSkill(report).needsReview).toBe(drift !== null)
204+
},
205+
)
206+
178207
it('reports no drift when versions match', async () => {
179208
writeSkill(tmpDir, 'core', {
180209
name: 'core',

0 commit comments

Comments
 (0)