From 654e13b8a16cb6af05b924ad13122c72091d2e1f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 2 May 2026 23:18:29 -0700 Subject: [PATCH 1/4] refactor version handling with semver --- .changeset/semver-version-handling.md | 7 ++ packages/intent/package.json | 2 + packages/intent/src/scanner.ts | 73 ++++----------------- packages/intent/src/staleness.ts | 85 +++++++++++++++++-------- packages/intent/tests/scanner.test.ts | 63 ++++++++++++++++++ packages/intent/tests/staleness.test.ts | 33 +++++++++- pnpm-lock.yaml | 44 +++++++------ 7 files changed, 198 insertions(+), 109 deletions(-) create mode 100644 .changeset/semver-version-handling.md diff --git a/.changeset/semver-version-handling.md b/.changeset/semver-version-handling.md new file mode 100644 index 0000000..6036b2f --- /dev/null +++ b/.changeset/semver-version-handling.md @@ -0,0 +1,7 @@ +--- +"@tanstack/intent": patch +--- + +Replace custom version parsing and comparison with `semver` for stale drift reporting and installed package variant selection. + +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. diff --git a/packages/intent/package.json b/packages/intent/package.json index 3a83ea5..1939aed 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -33,9 +33,11 @@ "dependencies": { "cac": "^6.7.14", "jsonc-parser": "^3.3.1", + "semver": "^7.7.4", "yaml": "2.8.3" }, "devDependencies": { + "@types/semver": "^7.7.1", "@verdaccio/node-api": "6.0.0-6-next.76", "tsdown": "^0.19.0", "verdaccio": "^6.3.2" diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 1d3da77..ed0cd16 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import semver from 'semver' import { createDependencyWalker, createPackageRegistrar, @@ -374,76 +375,24 @@ function getPackageDepth(packageRoot: string, projectRoot: string): number { return relative(projectRoot, packageRoot).split(sep).length } -interface ParsedSemver { - major: number - minor: number - patch: number - prerelease: Array -} - -function parseSemver(version: string): ParsedSemver | null { - const match = - /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( - version, - ) - if (!match) return null +function normalizeVersion(version: string): string | null { + const validVersion = semver.valid(version) + if (validVersion) return validVersion - const prerelease = match[4] - ? match[4].split('.').map((identifier) => { - return /^\d+$/.test(identifier) ? Number(identifier) : identifier - }) - : [] - - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - prerelease, - } -} - -function comparePrereleaseIdentifiers( - a: string | number | undefined, - b: string | number | undefined, -): number { - if (a === undefined) return b === undefined ? 0 : 1 - if (b === undefined) return -1 - - if (typeof a === 'number' && typeof b === 'number') { - return a - b - } - - if (typeof a === 'number') return -1 - if (typeof b === 'number') return 1 - - return a.localeCompare(b) + return semver.coerce(version)?.version ?? null } function comparePackageVersions(a: string, b: string): number { - const parsedA = parseSemver(a) - const parsedB = parseSemver(b) + const versionA = normalizeVersion(a) + const versionB = normalizeVersion(b) - if (!parsedA || !parsedB) { - if (parsedA) return 1 - if (parsedB) return -1 + if (!versionA || !versionB) { + if (versionA) return 1 + if (versionB) return -1 return 0 } - for (const key of ['major', 'minor', 'patch'] as const) { - const diff = parsedA[key] - parsedB[key] - if (diff !== 0) return diff - } - - const length = Math.max(parsedA.prerelease.length, parsedB.prerelease.length) - for (let i = 0; i < length; i++) { - const diff = comparePrereleaseIdentifiers( - parsedA.prerelease[i], - parsedB.prerelease[i], - ) - if (diff !== 0) return diff - } - - return 0 + return semver.compare(versionA, versionB) } function formatVariantWarning( diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 0dfee07..9741a3b 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync } from 'node:fs' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { isAbsolute, join, relative, resolve } from 'node:path' +import semver from 'semver' import { readIntentArtifacts } from './artifact-coverage.js' -import { findSkillFiles, parseFrontmatter } from './utils.js' +import { findSkillFiles, parseFrontmatter, toPosixPath } from './utils.js' import type { IntentArtifactSet, IntentArtifactSkill, @@ -26,19 +27,48 @@ function classifyVersionDrift( oldVer: string, newVer: string, ): 'major' | 'minor' | 'patch' | null { - if (oldVer === newVer) return null - const oldParts = oldVer - .replace(/[^0-9.]/g, '') - .split('.') - .map(Number) - const newParts = newVer - .replace(/[^0-9.]/g, '') - .split('.') - .map(Number) - if ((newParts[0] ?? 0) > (oldParts[0] ?? 0)) return 'major' - if ((newParts[1] ?? 0) > (oldParts[1] ?? 0)) return 'minor' - if ((newParts[2] ?? 0) > (oldParts[2] ?? 0)) return 'patch' - return null + const oldVersion = normalizeVersion(oldVer) + const newVersion = normalizeVersion(newVer) + + if (!oldVersion || !newVersion) return null + if (semver.eq(oldVersion, newVersion)) return null + if (!semver.gt(newVersion, oldVersion)) return null + + const oldParsed = semver.parse(oldVersion) + const newParsed = semver.parse(newVersion) + if ( + oldParsed && + newParsed && + oldParsed.major === newParsed.major && + oldParsed.minor === newParsed.minor && + oldParsed.patch === newParsed.patch && + oldParsed.prerelease.length > 0 + ) { + return 'patch' + } + + const drift = semver.diff(oldVersion, newVersion) + switch (drift) { + case 'major': + case 'premajor': + return 'major' + case 'minor': + case 'preminor': + return 'minor' + case 'patch': + case 'prepatch': + case 'prerelease': + return 'patch' + default: + return null + } +} + +function normalizeVersion(version: string): string | null { + const validVersion = semver.valid(version) + if (validVersion) return validVersion + + return semver.coerce(version)?.version ?? null } // --------------------------------------------------------------------------- @@ -156,7 +186,14 @@ function readPackageJson(packageDir: string): Record | null { // --------------------------------------------------------------------------- function normalizeFilePath(path: string): string { - return resolve(path).split(sep).join('/') + return toPosixPath(resolve(path)) +} + +function getRelativePackageDir( + artifactRoot: string, + packageDir: string, +): string { + return toPosixPath(relative(artifactRoot, packageDir)) } function normalizeList(values: Array | undefined): Array { @@ -181,7 +218,7 @@ function artifactPackageMatches( packageName: string, artifactRoot: string, ): boolean { - const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/') + const relPackageDir = getRelativePackageDir(artifactRoot, packageDir) if (!relPackageDir) return true if (artifact.packages.includes(packageName)) return true @@ -352,7 +389,7 @@ function artifactCoversPackage( packageName: string, artifactRoot: string, ): boolean { - const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/') + const relPackageDir = getRelativePackageDir(artifactRoot, packageDir) return ( artifact.packages.includes(packageName) || artifact.packages.includes(relPackageDir) || @@ -368,7 +405,7 @@ function artifactIgnoresPackage( packageName: string, artifactRoot: string, ): boolean { - const relPackageDir = relative(artifactRoot, packageDir).split(sep).join('/') + const relPackageDir = getRelativePackageDir(artifactRoot, packageDir) return artifacts.ignoredPackages.some( (ignored) => ignored.packageName === packageName || @@ -416,7 +453,7 @@ export function buildWorkspaceCoverageSignals({ ], needsReview: true, packageName, - packageRoot: relative(artifactRoot, packageDir).split(sep).join('/'), + packageRoot: getRelativePackageDir(artifactRoot, packageDir), }) } @@ -433,16 +470,14 @@ export async function checkStaleness( artifactRoot = packageDir, ): Promise { const skillsDir = join(packageDir, 'skills') - const library = packageName ?? 'unknown' + const library = packageName ?? readPackageName(packageDir) // Find all skills const skillFiles = findSkillFiles(skillsDir) const skillMetas: Array = skillFiles.map((filePath) => { const fm = parseFrontmatter(filePath) - const relName = relative(skillsDir, filePath) + const relName = toPosixPath(relative(skillsDir, filePath)) .replace(/[/\\]SKILL\.md$/, '') - .split(sep) - .join('/') return { name: typeof fm?.name === 'string' ? fm.name : relName, relName, @@ -482,7 +517,7 @@ export async function checkStaleness( if ( currentVersion && skill.libraryVersion && - skill.libraryVersion !== currentVersion + classifyVersionDrift(skill.libraryVersion, currentVersion) !== null ) { reasons.push( `version drift (${skill.libraryVersion} → ${currentVersion})`, diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 0545515..8883738 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -1071,6 +1071,69 @@ describe('scanForIntents', () => { expect(result.packages[0]!.version).toBe('5.0.0') expect(result.packages[0]!.packageRoot).toBe(validDir) }) + + it('uses semver coercion when comparing messy package versions', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = createDir(root, 'node_modules', 'consumer-a') + const consumerBDir = createDir(root, 'node_modules', 'consumer-b') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': 'release-5.0.1' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + + const messyDir = createDir( + consumerADir, + 'node_modules', + '@tanstack', + 'query', + ) + const validDir = createDir( + consumerBDir, + 'node_modules', + '@tanstack', + 'query', + ) + + writeJson(join(messyDir, 'package.json'), { + name: '@tanstack/query', + version: 'release-5.0.1', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(validDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(messyDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Messy version query skill', + }) + writeSkillMd(createDir(validDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Valid version query skill', + }) + + const result = scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.version).toBe('release-5.0.1') + expect(result.packages[0]!.packageRoot).toBe(messyDir) + }) }) describe('scanIntentPackageAtRoot', () => { diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index e34b7ff..f3a7af8 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -106,9 +106,14 @@ describe('checkStaleness', () => { expect(report.signals).toEqual([]) }) - it('defaults library to "unknown" when no name provided', async () => { + it('uses package.json name when no package name is provided', async () => { + writeFileSync( + join(tmpDir, 'package.json'), + JSON.stringify({ name: '@example/from-package-json' }), + ) + const report = await checkStaleness(tmpDir) - expect(report.library).toBe('unknown') + expect(report.library).toBe('@example/from-package-json') }) it('detects skills from SKILL.md files', async () => { @@ -175,6 +180,30 @@ describe('checkStaleness', () => { expect(report.versionDrift).toBe('patch') }) + it.each([ + ['1.0.0', '2.0.0', 'major'], + ['1.0.0', '1.1.0', 'minor'], + ['1.0.0', '1.0.1', 'patch'], + ['1.0.0-beta.1', '1.0.0', 'patch'], + ['1.0.0+build.1', '1.0.0+build.2', null], + ['2.0.0', '1.0.0', null], + ] as const)( + 'classifies semver drift from %s to %s as %s', + async (skillVersion, currentVersion, drift) => { + writeSkill(tmpDir, 'core', { + name: 'core', + description: 'Core', + library_version: skillVersion, + }) + + mockFetchVersion(currentVersion) + + const report = await checkStaleness(tmpDir, '@example/lib') + expect(report.versionDrift).toBe(drift) + expect(requireFirstSkill(report).needsReview).toBe(drift !== null) + }, + ) + it('reports no drift when versions match', async () => { writeSkill(tmpDir, 'core', { name: 'core', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d65f1..4e9fd01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,10 +89,16 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 + semver: + specifier: ^7.7.4 + version: 7.7.4 yaml: specifier: 2.8.3 version: 2.8.3 devDependencies: + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@verdaccio/node-api': specifier: 6.0.0-6-next.76 version: 6.0.0-6-next.76 @@ -1061,6 +1067,9 @@ packages: '@types/responselike@1.0.0': resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -3549,11 +3558,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -4261,7 +4265,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.7.3 + semver: 7.7.4 '@changesets/assemble-release-plan@6.0.9': dependencies: @@ -4270,7 +4274,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.7.3 + semver: 7.7.4 '@changesets/changelog-git@0.2.1': dependencies: @@ -4303,7 +4307,7 @@ snapshots: package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 - semver: 7.7.3 + semver: 7.7.4 spawndamnit: 3.0.1 term-size: 2.2.1 transitivePeerDependencies: @@ -4328,7 +4332,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.7.3 + semver: 7.7.4 '@changesets/get-github-info@0.6.0': dependencies: @@ -5082,6 +5086,8 @@ snapshots: dependencies: '@types/node': 25.0.9 + '@types/semver@7.7.1': {} + '@types/unist@3.0.3': {} '@types/whatwg-mimetype@3.0.2': {} @@ -5160,7 +5166,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 minimatch: 10.2.4 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -6361,7 +6367,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: @@ -6386,7 +6392,7 @@ snapshots: eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.1.1 - semver: 7.7.3 + semver: 7.7.4 stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: @@ -6404,7 +6410,7 @@ snapshots: globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 - semver: 7.7.3 + semver: 7.7.4 ts-declaration-location: 1.0.7(typescript@5.9.3) transitivePeerDependencies: - typescript @@ -6655,7 +6661,7 @@ snapshots: proxy-addr: 2.0.7 rfdc: 1.4.1 secure-json-parse: 2.7.0 - semver: 7.7.3 + semver: 7.7.4 tiny-lru: 11.4.7 fastq@1.20.1: @@ -7100,7 +7106,7 @@ snapshots: jws: 3.2.3 lodash: 4.17.21 ms: 2.1.3 - semver: 7.7.3 + semver: 7.7.4 jsonwebtoken@9.0.3: dependencies: @@ -7465,7 +7471,7 @@ snapshots: open: 8.4.2 ora: 5.3.0 resolve.exports: 2.0.3 - semver: 7.7.3 + semver: 7.7.4 string-width: 4.2.3 tar-stream: 2.2.0 tmp: 0.2.5 @@ -7997,8 +8003,6 @@ snapshots: semver@7.7.2: {} - semver@7.7.3: {} - semver@7.7.4: {} send@0.18.0: @@ -8356,7 +8360,7 @@ snapshots: picomatch: 4.0.3 rolldown: 1.0.0-beta.59 rolldown-plugin-dts: 0.20.0(oxc-resolver@11.16.3)(rolldown@1.0.0-beta.59)(typescript@5.9.3) - semver: 7.7.3 + semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 @@ -8644,7 +8648,7 @@ snapshots: eslint-visitor-keys: 5.0.0 espree: 11.0.0 esquery: 1.7.0 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color From a9b51f4073b98099418c088d681f24252cda47ca Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 2 May 2026 23:22:35 -0700 Subject: [PATCH 2/4] rm changeset --- .changeset/semver-version-handling.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/semver-version-handling.md diff --git a/.changeset/semver-version-handling.md b/.changeset/semver-version-handling.md deleted file mode 100644 index 6036b2f..0000000 --- a/.changeset/semver-version-handling.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@tanstack/intent": patch ---- - -Replace custom version parsing and comparison with `semver` for stale drift reporting and installed package variant selection. - -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. From 47d7f608cd13c2b411f9b452045513791bb2777e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 06:23:24 +0000 Subject: [PATCH 3/4] ci: apply automated fixes --- packages/intent/src/staleness.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 9741a3b..0139644 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -476,8 +476,10 @@ export async function checkStaleness( const skillFiles = findSkillFiles(skillsDir) const skillMetas: Array = skillFiles.map((filePath) => { const fm = parseFrontmatter(filePath) - const relName = toPosixPath(relative(skillsDir, filePath)) - .replace(/[/\\]SKILL\.md$/, '') + const relName = toPosixPath(relative(skillsDir, filePath)).replace( + /[/\\]SKILL\.md$/, + '', + ) return { name: typeof fm?.name === 'string' ? fm.name : relName, relName, From bac2d390a6232b6e0452389945eb725247d0bb82 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 2 May 2026 23:27:27 -0700 Subject: [PATCH 4/4] re-add changset --- .changeset/fruity-hounds-attend.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fruity-hounds-attend.md diff --git a/.changeset/fruity-hounds-attend.md b/.changeset/fruity-hounds-attend.md new file mode 100644 index 0000000..9904c85 --- /dev/null +++ b/.changeset/fruity-hounds-attend.md @@ -0,0 +1,7 @@ +--- +'@tanstack/intent': patch +--- + +Replace custom version parsing and comparison with `semver` for stale drift reporting and installed package variant selection. + +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.