Skip to content

Commit b41d052

Browse files
committed
feat: add version conflict detection and reporting in CLI output
1 parent ae18014 commit b41d052

4 files changed

Lines changed: 123 additions & 3 deletions

File tree

packages/intent/src/cli.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,27 @@ async function cmdList(args: Array<string>): Promise<void> {
7878
])
7979
printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows)
8080

81+
if (result.conflicts.length > 0) {
82+
console.log(`
83+
Version conflicts:
84+
`)
85+
for (const conflict of result.conflicts) {
86+
const otherVariants = conflict.variants.filter(
87+
(variant) => variant.packageRoot !== conflict.chosen.packageRoot,
88+
)
89+
console.log(
90+
` ${conflict.packageName} -> using ${conflict.chosen.version}`,
91+
)
92+
console.log(` chosen: ${conflict.chosen.packageRoot}`)
93+
for (const variant of otherVariants) {
94+
console.log(
95+
` also found: ${variant.version} at ${variant.packageRoot}`,
96+
)
97+
}
98+
console.log()
99+
}
100+
}
101+
81102
// Skills detail
82103
const allSkills = result.packages.map((p) => p.skills)
83104
const nameWidth = computeSkillNameWidth(allSkills)

packages/intent/src/scanner.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import {
88
resolveDepDir,
99
} from './utils.js'
1010
import type {
11+
InstalledVariant,
1112
IntentConfig,
1213
IntentPackage,
1314
NodeModulesScanTarget,
1415
ScanResult,
1516
SkillEntry,
17+
VersionConflict,
1618
} from './types.js'
1719
import type { Dirent } from 'node:fs'
1820

