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
7 changes: 7 additions & 0 deletions .changeset/fruity-hounds-attend.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/intent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
73 changes: 11 additions & 62 deletions packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string | number>
}

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(
Expand Down
89 changes: 63 additions & 26 deletions packages/intent/src/staleness.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -156,7 +186,14 @@ function readPackageJson(packageDir: string): Record<string, unknown> | 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<string> | undefined): Array<string> {
Expand All @@ -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
Expand Down Expand Up @@ -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) ||
Expand All @@ -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 ||
Expand Down Expand Up @@ -416,7 +453,7 @@ export function buildWorkspaceCoverageSignals({
],
needsReview: true,
packageName,
packageRoot: relative(artifactRoot, packageDir).split(sep).join('/'),
packageRoot: getRelativePackageDir(artifactRoot, packageDir),
})
}

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

// Find all skills
const skillFiles = findSkillFiles(skillsDir)
const skillMetas: Array<SkillMeta> = skillFiles.map((filePath) => {
const fm = parseFrontmatter(filePath)
const relName = relative(skillsDir, filePath)
.replace(/[/\\]SKILL\.md$/, '')
.split(sep)
.join('/')
const relName = toPosixPath(relative(skillsDir, filePath)).replace(
/[/\\]SKILL\.md$/,
'',
)
return {
name: typeof fm?.name === 'string' ? fm.name : relName,
relName,
Expand Down Expand Up @@ -482,7 +519,7 @@ export async function checkStaleness(
if (
currentVersion &&
skill.libraryVersion &&
skill.libraryVersion !== currentVersion
classifyVersionDrift(skill.libraryVersion, currentVersion) !== null
) {
reasons.push(
`version drift (${skill.libraryVersion} → ${currentVersion})`,
Expand Down
63 changes: 63 additions & 0 deletions packages/intent/tests/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
33 changes: 31 additions & 2 deletions packages/intent/tests/staleness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading