/** * Dependency resolver for market assets. * * 1. Collects the target assets and all transitive asset dependencies. * 2. For each asset, selects an explicitly requested immutable version, or * otherwise takes the latest published version, and verifies it satisfies * ALL constraints from every dependent. * 3. Intersects npm dependency ranges across all resolved assets and checks * that they are compatible. */ import * as semver from 'semver' import type { MarketClient } from './client.js' import type { AssetFileEntry } from './contract.js' import { lookupAssets } from './refs.js' import type { AssetRefEntry } from './v1/index.js' import { assetDependencyAlias, type AssetDependencyAlias, assetDependencyRange, semverSchema, type AssetDependencies, } from './schemas.js' export interface ResolvedAsset { name: string type: string version: string description: string | null npmDependencies: Record assetDependencies: AssetDependencies skillDependencies: Record /** The resolved version's file index, carried from resolution so install needs no further reads. */ files: AssetFileEntry[] } export interface ResolveResult { assets: ResolvedAsset[] npmDependencies: Record skillDependencies: Record /** * File aliases declared by dependents, keyed by dependency name: canonical install path → the * path the dependent's project keeps the file at. Install writes files through this map (a * template whose author renamed a dependency's files reinstalls them at the renamed locations). */ assetAliases?: Record } interface AssetRequest { name: string range: string // semver range, e.g. "^1.0.0" or "*" } interface Constraint { range: string from: string } type Constraints = Map type ExactPins = Map interface ResolutionPass { constraints: Constraints resolved: Map } const MAX_RESOLUTION_PASSES = 100 export class ResolutionError extends Error { constructor(message: string) { super(message) this.name = 'ResolutionError' } } export async function resolve( client: MarketClient['asset'], requests: AssetRequest[], opts: { includeUnapproved?: boolean; key?: string } = {}, ): Promise { const metaCache = new Map() const includeUnapproved = opts.includeUnapproved ?? false let exactPins = exactPinsFor(rootConstraints(requests)) const seenPinSets = new Set() for (let pass = 1; pass <= MAX_RESOLUTION_PASSES; pass++) { const signature = pinSignature(exactPins) if (seenPinSets.has(signature)) { throw new ResolutionError( `Exact-version resolution did not converge; pin set repeated after ${pass - 1} passes.`, ) } seenPinSets.add(signature) const result = await resolvePass( client, requests, exactPins, includeUnapproved, metaCache, opts.key, ) const nextPins = exactPinsFor(result.constraints) if (!samePins(exactPins, nextPins)) { exactPins = nextPins continue } validateConstraints(result.resolved, result.constraints) const assets = Array.from(result.resolved.values()) return { assets, npmDependencies: mergeNpmDependencies(assets), skillDependencies: mergeSkillDependencies(assets), assetAliases: mergeAssetAliases(assets), } } throw new ResolutionError( `Exact-version resolution did not converge within ${MAX_RESOLUTION_PASSES} passes.`, ) } async function resolvePass( client: MarketClient['asset'], requests: AssetRequest[], initialPins: ExactPins, includeUnapproved: boolean, metaCache: Map, key?: string, ): Promise { const constraints = rootConstraints(requests) const resolved = new Map() let wave = requests.map(({ name }) => name) // Dependency-tree BFS: each wave's metadata is fetched concurrently (a ~60-asset template // resolves in a couple of round trips instead of sixty), then integrated in order so // constraint accumulation stays deterministic. A dependency first constrained by a sibling in // the same wave is fetched before that constraint lands; the outer exact-pin convergence loop // already re-passes in that case, exactly as it does for pins discovered late. while (wave.length > 0) { const names = [...new Set(wave)].filter((name) => !resolved.has(name)) wave = [] const wanted = names.map((assetName) => { const constrainedExact = requiredExactVersion(assetName, constraints.get(assetName) ?? []) const exactVersion = constrainedExact ?? initialPins.get(assetName) ?? null return { assetName, exactVersion } }) // One collection read per wave; the cache spans convergence passes, so a // re-pass that pins already-seen versions makes no further requests. const misses = wanted.filter( ({ assetName, exactVersion }) => !metaCache.has(metaKey(assetName, exactVersion)), ) const fetched = await lookupAssets( client, misses.map(({ assetName, exactVersion }) => ({ name: assetName, version: exactVersion ?? undefined, })), { includeUnapproved, key }, ) misses.forEach(({ assetName, exactVersion }, index) => { const entry = fetched[index] if (!entry) return metaCache.set(metaKey(assetName, exactVersion), entry) metaCache.set(metaKey(assetName, entry.version), entry) }) for (const { assetName, exactVersion } of wanted) { const meta = metaCache.get(metaKey(assetName, exactVersion)) ?? null if (!meta) { if (exactVersion) { throw new ResolutionError( `Asset version "${assetName}@${exactVersion}" not found${ includeUnapproved ? '' : ' or not approved' }.`, ) } throw new ResolutionError(`Asset "${assetName}" not found.`) } if (!includeUnapproved && !meta.approved) { throw new ResolutionError(`Asset "${assetName}" has no approved versions.`) } if (exactVersion && meta.version !== exactVersion) { throw new ResolutionError( `Market API did not return requested version "${assetName}@${exactVersion}". ` + 'Refusing to fall back to a different version.', ) } const selectedVersion = meta.version const assetDependencies = meta.assetDependencies resolved.set(assetName, { name: assetName, type: meta.type, version: selectedVersion, description: meta.description, npmDependencies: meta.npmDependencies, assetDependencies, skillDependencies: meta.skillDependencies, files: meta.files, }) for (const [dependencyName, dependencyValue] of Object.entries(assetDependencies)) { const dependencyRange = assetDependencyRange(dependencyValue) addConstraint( constraints, dependencyName, dependencyRange, `${assetName}@${selectedVersion}`, ) wave.push(dependencyName) } } } return { constraints, resolved } } function validateConstraints(resolved: Map, constraints: Constraints): void { for (const [assetName, asset] of resolved) { const assetConstraints = constraints.get(assetName) ?? [] for (const c of assetConstraints) { if (!semver.satisfies(asset.version, c.range)) { throw new ResolutionError( `Conflict: "${assetName}@${asset.version}" does not satisfy ` + `${c.range} (required by ${c.from}).`, ) } } } } /** * Merge skill dependencies from all resolved assets. Skills are not versioned * by the resolver — each entry is a label mapped to a `skills add` source — so * the merge is a simple union. The same label declared by two assets must point * at the same source; otherwise installing one would silently shadow the other. */ function mergeSkillDependencies(assets: ResolvedAsset[]): Record { const merged: Record = {} const origin: Record = {} for (const asset of assets) { const from = `${asset.name}@${asset.version}` for (const [label, source] of Object.entries(asset.skillDependencies)) { if (label in merged && merged[label] !== source) { throw new ResolutionError( `skill dependency conflict for "${label}":\n` + ` ${merged[label]} (from ${origin[label]})\n` + ` ${source} (from ${from})`, ) } merged[label] = source origin[label] = from } } return merged } /** * Merge file aliases from every resolved asset, keyed by the dependency they apply to. Two * dependents rarely alias the same file; when they do, the first resolved asset wins — a * deterministic choice that keeps installs reproducible. */ function mergeAssetAliases(assets: ResolvedAsset[]): Record { const merged: Record = {} for (const asset of assets) { for (const [dependencyName, value] of Object.entries(asset.assetDependencies)) { const alias = assetDependencyAlias(value) if (Object.keys(alias).length === 0) continue merged[dependencyName] = { ...alias, ...(merged[dependencyName] ?? {}) } } } return merged } function rootConstraints(requests: AssetRequest[]): Constraints { const constraints: Constraints = new Map() for (const request of requests) { addConstraint(constraints, request.name, request.range, '') } return constraints } function addConstraint(constraints: Constraints, name: string, range: string, from: string): void { const existing = constraints.get(name) ?? [] existing.push({ range, from }) constraints.set(name, existing) } function exactPinsFor(constraints: Constraints): ExactPins { const pins: ExactPins = new Map() for (const [assetName, assetConstraints] of constraints) { const version = requiredExactVersion(assetName, assetConstraints) if (version) pins.set(assetName, version) } return pins } function samePins(left: ExactPins, right: ExactPins): boolean { if (left.size !== right.size) return false for (const [name, version] of left) { if (right.get(name) !== version) return false } return true } function pinSignature(pins: ExactPins): string { return JSON.stringify([...pins].sort(([left], [right]) => left.localeCompare(right))) } const metaKey = (name: string, version: string | null): string => version ? `${name}@${version}` : name function requiredExactVersion(assetName: string, constraints: Constraint[]): string | null { const exactVersions = new Set( constraints .map(({ range }) => exactVersion(range)) .filter((version): version is string => version !== null), ) if (exactVersions.size <= 1) return exactVersions.values().next().value ?? null throw new ResolutionError( `Conflicting exact versions requested for "${assetName}":\n` + constraints.map((constraint) => ` ${constraint.range} (from ${constraint.from})`).join('\n'), ) } function exactVersion(range: string): string | null { const candidate = range.trim().replace(/^=/u, '') return semverSchema.safeParse(candidate).success ? candidate : null } /** * Merge npm dependency ranges from all resolved assets. * For each package, check that all declared ranges are compatible * (using semver.intersects), then return the single declared range that is a * subset of every other — the true intersection. */ function mergeNpmDependencies(assets: ResolvedAsset[]): Record { // Collect all ranges per package const rangesPerPkg: Map = new Map() for (const asset of assets) { for (const [pkg, range] of Object.entries(asset.npmDependencies)) { const existing = rangesPerPkg.get(pkg) ?? [] existing.push({ range, from: `${asset.name}@${asset.version}` }) rangesPerPkg.set(pkg, existing) } } const merged: Record = {} for (const [pkg, ranges] of rangesPerPkg) { const declared = new Set(ranges.map(({ range }) => range)) if (pkg.startsWith('@drawcall/') && declared.has('latest')) { merged[pkg] = 'latest' continue } if (declared.size === 1) { merged[pkg] = ranges[0].range continue } if (ranges.some(({ range }) => semver.validRange(range) === null)) { throw new ResolutionError( `npm dependency conflict for "${pkg}":\n` + ranges.map((r) => ` ${r.range} (from ${r.from})`).join('\n'), ) } // Check pairwise compatibility for (let i = 0; i < ranges.length; i++) { for (let j = i + 1; j < ranges.length; j++) { if (!semver.intersects(ranges[i].range, ranges[j].range)) { throw new ResolutionError( `npm dependency conflict for "${pkg}":\n` + ` ${ranges[i].range} (from ${ranges[i].from})\n` + ` ${ranges[j].range} (from ${ranges[j].from})`, ) } } } // Use the single declared range that is contained in every other declared // range — the true intersection of all constraints. Picking by "highest // minimum version" is unsound: a range with a higher minimum can also have // a higher (or open) upper bound, silently dropping another dependent's // upper bound. If no single range represents the intersection, it cannot be // expressed as one package.json range, so fail loudly. const narrowest = ranges.find((candidate) => ranges.every((other) => semver.subset(candidate.range, other.range)), ) if (!narrowest) { throw new ResolutionError( `npm dependency ranges for "${pkg}" overlap but cannot be combined into a single range:\n` + ranges.map((r) => ` ${r.range} (from ${r.from})`).join('\n'), ) } merged[pkg] = narrowest.range } return merged }