@@ -212,7 +214,7 @@ function comparePackageVersions(a: string, b: string): number {
212214

213215
function formatVariantWarning(
214216
name: string,
215-
variants: Array<{ version: string; packageRoot: string }>,
217+
variants: Array<InstalledVariant>,
216218
chosen: IntentPackage,
217219
): string | null {
218220
const uniqueVersions = new Set(variants.map((variant) => variant.version))
@@ -225,6 +227,24 @@ function formatVariantWarning(
225227
return `Found ${variants.length} installed variants of ${name} across ${uniqueVersions.size} versions (${details}). Using ${chosen.version} from ${chosen.packageRoot}.`
226228
}
227229

230+
function toVersionConflict(
231+
packageName: string,
232+
variants: Array<InstalledVariant>,
233+
chosen: IntentPackage,
234+
): VersionConflict | null {
235+
const uniqueVersions = new Set(variants.map((variant) => variant.version))
236+
if (uniqueVersions.size <= 1) return null
237+
238+
return {
239+
packageName,
240+
chosen: {
241+
version: chosen.version,
242+
packageRoot: chosen.packageRoot,
243+
},
244+
variants,
245+
}
246+
}
247+
228248
// ---------------------------------------------------------------------------
229249
// Main scanner
230250
// ---------------------------------------------------------------------------
@@ -238,6 +258,7 @@ export async function scanForIntents(root?: string): Promise<ScanResult> {
238258

239259
const packages: Array<IntentPackage> = []
240260
const warnings: Array<string> = []
261+
const conflicts: Array<VersionConflict> = []
241262
const nodeModules: ScanResult['nodeModules'] = {
242263
local: {
243264
path: nodeModulesDir,
@@ -473,13 +494,18 @@ export async function scanForIntents(root?: string): Promise<ScanResult> {
473494
}
474495

475496
if (!nodeModules.local.exists && !nodeModules.global.exists) {
476-
return { packageManager, packages, warnings, nodeModules }
497+
return { packageManager, packages, warnings, conflicts, nodeModules }
477498
}
478499

479500
for (const pkg of packages) {
480501
const variants = packageVariants.get(pkg.name)
481502
if (!variants) continue
482503

504+
const conflict = toVersionConflict(pkg.name, [...variants.values()], pkg)
505+
if (conflict) {
506+
conflicts.push(conflict)
507+
}
508+
483509
const warning = formatVariantWarning(pkg.name, [...variants.values()], pkg)
484510
if (warning) {
485511
warnings.push(warning)
@@ -489,5 +515,5 @@ export async function scanForIntents(root?: string): Promise<ScanResult> {
489515
// Sort by dependency order
490516
const sorted = topoSort(packages)
491517

492-
return { packageManager, packages: sorted, warnings, nodeModules }
518+
return { packageManager, packages: sorted, warnings, conflicts, nodeModules }
493519
}

packages/intent/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface ScanResult {
1717
packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown'
1818
packages: Array<IntentPackage>
1919
warnings: Array<string>
20+
conflicts: Array<VersionConflict>
2021
nodeModules: {
2122
local: NodeModulesScanTarget
2223
global: NodeModulesScanTarget
@@ -39,6 +40,17 @@ export interface IntentPackage {
3940
packageRoot: string
4041
}
4142

43+
export interface InstalledVariant {
44+
version: string
45+
packageRoot: string
46+
}
47+
48+
export interface VersionConflict {
49+
packageName: string
50+
chosen: InstalledVariant
51+
variants: Array<InstalledVariant>
52+
}
53+
4254
export interface SkillEntry {
4355
name: string
4456
path: string

packages/intent/tests/cli.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ describe('cli commands', () => {
122122
const output = logSpy.mock.calls.at(-1)?.[0]
123123
const parsed = JSON.parse(String(output)) as {
124124
packages: Array<{ name: string; version: string; packageRoot: string }>
125+
conflicts: Array<{ packageName: string }>
125126
warnings: Array<string>
126127
}
127128

@@ -132,9 +133,69 @@ describe('cli commands', () => {
132133
version: '0.5.2',
133134
packageRoot: pkgDir,
134135
})
136+
expect(parsed.conflicts).toEqual([])
135137
expect(parsed.warnings).toEqual([])
136138
})
137139

140+
it('explains which package version was chosen when conflicts exist', async () => {
141+
const root = mkdtempSync(join(tmpdir(), 'intent-cli-conflicts-'))
142+
tempDirs.push(root)
143+
144+
writeJson(join(root, 'package.json'), {
145+
name: 'app',
146+
private: true,
147+
dependencies: {
148+
'consumer-a': '1.0.0',
149+
'consumer-b': '1.0.0',
150+
},
151+
})
152+
153+
const consumerADir = join(root, 'node_modules', 'consumer-a')
154+
const consumerBDir = join(root, 'node_modules', 'consumer-b')
155+
const queryV4Dir = join(consumerADir, 'node_modules', '@tanstack', 'query')
156+
const queryV5Dir = join(consumerBDir, 'node_modules', '@tanstack', 'query')
157+
158+
writeJson(join(consumerADir, 'package.json'), {
159+
name: 'consumer-a',
160+
version: '1.0.0',
161+
dependencies: { '@tanstack/query': '4.0.0' },
162+
})
163+
writeJson(join(consumerBDir, 'package.json'), {
164+
name: 'consumer-b',
165+
version: '1.0.0',
166+
dependencies: { '@tanstack/query': '5.0.0' },
167+
})
168+
writeJson(join(queryV4Dir, 'package.json'), {
169+
name: '@tanstack/query',
170+
version: '4.0.0',
171+
intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' },
172+
})
173+
writeJson(join(queryV5Dir, 'package.json'), {
174+
name: '@tanstack/query',
175+
version: '5.0.0',
176+
intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' },
177+
})
178+
writeSkillMd(join(queryV4Dir, 'skills', 'fetching'), {
179+
name: 'fetching',
180+
description: 'Query v4 skill',
181+
})
182+
writeSkillMd(join(queryV5Dir, 'skills', 'fetching'), {
183+
name: 'fetching',
184+
description: 'Query v5 skill',
185+
})
186+
187+
process.chdir(root)
188+
189+
const exitCode = await main(['list'])
190+
const output = logSpy.mock.calls.flat().join('\n')
191+
192+
expect(exitCode).toBe(0)
193+
expect(output).toContain('Version conflicts:')
194+
expect(output).toContain('@tanstack/query -> using 5.0.0')
195+
expect(output).toContain(`chosen: ${queryV5Dir}`)
196+
expect(output).toContain(`also found: 4.0.0 at ${queryV4Dir}`)
197+
})
198+
138199
it('validates a well-formed skills directory', async () => {
139200
const root = mkdtempSync(join(tmpdir(), 'intent-cli-validate-'))
140201
tempDirs.push(root)

0 commit comments

Comments
 (0)