From 92c30e9cf98958783a1b9aecb159c5b9a84f007d Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Sun, 11 Oct 2026 23:49:09 +0300 Subject: [PATCH 1/3] fix(add): keep type/imported files under --runtime, resolve utils/lib deps, honour paths with target, quiet type-only cycles - runtime filter keeps *.types.ts/*.d.ts and files imported (transitively) by kept files - utils, utils/, lib/ registryDependencies resolve against their own endpoints; installed on add if missing (never overwritten when only a dependency) - paths precedence: --path flag > config.paths by target prefix > file target - dependency cycles: no default warning, items installed once, type-only edges at --verbose - registry item cache namespaced per --registry-url --- src/commands/add.ts | 54 ++++++-- src/index.ts | 5 + src/registry/api.ts | 68 ++++++++-- src/utils/dependency-resolver.ts | 211 ++++++++++++++++++++++++------- src/utils/logger.ts | 5 + src/utils/paths.ts | 99 +++++++++++++-- src/utils/runtime-files.ts | 128 ++++++++++++++++++- tests/commands/add-cdn.test.ts | 3 +- 8 files changed, 481 insertions(+), 92 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index 5b76cfa..1c1d28c 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -9,7 +9,7 @@ import { checkProjectDependencies, showDependencyStatus } from "../utils/depende import { CLI_MESSAGES } from "../utils/cli-messages.js" import { installDependencies } from "../utils/package-manager.js" import { logger, Spinner } from "../utils/logger.js" -import { resolveRegistryTree } from "../utils/dependency-resolver.js" +import { normalizeDependencyName, resolveRegistryTree } from "../utils/dependency-resolver.js" import { handleError, ConfigNotFoundError } from "../utils/errors.js" import { buildUnifiedDiff, formatDiffPreview, hasDiff } from "../utils/diff-utils.js" import { applyTransforms, shouldTransformFile } from "../utils/transform.js" @@ -20,6 +20,7 @@ import { resolveInstallDir, } from "../utils/paths.js" import { ensureDir } from "../utils/project.js" +import { applyPathOverrides } from "../utils/paths.js" import { ensureDir as fsEnsureDir, pathExists, readFile, writeFile } from "../utils/fs.js" import { filterFilesForRuntime } from "../utils/runtime-files.js" @@ -34,20 +35,35 @@ interface AddOptions { registryVersion?: string strictCdn?: boolean runtime?: string + /** Repeatable `--path key=dir` overrides (highest precedence, see docs/configuration.md). */ + path?: string[] } +/** + * Hidden from catalogs only (interactive picker, `--all`). Never applied to + * explicit names or `registryDependencies`, so `utils` and `lib/*` resolve. + */ const ADD_EXCLUDED_COMPONENT_TYPES = ["registry:variants", "registry:lib", "registry:utils"] +/** Options for fetching a single item by name: no catalog exclusions. */ +function fetchOptions(requestOptions: T): T { + return { ...requestOptions, excludeTypes: [] } +} + +/** Types installed as shared foundations: never overwritten when pulled in as a dependency. */ +const SHARED_FOUNDATION_TYPES = new Set(["registry:utils", "registry:lib"]) + export { inferTargetFromType, resolveInstallDir } export async function addCommand(components: string[], options: AddOptions) { const registryType = resolveRegistryType(options.registry) try { - const config = await findConfig(registryType) - if (!config) { + const loadedConfig = await findConfig(registryType) + if (!loadedConfig) { throw new ConfigNotFoundError(registryType) } + const config = applyPathOverrides(loadedConfig, options.path) const cdnResolution: CdnResolutionOptions = { registryUrl: options.registryUrl || config.registryUrl, @@ -90,7 +106,7 @@ export async function addCommand(components: string[], options: AddOptions) { await ensureDir(root) } - const getComponentFn = (name: string, type: RegistryType) => getComponent(name, type, requestOptions) + const getComponentFn = (name: string, type: RegistryType) => getComponent(name, type, fetchOptions(requestOptions)) const results = await installRequestedComponents( selectedComponents, @@ -118,10 +134,11 @@ async function addAllComponents( handleValidationError(validation) } - const config = await findConfig(registryType) - if (!config) { + const loadedConfig = await findConfig(registryType) + if (!loadedConfig) { throw new ConfigNotFoundError(registryType) } + const config = applyPathOverrides(loadedConfig, options.path) const getAllComponentsFn = (type: RegistryType) => getAllComponents(type, requestOptions) @@ -153,7 +170,7 @@ async function addAllComponents( allComponents.map(c => c.name), registryType, config, - (name: string, type: RegistryType) => getComponent(name, type, requestOptions), + (name: string, type: RegistryType) => getComponent(name, type, fetchOptions(requestOptions)), options, allComponents ) @@ -164,7 +181,7 @@ async function addAllComponents( allComponents.map(c => c.name), registryType, config, - (name: string, type: RegistryType) => getComponent(name, type, requestOptions), + (name: string, type: RegistryType) => getComponent(name, type, fetchOptions(requestOptions)), options, allComponents ) @@ -197,7 +214,8 @@ async function processComponents( getComponentFn: (name: string, type: RegistryType) => Promise, options: AddOptions, preloadedComponents?: Component[], - totalCount?: number + totalCount?: number, + explicitNames?: ReadonlySet, ): Promise> { const results: Array<{ name: string; status: "success" | "error"; error?: string }> = [] const componentMap = new Map(preloadedComponents?.map(c => [c.name.toLowerCase(), c])) @@ -296,7 +314,12 @@ async function processComponents( continue } - await installComponentFiles(componentForInstall, config, options.force) + // utils/lib pulled in as a dependency are installed only if missing (user edits are kept, even with --force). + const pulledIn = + SHARED_FOUNDATION_TYPES.has(component.type) && + explicitNames !== undefined && + !explicitNames.has(component.name.toLowerCase()) + await installComponentFiles(componentForInstall, config, options.force && !pulledIn, { quiet: pulledIn }) if (component.dependencies.length > 0) { try { @@ -429,7 +452,9 @@ async function installRequestedComponents( const orderedNames = new Set(orderedComponents.map(component => component.name.toLowerCase())) const normalizedRequested = Array.from(new Set(componentNames.map(name => name.toLowerCase()))) - const missingRequested = normalizedRequested.filter(name => !orderedNames.has(name)) + const missingRequested = normalizedRequested.filter( + name => !orderedNames.has(normalizeDependencyName(name)), + ) const missingResults = missingRequested.map(name => ({ name, @@ -437,6 +462,7 @@ async function installRequestedComponents( error: `Component "${name}" was not found in ${registryType} registry` })) + const explicitNames = new Set(normalizedRequested.map(normalizeDependencyName)) const processingResults = await processComponents( orderedComponents.map(component => component.name), registryType, @@ -444,7 +470,8 @@ async function installRequestedComponents( resolverGetComponent, options, orderedComponents, - orderedComponents.length + orderedComponents.length, + explicitNames, ) return [...missingResults, ...processingResults] } @@ -475,6 +502,7 @@ async function installComponentFiles( component: Component, config: Config, force = false, + opts: { quiet?: boolean } = {}, ): Promise { for (const file of component.files) { const relative = resolveFileInstallPath(file, component.type, config) @@ -482,7 +510,7 @@ async function installComponentFiles( const fileName = path.basename(targetPath) if (!force && (await pathExists(targetPath))) { - console.log(` ⚠️ ${CLI_MESSAGES.status.skipped(fileName)}`) + if (!opts.quiet) console.log(` ⚠️ ${CLI_MESSAGES.status.skipped(fileName)}`) continue } diff --git a/src/index.ts b/src/index.ts index 90ac115..d451bd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,6 +105,11 @@ program "--runtime ", "Install only this runtime's files (+ shared); skip items with a different runtime field", ) + .option( + "--path ", + "Override an install folder for this run (e.g. ui=kit); beats config.paths and registry targets. Repeatable", + (value: string, previous: string[] = []) => [...previous, value], + ) .option("--dry-run", "Plan without writing") .option("--retry", "Retry CDN requests") .option("--registry-url ", "Override registry base URL") diff --git a/src/registry/api.ts b/src/registry/api.ts index 769ab2f..bd7c8ed 100644 --- a/src/registry/api.ts +++ b/src/registry/api.ts @@ -1,6 +1,7 @@ import { Component, componentSchema } from "./schema.js" import { SCHEMA_CONFIG, TYPE_TO_FOLDER, getCdnUrls, type RegistryType, type CdnResolutionOptions } from "../utils/schema-config.js" import { logger } from "../utils/logger.js" +import { createHash } from "crypto" import { getCachedJson, setCachedJson } from "../utils/cache.js" const REGISTRY_INDEX_CACHE_TTL_MS = 3_600_000 @@ -22,6 +23,27 @@ export interface RegistryFetchOptions { cdn?: CdnResolutionOptions } +/** + * Cache key namespace. An explicit registry URL gets its own namespace so that + * two registries (e.g. one per runtime) never share cached items by name. + */ +function cacheScope(registryType: RegistryType, options: RegistryFetchOptions = {}): string { + const url = options.cdn?.registryUrl?.trim() + if (!url) return registryType + const hash = createHash("sha1").update(url.replace(/\/+$/, "")).digest("hex").slice(0, 10) + return `${registryType}/@${hash}` +} + +/** + * `lib/` and `utils/` registry dependencies address the lib and + * utils endpoints directly (`lib/.json`, `utils/.json`). + */ +export function parseTypedDependency(name: string): { name: string; type?: "registry:lib" | "registry:utils" } { + const m = name.trim().match(/^(lib|utils)\/(.+)$/i) + if (!m) return { name: name.trim() } + return { name: m[2]!, type: m[1]!.toLowerCase() === "lib" ? "registry:lib" : "registry:utils" } +} + export function getRegistryCdnState( registryType: RegistryType, options: RegistryFetchOptions = {} @@ -127,7 +149,7 @@ async function getRegistryIndex(registryType: RegistryType, options: RegistryFet } const cached = await getCachedJson( - `${registryType}/index.json`, + `${cacheScope(registryType, options)}/index.json`, { noCache: options.noCache, ttlMs: REGISTRY_INDEX_CACHE_TTL_MS } ) if (cached) { @@ -139,7 +161,7 @@ async function getRegistryIndex(registryType: RegistryType, options: RegistryFet cache.registryIndex = await fetchFromRegistryPath("index.json", registryType, options) await setCachedJson( - `${registryType}/index.json`, + `${cacheScope(registryType, options)}/index.json`, cache.registryIndex, { ttlMs: REGISTRY_INDEX_CACHE_TTL_MS } ) @@ -147,13 +169,17 @@ async function getRegistryIndex(registryType: RegistryType, options: RegistryFet } async function getComponentByType( - name: string, + rawName: string, registryType: RegistryType, options: RegistryFetchOptions = {} ): Promise { + const typed = parseTypedDependency(rawName) + const name = typed.name const normalizedName = name.toLowerCase() + const scope = cacheScope(registryType, options) + const cacheKey = `${scope}/components/${typed.type ? typed.type.replace("registry:", "") + "-" : ""}${normalizedName}.json` const cachedComponent = await getCachedJson( - `${registryType}/components/${normalizedName}.json`, + cacheKey, { noCache: options.noCache, ttlMs: REGISTRY_INDEX_CACHE_TTL_MS } ) if (cachedComponent) { @@ -165,31 +191,49 @@ async function getComponentByType( } try { - const index = await getRegistryIndex(registryType, options) + let index: any = null + try { + index = await getRegistryIndex(registryType, options) + } catch (error) { + // Typed dependencies (utils, lib/*) can still be fetched without an index. + if (!typed.type && normalizedName !== "utils") throw error + logger.debug(`Index unavailable, trying direct endpoint for ${rawName}`) + } const excludeTypes = options.excludeTypes ?? [] - const componentInfo = index.components?.find( + const componentInfo = index?.components?.find( (c: any) => typeof c?.name === "string" && c.name.toLowerCase() === normalizedName && + (!typed.type || c.type === typed.type) && !excludeTypes.includes(c.type) ) - if (!componentInfo) { + + // Resolve the endpoint type: index entry first; otherwise `lib/x`, `utils/x` + // and bare `utils` fall back to their own registry type endpoints. + let itemType: string | undefined = componentInfo?.type + if (!itemType && !excludeTypes.includes("registry:utils") && (typed.type === "registry:utils" || (!typed.type && normalizedName === "utils"))) { + itemType = "registry:utils" + } + if (!itemType && typed.type === "registry:lib" && !excludeTypes.includes("registry:lib")) { + itemType = "registry:lib" + } + if (!itemType) { logger.debug(`Component ${name} not found in ${registryType} registry`) return null } const folder = - componentInfo.type === "registry:variants" + itemType === "registry:variants" ? "components/variants" - : TYPE_TO_FOLDER[componentInfo.type as keyof typeof TYPE_TO_FOLDER] + : TYPE_TO_FOLDER[itemType as keyof typeof TYPE_TO_FOLDER] if (!folder) { - logger.debug(`Unknown component type: ${componentInfo.type}`) + logger.debug(`Unknown component type: ${itemType}`) return null } - logger.debug(`Loading ${name} from /${folder}/ (type: ${componentInfo.type})`) + logger.debug(`Loading ${name} from /${folder}/ (type: ${itemType})`) const data = await fetchFromRegistryPath(`${folder}/${name}.json`, registryType, options) - await setCachedJson(`${registryType}/components/${normalizedName}.json`, data, { ttlMs: REGISTRY_INDEX_CACHE_TTL_MS }) + await setCachedJson(cacheKey, data, { ttlMs: REGISTRY_INDEX_CACHE_TTL_MS }) return componentSchema.parse(data) } catch (error) { logger.debug(`Failed to get component by type: ${(error as Error).message}`) diff --git a/src/utils/dependency-resolver.ts b/src/utils/dependency-resolver.ts index 6953005..e515394 100644 --- a/src/utils/dependency-resolver.ts +++ b/src/utils/dependency-resolver.ts @@ -1,9 +1,73 @@ import { logger } from "./logger.js" import { Component } from "../registry/schema.js" import { type RegistryType } from "../utils/schema-config.js" +import { extractRelativeImports, isTypeFile, resolveImportKey } from "./runtime-files.js" type GetComponentFn = (name: string, registryType: RegistryType) => Promise +export type EdgeKind = "type-only" | "value" | "unknown" + +export interface DependencyEdge { + from: string + to: string + kind: EdgeKind +} + +export interface DependencyCycle { + /** Item names in cycle order (first item is not repeated at the end). */ + members: string[] + edges: DependencyEdge[] + /** True when every edge is known to be a type-only import. */ + typeOnly: boolean + /** True when every edge is a known value import (a real runtime import cycle). */ + allValue: boolean +} + +/** + * `utils/` and `lib/` are typed spellings of registry items ``. + * Resolver keys use the bare item name so every item is installed once. + */ +export function normalizeDependencyName(name: string): string { + const lower = name.trim().toLowerCase() + const m = lower.match(/^(?:lib|utils)\/(.+)$/) + return m ? m[1]! : lower +} + +function fileKey(file: { path: string; target?: string }): string { + return (file.target || file.path || "").replace(/\\/g, "/").replace(/^\.\//, "") +} + +/** + * Classify the dependency edge `from → to` by reading the relative imports of + * `from`'s files that land on files of `to`. + * - every such import is type-only (or targets a `*.types.ts` file) → "type-only" + * - at least one value import → "value" + * - no import points at `to` (e.g. package alias, or declared only) → "unknown" + */ +export function classifyEdge(from: Component, to: Component): EdgeKind { + const toKeys = new Map() + for (const file of to.files) toKeys.set(fileKey(file), fileKey(file)) + const known = new Set(toKeys.keys()) + let seen = 0 + for (const file of from.files) { + const key = fileKey(file) + // Resolve against both `from`'s own files and `to`'s (same install root). + const all = new Set([...known, ...from.files.map(fileKey)]) + for (const ref of extractRelativeImports(file.content ?? "")) { + const resolved = resolveImportKey(key, ref.specifier, all) + if (!resolved || !known.has(resolved)) continue + seen += 1 + const typeOnly = ref.typeOnly || isTypeFile(resolved) + if (!typeOnly) return "value" + } + } + return seen > 0 ? "type-only" : "unknown" +} + +function describeEdge(edge: DependencyEdge): string { + return `${edge.from} -> ${edge.to}` +} + export async function resolveRegistryTree( componentNames: string[], registryType: RegistryType, @@ -12,20 +76,23 @@ export async function resolveRegistryTree( const componentsByName = new Map() const state = new Map() const visitStack: string[] = [] - - const normalized = componentNames.map(name => name.toLowerCase()) + const backEdges = new Set() + const cycleStacks: string[][] = [] const ensureComponent = async (name: string): Promise => { - const key = name.toLowerCase() + const key = normalizeDependencyName(name) if (state.get(key) === "done") { return componentsByName.get(key) ?? null } if (state.get(key) === "visiting") { - const cycle = visitStack.includes(key) - ? [...visitStack, key].join(" -> ") - : key - logger.warn(`Circular registry dependency detected: ${cycle}`) + // Dependency cycle: not an error. Items are written once, file order does not matter. + const at = visitStack.indexOf(key) + if (at >= 0) { + const from = visitStack[visitStack.length - 1]! + backEdges.add(`${from}\u0000${key}`) + cycleStacks.push(visitStack.slice(at)) + } return componentsByName.get(key) ?? null } @@ -49,73 +116,121 @@ export async function resolveRegistryTree( return component } - for (const name of normalized) { + for (const name of componentNames) { await ensureComponent(name) } + const keyOf = (component: Component) => component.name.toLowerCase() + const keyByNormalized = new Map() + for (const [k, component] of componentsByName) keyByNormalized.set(k, keyOf(component)) + const graph = new Map>() const inDegree = new Map() - for (const component of componentsByName.values()) { - const name = component.name.toLowerCase() - if (!graph.has(name)) { - graph.set(name, new Set()) - inDegree.set(name, 0) - } + const name = keyOf(component) + graph.set(name, new Set()) + inDegree.set(name, 0) } - for (const component of componentsByName.values()) { - const from = component.name.toLowerCase() - const deps = component.registryDependencies ?? [] - for (const dep of deps) { - const to = dep.toLowerCase() - if (!componentsByName.has(to)) { - continue - } - - const targets = graph.get(to) - if (targets) { - targets.add(from) - } else { - graph.set(to, new Set([from])) - } - + for (const [fromKey, component] of componentsByName) { + const from = keyOf(component) + const seenDeps = new Set() + for (const dep of component.registryDependencies ?? []) { + const depKey = normalizeDependencyName(dep) + const target = componentsByName.get(depKey) + if (!target || seenDeps.has(depKey)) continue + seenDeps.add(depKey) + // Back edges (cycle closers) do not constrain the install order. + if (backEdges.has(`${fromKey}\u0000${depKey}`)) continue + const to = keyOf(target) + if (to === from) continue + graph.get(to)!.add(from) inDegree.set(from, (inDegree.get(from) ?? 0) + 1) } } const queue: string[] = [] inDegree.forEach((value, name) => { - if (value === 0) { - queue.push(name) - } + if (value === 0) queue.push(name) }) const orderedKeys: string[] = [] while (queue.length > 0) { - const current = queue.shift() - if (!current) continue - + const current = queue.shift()! orderedKeys.push(current) - const targets = graph.get(current) || new Set() - for (const next of targets) { + for (const next of graph.get(current) ?? []) { const degree = (inDegree.get(next) ?? 1) - 1 inDegree.set(next, degree) - if (degree === 0) { - queue.push(next) - } + if (degree === 0) queue.push(next) } } - // Fall back to stable insertion order for any remaining cyclic nodes. - for (const key of componentsByName.keys()) { - if (!orderedKeys.includes(key)) { - logger.warn(`Unresolved dependency cycle detected for ${key}, appending in current order`) - orderedKeys.push(key) - } + // Defensive: back edges are removed above, so this should not trigger. + for (const component of componentsByName.values()) { + const key = keyOf(component) + if (!orderedKeys.includes(key)) orderedKeys.push(key) + } + + if (cycleStacks.length > 0) { + reportCycles(findCycles(cycleStacks, componentsByName)) } + const byComponentName = new Map() + for (const component of componentsByName.values()) byComponentName.set(keyOf(component), component) return orderedKeys - .map(key => componentsByName.get(key)) + .map(key => byComponentName.get(key)) .filter((component): component is Component => Boolean(component)) } + +/** Build cycle descriptions (deduplicated) with classified edges. */ +export function findCycles( + stacks: string[][], + componentsByName: ReadonlyMap, +): DependencyCycle[] { + const seen = new Set() + const cycles: DependencyCycle[] = [] + for (const members of stacks) { + const canonical = [...members].sort().join("\u0000") + if (seen.has(canonical)) continue + seen.add(canonical) + + const edges: DependencyEdge[] = [] + for (let i = 0; i < members.length; i += 1) { + const from = members[i]! + const to = members[(i + 1) % members.length]! + const a = componentsByName.get(from) + const b = componentsByName.get(to) + edges.push({ from, to, kind: a && b ? classifyEdge(a, b) : "unknown" }) + } + cycles.push({ + members, + edges, + typeOnly: edges.every(e => e.kind === "type-only"), + allValue: edges.every(e => e.kind === "value"), + }) + } + return cycles +} + +/** + * Cycles are resolvable (each item is installed once, files are plain copies), + * so they are never a default-level warning. Only a cycle made of value imports + * on every edge gets a single concise warning; type-only edges are listed at + * --verbose. + */ +function reportCycles(cycles: DependencyCycle[]): void { + for (const cycle of cycles) { + if (cycle.allValue) { + logger.warn( + `Runtime import cycle between ${cycle.members.join(", ")} (installed once; check for circular value imports)`, + ) + continue + } + for (const edge of cycle.edges) { + if (edge.kind === "type-only") { + logger.verbose(`type-only dependency edge: ${describeEdge(edge)} (cycle ${cycle.members.join(" -> ")})`) + } + } + logger.verbose(`dependency cycle (resolved, installed once): ${[...cycle.members, cycle.members[0]!].join(" -> ")}`) + } +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 65146db..a32b4ab 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -72,6 +72,11 @@ export const logger = { debug(message: string, ...args: unknown[]) { output("debug", message, ...args) }, + /** Informational line shown only with --verbose. */ + verbose(message: string, ...args: unknown[]) { + if (!verboseEnabled) return + console.log(`i ${message}`, ...args) + }, spinner(text: string) { return new Spinner(text).start() }, diff --git a/src/utils/paths.ts b/src/utils/paths.ts index cc6eab6..bc268e9 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -32,6 +32,79 @@ export function resolveTypeFolder(componentType: string, config: Config): string return TYPE_TO_FOLDER[typed] || CANONICAL_PATHS.components } +/** Known target prefixes → path key, longest first. */ +const TARGET_PREFIX_KEYS: ReadonlyArray = [ + ["components/ui", "ui"], + ["components", "components"], + ["blocks", "blocks"], + ["layouts", "layouts"], + ["variants", "variants"], + ["widgets", "widgets"], + ["utils", "utils"], + ["lib", "lib"], +] + +function configuredPath(key: PathKey, config: Config): string | undefined { + const value = config.paths?.[key] + return value && value.trim() ? normalizeDir(value) : undefined +} + +/** + * Remap a registry `target` (relative to `dir`) through `config.paths`. + * + * 1. If the target starts with a canonical folder (`components/ui`, `components`, + * `blocks`, `layouts`, `variants`, `widgets`, `utils`, `lib`) and `paths` remaps + * that key, the prefix is replaced by the configured folder. + * 2. Otherwise (registry-specific first folder such as `primitives/` or `presets/`) + * the first folder is treated as the item type's own folder and replaced by + * `paths[]`, when configured. + * 3. With no matching `paths` entry the target is returned unchanged. + */ +export function mapTargetByPaths(target: string, componentType: string, config: Config): string { + if (!config.paths) return target + + for (const [prefix, key] of TARGET_PREFIX_KEYS) { + if (target === prefix || target.startsWith(prefix + "/")) { + const mapped = configuredPath(key, config) + return mapped ? joinTarget(mapped, target.slice(prefix.length)) : target + } + } + + const typeKey = TYPE_TO_PATH_KEY[componentType as ComponentType] + const mapped = typeKey ? configuredPath(typeKey, config) : undefined + if (!mapped) return target + const slash = target.indexOf("/") + return slash === -1 ? joinTarget(mapped, "/" + target) : joinTarget(mapped, target.slice(slash)) +} + +function joinTarget(folder: string, rest: string): string { + const tail = rest.replace(/^\/+/, "") + return tail ? toPosix(path.posix.join(folder, tail)) : folder +} + +/** + * Merge `--path key=dir` CLI flags over `config.paths` (flags win). + * Throws on a malformed pair or unknown key. + */ +export function applyPathOverrides(config: Config, flags?: readonly string[]): Config { + if (!flags || flags.length === 0) return config + const validKeys = Object.keys(CANONICAL_PATHS) + const overrides: Record = {} + for (const flag of flags) { + const eq = flag.indexOf("=") + const key = eq === -1 ? "" : flag.slice(0, eq).trim() + const dir = eq === -1 ? "" : flag.slice(eq + 1).trim() + if (!key || !dir) { + throw new Error(`Invalid --path "${flag}". Expected key=dir, e.g. --path ui=kit`) + } + if (!validKeys.includes(key)) { + throw new Error(`Unknown --path key "${key}". Valid keys: ${validKeys.join(", ")}`) + } + overrides[key] = dir + } + return { ...config, paths: { ...config.paths, ...overrides } } +} + /** * All install roots that `init` should ensure exist. */ @@ -85,18 +158,22 @@ export function resolveFileInstallPath( const typeFolder = resolveTypeFolder(componentType, config) if (file.target && file.target.trim()) { - let target = toPosix(file.target).replace(/^\.\//, "").replace(/^\/+/, "") - // Absolute-from-cwd style targets that already include dir - if (target === dir || target.startsWith(dir + "/")) { - return target - } - // Legacy: target starts with src/ - if (target.startsWith("src/")) { - target = target.slice(4) - if (dir === "src") return toPosix(path.posix.join("src", target)) - return toPosix(path.posix.join(dir, target)) + const original = toPosix(file.target).replace(/^\.\//, "").replace(/^\/+/, "") + // Target relative to `dir` (strip a leading `dir/`, or legacy `src/`) + let rel = original + let legacy: string + if (original === dir || original.startsWith(dir + "/")) { + rel = original === dir ? "" : original.slice(dir.length + 1) + legacy = original + } else if (original.startsWith("src/")) { + rel = original.slice(4) + legacy = dir === "src" ? toPosix(path.posix.join("src", rel)) : toPosix(path.posix.join(dir, rel)) + } else { + legacy = toPosix(path.posix.join(dir, original)) } - return toPosix(path.posix.join(dir, target)) + // Precedence: CLI --path (merged into config.paths) > config.paths by target prefix > file target + const mapped = rel ? mapTargetByPaths(rel, componentType, config) : rel + return mapped === rel ? legacy : toPosix(path.posix.join(dir, mapped)) } const raw = toPosix(file.path).replace(/^\.\//, "").replace(/^\/+/, "") diff --git a/src/utils/runtime-files.ts b/src/utils/runtime-files.ts index d8ca923..b846c84 100644 --- a/src/utils/runtime-files.ts +++ b/src/utils/runtime-files.ts @@ -58,12 +58,102 @@ export function matchesRuntimeFile(filePath: string, runtime: string): boolean { } } +/** `*.types.ts`, `*.types.tsx`, `*.d.ts`: pure type files, never tied to one runtime. */ +export function isTypeFile(filePath: string): boolean { + const base = path.basename(filePath).toLowerCase() + return /\.types\.(ts|tsx|mts|cts)$/.test(base) || /\.d\.(ts|mts|cts)$/.test(base) +} + +const IMPORT_EXTENSIONS = [ + "", + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".svelte", + ".vue", + ".json", + ".solid.tsx", +] + +export interface ImportRef { + /** Raw module specifier, e.g. `./button.types` */ + specifier: string + /** True when the statement only imports types (`import type`, `export type … from`, all `{ type X }`). */ + typeOnly: boolean +} + +/** + * Relative module specifiers (`./x`, `../x`) found in source text. + * Regex based on purpose (no parser dependency): good enough for registry + * sources (ts/tsx/svelte/vue script blocks). + */ +export function extractRelativeImports(source: string): ImportRef[] { + const refs: ImportRef[] = [] + const stripped = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "") + + const statement = /\b(import|export)\s+(type\s+)?([^"';]*?)\bfrom\s*["'](\.{1,2}\/[^"']*|\.{1,2})["']/g + for (const m of stripped.matchAll(statement)) { + const keyword = m[1] + const typeKeyword = Boolean(m[2]) + const clause = m[3] ?? "" + let typeOnly = typeKeyword + if (!typeOnly && keyword === "import") { + const named = clause.match(/^\s*\{([^}]*)\}\s*$/) + if (named) { + const specs = named[1]!.split(",").map((x) => x.trim()).filter(Boolean) + typeOnly = specs.length > 0 && specs.every((x) => /^type\s+/.test(x)) + } + } + refs.push({ specifier: m[4]!, typeOnly }) + } + + const sideEffect = /\bimport\s*["'](\.{1,2}\/[^"']*)["']/g + for (const m of stripped.matchAll(sideEffect)) { + refs.push({ specifier: m[1]!, typeOnly: false }) + } + + const dynamic = /\bimport\s*\(\s*["'](\.{1,2}\/[^"']*)["']\s*\)/g + for (const m of stripped.matchAll(dynamic)) { + refs.push({ specifier: m[1]!, typeOnly: false }) + } + return refs +} + +/** + * Resolve a relative specifier from `fromKey` to one of `known` file keys + * (posix, same root). Returns the matching key or undefined. + */ +export function resolveImportKey( + fromKey: string, + specifier: string, + known: ReadonlySet, +): string | undefined { + const joined = path.posix.normalize(path.posix.join(path.posix.dirname(fromKey), specifier)) + // TS-style `./x.js` may point at `./x.ts` + const base = /\.(m?js|jsx)$/.test(joined) && !known.has(joined) ? joined.replace(/\.(m?js|jsx)$/, "") : joined + for (const ext of IMPORT_EXTENSIONS) { + if (known.has(base + ext)) return base + ext + } + for (const ext of IMPORT_EXTENSIONS.slice(1)) { + if (known.has(`${base}/index${ext}`)) return `${base}/index${ext}` + } + return undefined +} + /** * Filter registry item files for `--runtime` / config.runtime. - * Shared assets (`*.shared.ts`, `*.variants.json`) are always kept. + * + * Kept: files matching the runtime, shared assets (`*.shared.ts`, + * `*.variants.json`), type files (`*.types.ts`, `*.d.ts`), and — transitively — + * every file of the same item that a kept file imports with a relative + * specifier. A file imported by a selected file is never dropped. * Utils/lib/variants items are never sliced. */ -export function filterFilesForRuntime( +export function filterFilesForRuntime( files: readonly T[], runtime: string | undefined, options?: { componentType?: string }, @@ -76,12 +166,36 @@ export function filterFilesForRuntime( return [...files] } - return files.filter((file) => { + const byKey = new Map() + for (const file of files) { const key = fileKey(file) - if (!key) return false - if (isSharedRuntimeFile(key)) return true - return matchesRuntimeFile(key, runtime) - }) + if (key) byKey.set(key, file) + } + const known = new Set(byKey.keys()) + + const kept = new Set() + const queue: string[] = [] + for (const key of byKey.keys()) { + if (isSharedRuntimeFile(key) || isTypeFile(key) || matchesRuntimeFile(key, runtime)) { + kept.add(key) + queue.push(key) + } + } + + while (queue.length > 0) { + const key = queue.pop()! + const content = byKey.get(key)?.content + if (!content) continue + for (const ref of extractRelativeImports(content)) { + const resolved = resolveImportKey(key, ref.specifier, known) + if (resolved && !kept.has(resolved)) { + kept.add(resolved) + queue.push(resolved) + } + } + } + + return files.filter((file) => kept.has(fileKey(file))) } export function isKnownRuntime(value: string): value is Runtime { diff --git a/tests/commands/add-cdn.test.ts b/tests/commands/add-cdn.test.ts index 72242c8..68ecf77 100644 --- a/tests/commands/add-cdn.test.ts +++ b/tests/commands/add-cdn.test.ts @@ -65,7 +65,8 @@ describe("add command CDN option wiring", () => { }) expect(registryApi.getComponent).toHaveBeenCalledWith("button", "ui", { - excludeTypes: ["registry:variants", "registry:lib", "registry:utils"], + // name fetches are never catalog-filtered (utils and lib/* resolve via registryDependencies) + excludeTypes: [], maxRetries: 1, noCache: false, cdn: { From 9ca1845f09aa94f28688daa190f34aa2930cb512 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Sun, 11 Oct 2026 23:49:09 +0300 Subject: [PATCH 2/3] test: synthetic fixture registry, unit tests and e2e script for the four registry gaps --- scripts/e2e-fixture-registry.mjs | 195 ++++++++++++++++++++ tests/README.md | 11 ++ tests/commands/add-fixture-registry.test.ts | 144 +++++++++++++++ tests/fixtures/build-fixture-registry.mjs | 168 +++++++++++++++++ tests/helpers/check-imports.mjs | 25 +++ tests/helpers/static-server.mjs | 27 +++ tests/registry/typed-dependencies.test.ts | 130 +++++++++++++ tests/utils/dependency-resolver.test.ts | 127 ++++++++++++- tests/utils/paths.test.ts | 92 +++++++++ tests/utils/runtime-files.test.ts | 101 ++++++++++ 10 files changed, 1018 insertions(+), 2 deletions(-) create mode 100644 scripts/e2e-fixture-registry.mjs create mode 100644 tests/commands/add-fixture-registry.test.ts create mode 100644 tests/fixtures/build-fixture-registry.mjs create mode 100644 tests/helpers/check-imports.mjs create mode 100644 tests/helpers/static-server.mjs create mode 100644 tests/registry/typed-dependencies.test.ts create mode 100644 tests/utils/paths.test.ts diff --git a/scripts/e2e-fixture-registry.mjs b/scripts/e2e-fixture-registry.mjs new file mode 100644 index 0000000..5709401 --- /dev/null +++ b/scripts/e2e-fixture-registry.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +/** + * End-to-end check of the BUILT CLI (dist/index.js) against a generated, synthetic static registry. + * Covers: runtime filter keeps *.types.ts + imported helpers, utils/lib resolved via registryDependencies, + * `paths` / `--path` precedence with registry targets, quiet type-only cycles. Two runtimes: svelte, react. + * + * npm run build && node scripts/e2e-fixture-registry.mjs (or: npm run test:e2e) + * + * No network, no npm installs (fixture items declare no npm dependencies). Uses a temp HOME for the CLI cache. + */ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { buildFixtureRegistry } from "../tests/fixtures/build-fixture-registry.mjs" +import { serveDir } from "../tests/helpers/static-server.mjs" +import { findUnresolvedImports } from "../tests/helpers/check-imports.mjs" + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const cli = process.env.UI8KIT_CLI || path.join(root, "dist", "index.js") +assert.ok(fs.existsSync(cli), "dist/index.js missing: run `npm run build` first") + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ui8kit-e2e-")) +const registryDir = path.join(tmp, "registry") +buildFixtureRegistry(registryDir) +const server = await serveDir(registryDir) + +let failures = 0 +function check(name, fn) { + try { + fn() + console.log(` ok ${name}`) + } catch (error) { + failures += 1 + console.log(` FAIL ${name}\n ${String(error.message).split("\n").join("\n ")}`) + } +} + +// Async on purpose: the static server lives in this process and must keep serving while the CLI runs. +function run(cwd, args) { + const home = path.join(tmp, "home") + fs.mkdirSync(home, { recursive: true }) + return new Promise((resolve) => { + const child = spawn(process.execPath, [cli, ...args], { + cwd, + env: { ...process.env, HOME: home, USERPROFILE: home, NO_COLOR: "1" }, + }) + let out = "" + child.stdout.on("data", (d) => (out += d)) + child.stderr.on("data", (d) => (out += d)) + const timer = setTimeout(() => child.kill("SIGKILL"), 120_000) + child.on("close", (code) => { + clearTimeout(timer) + resolve({ code, out }) + }) + }) +} + +function project(name) { + const dir = path.join(tmp, name) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, private: true })) + return dir +} + +const exists = (dir, rel) => fs.existsSync(path.join(dir, rel)) + +for (const rt of ["svelte", "react"]) { + const ext = rt === "react" ? "tsx" : "svelte" + const regUrl = `${server.url}/${rt}` + const common = ["--registry-url", regUrl, "--strict-cdn"] + console.log(`\n[${rt}]`) + + // 1) init with --runtime (no `--framework none` workaround), then add three items + { + const dir = project(`${rt}-three`) + const init = await run(dir, ["init", "--yes", "--framework", rt, "--runtime", rt, "--dir", "src", ...common]) + check("init --runtime succeeds", () => assert.equal(init.code, 0, init.out)) + const add = await run(dir, ["add", "button", "card", "hero-split", "--force", "--runtime", rt]) + check("add button card hero-split: exit 0, 0 failed", () => { + assert.equal(add.code, 0, add.out) + assert.match(add.out, /Failed: 0/) + }) + check("no 'not found' and no cycle warnings", () => { + assert.doesNotMatch(add.out, /Component \S+ not found/i) + assert.doesNotMatch(add.out, /cycle|circular/i) + }) + check("*.types.ts and imported helper survive the runtime filter", () => { + assert.ok(exists(dir, "src/blocks/hero-split.types.ts")) + assert.ok(exists(dir, "src/blocks/feature-grid.types.ts")) + assert.ok(exists(dir, "src/components/section-frame.types.ts")) + assert.ok(exists(dir, "src/primitives/button/button.helpers.ts")) + assert.ok(exists(dir, `src/blocks/hero-split.${ext}`)) + }) + check("0 unresolved relative imports", () => { + const r = findUnresolvedImports(path.join(dir, "src")) + assert.deepEqual(r.bad, []) + }) + } + + // 2) init --skip-core: add must install utils and lib/presets itself + { + const dir = project(`${rt}-skipcore`) + await run(dir, ["init", "--yes", "--framework", rt, "--runtime", rt, "--dir", "src", "--skip-core", ...common]) + check("init --skip-core leaves utils/presets absent", () => assert.ok(!exists(dir, "src/utils/cn.ts"))) + const add = await run(dir, ["add", "feature-grid"]) + check("add pulls registry:utils and registry:lib through registryDependencies", () => { + assert.equal(add.code, 0, add.out) + assert.ok(exists(dir, "src/utils/cn.ts"), "utils missing") + assert.ok(exists(dir, "src/presets/defaults.ts"), "presets missing") + assert.doesNotMatch(add.out, /Component \S+ not found/i) + }) + // existing user-edited utils must not be overwritten, even with --force + fs.writeFileSync(path.join(dir, "src/utils/cn.ts"), "// mine\n") + await run(dir, ["add", "card", "--force"]) + check("pulled-in utils are not overwritten (even with --force)", () => { + assert.equal(fs.readFileSync(path.join(dir, "src/utils/cn.ts"), "utf8"), "// mine\n") + }) + } + + // 3) type-only cycles: quiet by default, listed with --verbose, each item installed once + { + const dir = project(`${rt}-cycles`) + await run(dir, ["init", "--yes", "--framework", rt, "--runtime", rt, "--dir", "src", ...common]) + const quiet = await run(dir, ["add", "feature-grid", "section-frame", "--force"]) + check("cycles: no warning by default", () => assert.doesNotMatch(quiet.out, /cycle|circular|^! /im)) + const loud = await run(dir, ["--verbose", "add", "feature-grid", "section-frame", "--force"]) + check("cycles: type-only edges reported at --verbose", () => { + assert.match(loud.out, /type-only dependency edge: feature-cards -> feature-grid/) + assert.match(loud.out, /type-only dependency edge: section-header -> section-frame/) + }) + check("each item installed once", () => { + const names = [...quiet.out.matchAll(/^√ \[\d+\/\d+\] Installing ([\w-]+) from/gm)].map((m) => m[1]) + assert.equal(new Set(names).size, names.length, names.join(",")) + }) + } + + // 4) add --all + { + const dir = project(`${rt}-all`) + await run(dir, ["init", "--yes", "--framework", rt, "--runtime", rt, "--dir", "src", ...common]) + const all = await run(dir, ["add", "--all", "--runtime", rt]) + check("add --all: 0 failed, no warnings about utils/lib", () => { + assert.equal(all.code, 0, all.out) + assert.match(all.out, /Failed: 0/) + assert.doesNotMatch(all.out, /Component \S+ not found|cycle|circular/i) + }) + check("add --all: 0 unresolved imports", () => assert.deepEqual(findUnresolvedImports(path.join(dir, "src")).bad, [])) + } + + // 5) paths precedence: CLI flag > config.paths (by target prefix) > target + { + const dir = project(`${rt}-paths`) + await run(dir, ["init", "--yes", "--framework", rt, "--runtime", rt, "--dir", "src", "--skip-core", ...common]) + const cfgPath = path.join(dir, "ui8kit.config.json") + const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")) + cfg.paths = { ui: "kit" } + fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)) + const add = await run(dir, ["add", "card", "badge", "--force"]) + check("config.paths remaps registry targets (custom + canonical prefix)", () => { + assert.equal(add.code, 0, add.out) + assert.ok(exists(dir, `src/kit/card/card.${ext}`), "primitives/ target not remapped") + assert.ok(exists(dir, `src/kit/badge/badge.${ext}`), "components/ui target not remapped") + assert.ok(!exists(dir, "src/primitives")) + }) + const flag = await run(dir, ["add", "card", "--force", "--path", "ui=flagged"]) + check("--path flag beats config.paths", () => { + assert.equal(flag.code, 0, flag.out) + assert.ok(exists(dir, `src/flagged/card/card.${ext}`)) + }) + const bad = await run(dir, ["add", "card", "--path", "nope=x"]) + check("--path with an unknown key fails with a clear message", () => { + assert.notEqual(bad.code, 0) + assert.match(bad.out, /Unknown --path key/) + }) + } + + // 6) no paths configured: targets win (backward compatible) + { + const dir = project(`${rt}-plain`) + await run(dir, ["init", "--yes", "--framework", rt, "--runtime", rt, "--dir", "src", "--skip-core", ...common]) + await run(dir, ["add", "card", "badge", "--force"]) + check("without paths the file target is used as before", () => { + assert.ok(exists(dir, `src/primitives/card/card.${ext}`)) + assert.ok(exists(dir, `src/components/ui/badge/badge.${ext}`)) + }) + } +} + +await server.close() +fs.rmSync(tmp, { recursive: true, force: true }) +console.log(failures ? `\n${failures} check(s) failed` : "\nall e2e checks passed") +process.exit(failures ? 1 : 0) diff --git a/tests/README.md b/tests/README.md index 06855bd..fb28117 100644 --- a/tests/README.md +++ b/tests/README.md @@ -159,3 +159,14 @@ Validates real CLI entry points as a user would call them from the shell. - If you add new CLI commands, add both: - A quick unit test for pure logic/handlers. - A small integration test in `tests/commands/cli.test.ts` for argument parsing/help path. + +## Registry gap tests (2.0.2) + +Fixture: `tests/fixtures/build-fixture-registry.mjs` generates a synthetic static registry (svelte + react) in a temp dir; `tests/helpers/static-server.mjs` serves it on 127.0.0.1. No private/real registry content. + +- `tests/utils/runtime-files.test.ts` — gap 1 (type files + imported files survive `--runtime`). +- `tests/registry/typed-dependencies.test.ts` — gap 2 endpoints (`utils`, `lib/presets`), catalog exclusion, `registryUrl`/`strictCdn`, per-URL cache. +- `tests/utils/paths.test.ts` — gap 3 precedence (`--path` > `config.paths` by target prefix > `target`). +- `tests/utils/dependency-resolver.test.ts` — gap 2 normalisation and gap 4 cycles (quiet, once, verbose edges). +- `tests/commands/add-fixture-registry.test.ts` — all four gaps through `addCommand` for svelte and react. +- `scripts/e2e-fixture-registry.mjs` (`npm run test:e2e`) — built CLI, temp projects, init/add for two runtimes. `UI8KIT_CLI=` runs it against another build. diff --git a/tests/commands/add-fixture-registry.test.ts b/tests/commands/add-fixture-registry.test.ts new file mode 100644 index 0000000..ac3797c --- /dev/null +++ b/tests/commands/add-fixture-registry.test.ts @@ -0,0 +1,144 @@ +import fs from "fs" +import os from "os" +import path from "path" +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +// @ts-expect-error plain .mjs helpers +import { buildFixtureRegistry } from "../fixtures/build-fixture-registry.mjs" +// @ts-expect-error plain .mjs helpers +import { serveDir } from "../helpers/static-server.mjs" +// @ts-expect-error plain .mjs helpers +import { findUnresolvedImports } from "../helpers/check-imports.mjs" +import { addCommand } from "../../src/commands/add.js" +import { resetCache } from "../../src/registry/api.js" +import { logger } from "../../src/utils/logger.js" + +let registryDir = "" +let server: { url: string; close: () => Promise } +const originalCwd = process.cwd() +const originalHome = process.env.HOME + +beforeAll(async () => { + registryDir = fs.mkdtempSync(path.join(os.tmpdir(), "ui8kit-addfx-reg-")) + buildFixtureRegistry(registryDir) + server = await serveDir(registryDir) +}) +afterAll(async () => { + await server.close() + fs.rmSync(registryDir, { recursive: true, force: true }) +}) + +let project = "" +beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), "ui8kit-addfx-")) + process.env.HOME = project // isolates ~/.ui8kit/cache + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "p", private: true })) + process.chdir(project) + vi.spyOn(console, "log").mockImplementation(() => {}) +}) +afterEach(() => { + process.chdir(originalCwd) + process.env.HOME = originalHome + resetCache() + logger.setVerbose(false) + vi.restoreAllMocks() + fs.rmSync(project, { recursive: true, force: true }) +}) + +function writeConfig(rt: string, extra: Record = {}) { + fs.writeFileSync( + path.join(project, "ui8kit.config.json"), + JSON.stringify({ + contractVersion: "2.0.0", + framework: rt, + runtime: rt, + dir: "src", + aliases: {}, + registry: "@ui8kit", + registryUrl: `${server.url}/${rt}`, + strictCdn: true, + ...extra, + }), + ) +} +const has = (rel: string) => fs.existsSync(path.join(project, rel)) +const output = () => (console.log as unknown as ReturnType).mock.calls.map((c) => c.join(" ")).join("\n") + +describe.each(["svelte", "react"])("add against the fixture registry (%s)", (rt) => { + const ext = rt === "react" ? "tsx" : "svelte" + + it("gap 1: --runtime keeps *.types.ts and imported helpers; 0 unresolved imports", async () => { + writeConfig(rt) + await addCommand(["button", "card", "hero-split"], { force: true, runtime: rt, cache: false }) + expect(has("src/blocks/hero-split.types.ts")).toBe(true) + expect(has("src/blocks/feature-grid.types.ts")).toBe(true) + expect(has("src/primitives/button/button.helpers.ts")).toBe(true) + expect(has(`src/blocks/hero-split.${ext}`)).toBe(true) + expect(findUnresolvedImports(path.join(project, "src")).bad).toEqual([]) + }) + + it("gap 2: utils and lib/presets are installed on add when missing, without a warning", async () => { + writeConfig(rt) + const warn = vi.spyOn(logger, "warn") + await addCommand(["feature-grid"], { runtime: rt, cache: false }) + expect(has("src/utils/cn.ts")).toBe(true) + expect(has("src/presets/defaults.ts")).toBe(true) + expect(warn.mock.calls.map((c) => String(c[0])).filter((m) => /not found/.test(m))).toEqual([]) + }) + + it("gap 2: existing utils are kept when pulled in as a dependency, even with --force", async () => { + writeConfig(rt) + fs.mkdirSync(path.join(project, "src/utils"), { recursive: true }) + fs.writeFileSync(path.join(project, "src/utils/cn.ts"), "// user edit\n") + await addCommand(["card"], { force: true, runtime: rt, cache: false }) + expect(fs.readFileSync(path.join(project, "src/utils/cn.ts"), "utf8")).toBe("// user edit\n") + expect(has("src/utils/index.ts")).toBe(true) // missing sibling still installed + }) + + it("gap 2: `add utils` works as an explicit item (force overwrites)", async () => { + writeConfig(rt) + fs.mkdirSync(path.join(project, "src/utils"), { recursive: true }) + fs.writeFileSync(path.join(project, "src/utils/cn.ts"), "// user edit\n") + await addCommand(["utils"], { force: true, runtime: rt, cache: false }) + expect(fs.readFileSync(path.join(project, "src/utils/cn.ts"), "utf8")).toMatch(/export const cn/) + }) + + it("gap 3: config.paths and --path flag, with registry targets", async () => { + writeConfig(rt, { paths: { ui: "kit" } }) + await addCommand(["card", "badge"], { force: true, runtime: rt, cache: false }) + expect(has(`src/kit/card/card.${ext}`)).toBe(true) + expect(has(`src/kit/badge/badge.${ext}`)).toBe(true) + await addCommand(["card"], { force: true, runtime: rt, cache: false, path: ["ui=flagged"] }) + expect(has(`src/flagged/card/card.${ext}`)).toBe(true) + }) + + it("gap 3: without paths the file target is used", async () => { + writeConfig(rt) + await addCommand(["card", "badge"], { force: true, runtime: rt, cache: false }) + expect(has(`src/primitives/card/card.${ext}`)).toBe(true) + expect(has(`src/components/ui/badge/badge.${ext}`)).toBe(true) + }) + + it("gap 4: type-only cycles are quiet; each item is installed once; --verbose lists edges", async () => { + writeConfig(rt) + const warn = vi.spyOn(logger, "warn") + await addCommand(["feature-grid", "section-frame"], { force: true, runtime: rt, cache: false }) + expect(warn).not.toHaveBeenCalled() + const installed = [...output().matchAll(/^- \[\d+\/\d+\] Installing ([\w-]+) from/gm)].map((m) => m[1]) + expect(new Set(installed).size).toBe(installed.length) + expect(output()).not.toMatch(/cycle|circular/i) + + logger.setVerbose(true) + ;(console.log as unknown as ReturnType).mockClear() + await addCommand(["feature-grid"], { force: true, runtime: rt, cache: false }) + expect(output()).toMatch(/type-only dependency edge: feature-cards -> feature-grid/) + }) + + it("add --all: 0 failed, 0 unresolved imports", async () => { + writeConfig(rt) + const warn = vi.spyOn(logger, "warn") + await addCommand([], { all: true, force: true, runtime: rt, cache: false }) + expect(output()).toMatch(/Failed: 0/) + expect(warn).not.toHaveBeenCalled() + expect(findUnresolvedImports(path.join(project, "src")).bad).toEqual([]) + }) +}) diff --git a/tests/fixtures/build-fixture-registry.mjs b/tests/fixtures/build-fixture-registry.mjs new file mode 100644 index 0000000..66b4d38 --- /dev/null +++ b/tests/fixtures/build-fixture-registry.mjs @@ -0,0 +1,168 @@ +// Generates a SYNTHETIC static registry (no real ui8kit content) that reproduces the four registry gaps. +// Layout per runtime root (what the CLI expects): index.json, components/ui/.json, components/.json, +// blocks/.json, utils/utils.json, lib/presets.json. +// +// gap 1 button. imports ./button.helpers (a .ts file the runtime filter would drop); every block ships *.types.ts +// gap 2 every item lists "utils"; blocks/components list "presets" or "lib/presets" (registry:lib) +// gap 3 targets are registry specific (primitives/..., presets/...) except `badge` (canonical components/ui/...) +// gap 4 feature-grid <-> feature-cards (type-only back edge), section-frame <-> section-header (value + type edge) +import fs from "node:fs" +import path from "node:path" + +const EXT = { svelte: "svelte", react: "tsx", vue: "vue" } + +function item(rt, name, type, files, registryDependencies = [], extra = {}) { + return { + $schema: "https://example.invalid/schema/registry-item.json", + name, + type, + title: name, + description: `Synthetic ${name}`, + runtime: rt, + dependencies: [], + devDependencies: [], + registryDependencies, + files: files.map(([target, content]) => ({ path: target, target, content })), + ...extra, + } +} + +export function fixtureItems(rt) { + const ext = EXT[rt] + const view = (body) => (rt === "react" ? `export const X = () => null\n// ${body}\n` : `\n`) + const items = [] + + items.push( + item(rt, "utils", "registry:utils", [ + ["utils/cn.ts", "export const cn = (...a: string[]) => a.join(' ')\n"], + ["utils/index.ts", "export { cn } from './cn'\n"], + ]), + item(rt, "presets", "registry:lib", [["presets/defaults.ts", "export const defaults = { gap: 'gap-4' }\n"]]), + ) + + items.push( + item( + rt, + "button", + "registry:ui", + [ + [`primitives/button/button.${ext}`, view("button") + `import { label } from "./button.helpers"\nimport { cn } from "../../utils"\n`], + ["primitives/button/button.helpers.ts", "export const label = 'button'\n"], + ], + ["utils"], + ), + item(rt, "card", "registry:ui", [[`primitives/card/card.${ext}`, view("card") + `import { cn } from "../../utils"\n`]], ["utils"]), + item( + rt, + "badge", + "registry:ui", + [[`components/ui/badge/badge.${ext}`, view("badge") + `import { cn } from "../../../utils"\n`]], + ["utils"], + ), + ) + + // gap 4a: feature-grid (value import of feature-cards) <-> feature-cards (type-only import from feature-grid.types) + items.push( + item( + rt, + "feature-grid", + "registry:block", + [ + [ + `blocks/feature-grid.${ext}`, + view("feature-grid") + + `import type { Feature } from "./feature-grid.types"\nimport { FeatureCards } from "../components/feature-cards.${ext}"\nimport { defaults } from "../presets/defaults"\nimport { cn } from "../utils"\n`, + ], + ["blocks/feature-grid.types.ts", "export type Feature = { title: string }\n"], + ], + ["feature-cards", "section-frame", "card", "lib/presets", "utils"], + ), + item( + rt, + "feature-cards", + "registry:component", + [ + [ + `components/feature-cards.${ext}`, + view("feature-cards") + + `import type { Feature } from "../blocks/feature-grid.types"\nimport Card from "../primitives/card/card.${ext}"\nimport { defaults } from "../presets/defaults"\nimport { cn } from "../utils"\n`, + ], + ], + ["card", "feature-grid", "presets", "utils"], + ), + ) + + // gap 4b: section-frame (value import of section-header) <-> section-header (type import of section-frame.types) + items.push( + item( + rt, + "section-frame", + "registry:component", + [ + [ + `components/section-frame.${ext}`, + view("section-frame") + + `import type { Heading } from "./section-frame.types"\nimport SectionHeader from "./section-header.${ext}"\nimport { cn } from "../utils"\n`, + ], + ["components/section-frame.types.ts", "export type Heading = { text: string }\n"], + ], + ["section-header", "utils"], + ), + item( + rt, + "section-header", + "registry:component", + [ + [ + `components/section-header.${ext}`, + view("section-header") + `import type { Heading } from "./section-frame.types"\nimport Button from "../primitives/button/button.${ext}"\nimport { cn } from "../utils"\n`, + ], + ], + ["section-frame", "button", "utils"], + ), + ) + + items.push( + item( + rt, + "hero-split", + "registry:block", + [ + [ + `blocks/hero-split.${ext}`, + view("hero-split") + + `import type { Hero } from "./hero-split.types"\nimport { FeatureGrid } from "./feature-grid.${ext}"\nimport SectionFrame from "../components/section-frame.${ext}"\nimport Button from "../primitives/button/button.${ext}"\nimport { defaults } from "../presets/defaults"\nimport { cn } from "../utils"\n`, + ], + ["blocks/hero-split.types.ts", "export type Hero = { title: string }\n"], + ], + ["feature-grid", "section-frame", "button", "presets", "utils"], + ), + ) + return items +} + +const FOLDER = { + "registry:ui": "components/ui", + "registry:component": "components", + "registry:block": "blocks", + "registry:utils": "utils", + "registry:lib": "lib", +} + +/** Writes `//...` for every runtime and returns the list of item names. */ +export function buildFixtureRegistry(dir, runtimes = ["svelte", "react"]) { + for (const rt of runtimes) { + const items = fixtureItems(rt) + const root = path.join(dir, rt) + for (const it of items) { + const file = path.join(root, FOLDER[it.type], `${it.name}.json`) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify(it, null, 2)) + } + const index = { + components: items.map(({ name, type, description, registryDependencies }) => ({ name, type, description, registryDependencies })), + } + fs.writeFileSync(path.join(root, "index.json"), JSON.stringify(index, null, 2)) + } + return fixtureItems(runtimes[0]).map((i) => i.name) +} diff --git a/tests/helpers/check-imports.mjs b/tests/helpers/check-imports.mjs new file mode 100644 index 0000000..caa7770 --- /dev/null +++ b/tests/helpers/check-imports.mjs @@ -0,0 +1,25 @@ +import fs from "node:fs" +import path from "node:path" + +/** Returns relative imports (./ ../) in an installed tree that resolve to no file. */ +export function findUnresolvedImports(root) { + const files = [] + ;(function walk(d) { + for (const e of fs.readdirSync(d)) { + const p = path.join(d, e) + fs.statSync(p).isDirectory() ? walk(p) : files.push(p) + } + })(root) + const bad = [] + for (const f of files.filter((f) => /\.(ts|tsx|svelte|vue)$/.test(f))) { + const src = fs.readFileSync(f, "utf8") + for (const m of src.matchAll(/(?:from|import)\s+["'](\.[^"']*)["']/g)) { + const b = path.resolve(path.dirname(f), m[1]) + const ok = [b, ...[".ts", ".tsx", ".svelte", ".vue"].map((e) => b + e), path.join(b, "index.ts")].some( + (c) => fs.existsSync(c) && fs.statSync(c).isFile(), + ) + if (!ok) bad.push(`${path.relative(root, f)} -> ${m[1]}`) + } + } + return { files: files.length, bad } +} diff --git a/tests/helpers/static-server.mjs b/tests/helpers/static-server.mjs new file mode 100644 index 0000000..c5d2f18 --- /dev/null +++ b/tests/helpers/static-server.mjs @@ -0,0 +1,27 @@ +import fs from "node:fs" +import http from "node:http" +import path from "node:path" + +/** Minimal static file server on 127.0.0.1 (random port). Returns { url, close }. */ +export function serveDir(root) { + const server = http.createServer((req, res) => { + const rel = decodeURIComponent((req.url || "/").split("?")[0]).replace(/^\/+/, "") + const file = path.join(root, rel) + if (!file.startsWith(root) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + res.statusCode = 404 + res.end("not found") + return + } + res.setHeader("content-type", "application/json") + res.end(fs.readFileSync(file)) + }) + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => new Promise((r) => server.close(() => r())), + }) + }) + }) +} diff --git a/tests/registry/typed-dependencies.test.ts b/tests/registry/typed-dependencies.test.ts new file mode 100644 index 0000000..490a0a7 --- /dev/null +++ b/tests/registry/typed-dependencies.test.ts @@ -0,0 +1,130 @@ +import fs from "fs" +import os from "os" +import path from "path" +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest" +// @ts-expect-error plain .mjs helpers +import { buildFixtureRegistry } from "../fixtures/build-fixture-registry.mjs" +// @ts-expect-error plain .mjs helpers +import { serveDir } from "../helpers/static-server.mjs" +import { getComponent, getAllComponents, resetCache } from "../../src/registry/api.js" + +let dir = "" +let server: { url: string; close: () => Promise } + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "ui8kit-typed-")) + buildFixtureRegistry(dir) + server = await serveDir(dir) +}) + +afterAll(async () => { + await server.close() + fs.rmSync(dir, { recursive: true, force: true }) +}) + +afterEach(() => { + resetCache() + vi.unstubAllGlobals() +}) + +const opts = (rt: string, extra: Record = {}) => ({ + noCache: true, + cdn: { registryUrl: `${server.url}/${rt}`, strictCdn: true }, + ...extra, +}) + +describe("registry:utils / registry:lib resolution (gap 2)", () => { + it("`utils` resolves against utils/utils.json", async () => { + const item = await getComponent("utils", "ui", opts("svelte")) + expect(item?.type).toBe("registry:utils") + expect(item?.files.map((f) => f.target)).toContain("utils/cn.ts") + }) + + it("`lib/presets` and `presets` resolve against lib/presets.json", async () => { + const typed = await getComponent("lib/presets", "ui", opts("svelte")) + const bare = await getComponent("presets", "ui", opts("svelte")) + expect(typed?.type).toBe("registry:lib") + expect(bare?.name).toBe("presets") + }) + + it("`utils/utils` resolves as the utils item", async () => { + expect((await getComponent("utils/utils", "ui", opts("react")))?.type).toBe("registry:utils") + }) + + it("catalog exclusion (excludeTypes) still hides utils/lib from listings", async () => { + const listed = await getAllComponents("ui", opts("svelte", { excludeTypes: ["registry:lib", "registry:utils"] })) + expect(listed.map((c) => c.name)).not.toContain("utils") + expect(listed.map((c) => c.name)).not.toContain("presets") + expect(listed.map((c) => c.name)).toContain("button") + }) + + it("excludeTypes also blocks the typed endpoint fallback (explicit opt-out)", async () => { + expect(await getComponent("utils", "ui", opts("svelte", { excludeTypes: ["registry:utils"] }))).toBeNull() + expect(await getComponent("lib/presets", "ui", opts("svelte", { excludeTypes: ["registry:lib"] }))).toBeNull() + }) + + it("`utils` still resolves from its endpoint when the index does not list it", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ui8kit-noindex-")) + buildFixtureRegistry(root, ["svelte"]) + const index = JSON.parse(fs.readFileSync(path.join(root, "svelte/index.json"), "utf8")) + index.components = index.components.filter((c: { type: string }) => c.type !== "registry:utils" && c.type !== "registry:lib") + fs.writeFileSync(path.join(root, "svelte/index.json"), JSON.stringify(index)) + const srv = await serveDir(root) + try { + const o = { noCache: true, cdn: { registryUrl: `${srv.url}/svelte`, strictCdn: true } } + expect((await getComponent("utils", "ui", o))?.type).toBe("registry:utils") + expect((await getComponent("lib/presets", "ui", o))?.type).toBe("registry:lib") + expect(await getComponent("presets", "ui", o)).toBeNull() // bare lib names need the index + } finally { + await srv.close() + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) + +describe("registryUrl / strictCdn keep working", () => { + it("strictCdn: only the explicit registryUrl is contacted", async () => { + const urls: string[] = [] + vi.stubGlobal("fetch", async (url: string) => { + urls.push(String(url)) + return new Response("nope", { status: 404 }) + }) + const result = await getComponent("button", "ui", { + noCache: true, + cdn: { registryUrl: "http://registry.invalid/svelte", strictCdn: true }, + }) + expect(result).toBeNull() + expect(urls.length).toBeGreaterThan(0) + expect(urls.every((u) => u.startsWith("http://registry.invalid/svelte/"))).toBe(true) + }) + + it("without strictCdn the default CDNs are tried after registryUrl", async () => { + const urls: string[] = [] + vi.stubGlobal("fetch", async (url: string) => { + urls.push(String(url)) + return new Response("nope", { status: 404 }) + }) + await getComponent("button", "ui", { + noCache: true, + cdn: { registryUrl: "http://registry.invalid/svelte" }, + }) + expect(urls[0]).toMatch(/^http:\/\/registry\.invalid\/svelte\//) + expect(urls.some((u) => u.includes("unpkg.com") || u.includes("jsdelivr"))).toBe(true) + }) + + it("two registryUrls do not share the on-disk item cache", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ui8kit-home-")) + const prev = process.env.HOME + process.env.HOME = home + try { + const svelte = await getComponent("button", "ui", { cdn: { registryUrl: `${server.url}/svelte`, strictCdn: true } }) + resetCache() + const react = await getComponent("button", "ui", { cdn: { registryUrl: `${server.url}/react`, strictCdn: true } }) + expect(svelte?.files[0]?.target).toMatch(/button\.svelte$/) + expect(react?.files[0]?.target).toMatch(/button\.tsx$/) + } finally { + process.env.HOME = prev + fs.rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/utils/dependency-resolver.test.ts b/tests/utils/dependency-resolver.test.ts index dc489e7..ead9283 100644 --- a/tests/utils/dependency-resolver.test.ts +++ b/tests/utils/dependency-resolver.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest" -import { resolveRegistryTree } from "../../src/utils/dependency-resolver.js" +import { describe, it, expect, vi, afterEach } from "vitest" +import { logger } from "../../src/utils/logger.js" +import { classifyEdge, normalizeDependencyName, resolveRegistryTree } from "../../src/utils/dependency-resolver.js" import { Component } from "../../src/registry/schema.js" import { type RegistryType } from "../../src/utils/schema-config.js" @@ -61,3 +62,125 @@ describe("dependency resolver", () => { expect(resolved.map(item => item.name).sort()).toEqual(["a", "b"]) }) }) + +describe("dependency resolver: typed deps and cycles (gaps 2 and 4)", () => { + afterEach(() => { + logger.setVerbose(false) + vi.restoreAllMocks() + }) + + const withContent = (name: string, deps: string[], files: Array<[string, string]>, type = "registry:component"): Component => ({ + name, + type, + files: files.map(([target, content]) => ({ path: target, target, content })), + dependencies: [], + devDependencies: [], + registryDependencies: deps, + }) + + const featureGrid = withContent("feature-grid", ["feature-cards"], [ + ["blocks/feature-grid.svelte", `import type { F } from "./feature-grid.types"\nimport C from "../components/feature-cards.svelte"`], + ["blocks/feature-grid.types.ts", "export type F = {}"], + ]) + const featureCards = withContent("feature-cards", ["feature-grid"], [ + ["components/feature-cards.svelte", `import type { F } from "../blocks/feature-grid.types"`], + ]) + + it("normalizeDependencyName maps utils/x and lib/x to the item name", () => { + expect(normalizeDependencyName("lib/Presets")).toBe("presets") + expect(normalizeDependencyName("utils/utils")).toBe("utils") + expect(normalizeDependencyName("Button")).toBe("button") + }) + + it("resolves `utils` and `lib/presets` registryDependencies instead of skipping them", async () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}) + const components: Record = { + block: { ...component("block", ["utils", "lib/presets"]), type: "registry:block" }, + utils: { ...component("utils"), type: "registry:utils" }, + "lib/presets": { ...component("presets"), type: "registry:lib" }, + } + const resolved = await resolveRegistryTree(["block"], TYPE, mockGetComponent(components)) + expect(resolved.map((c) => c.name).sort()).toEqual(["block", "presets", "utils"]) + expect(warn).not.toHaveBeenCalled() + }) + + it("installs `presets` once when it is listed as both `presets` and `lib/presets`", async () => { + const components: Record = { + a: component("a", ["presets", "b"]), + b: component("b", ["lib/presets"]), + presets: { ...component("presets"), type: "registry:lib" }, + "lib/presets": { ...component("presets"), type: "registry:lib" }, + } + const resolved = await resolveRegistryTree(["a"], TYPE, mockGetComponent(components)) + expect(resolved.filter((c) => c.name === "presets")).toHaveLength(1) + }) + + it("still warns for a dependency that really is missing", async () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}) + await resolveRegistryTree(["a"], TYPE, mockGetComponent({ a: component("a", ["ghost"]) })) + expect(warn).toHaveBeenCalledWith(expect.stringContaining("ghost not found")) + }) + + it("classifyEdge: type-only import, value import, and no import", () => { + expect(classifyEdge(featureCards, featureGrid)).toBe("type-only") + expect(classifyEdge(featureGrid, featureCards)).toBe("value") + expect(classifyEdge(featureGrid, withContent("other", [], [["x/other.svelte", ""]]))).toBe("unknown") + }) + + it("type-only cycle (feature-grid <-> feature-cards): no warning, each item once", async () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}) + const log = vi.spyOn(console, "log").mockImplementation(() => {}) + const resolved = await resolveRegistryTree(["feature-grid", "feature-cards"], TYPE, mockGetComponent({ + "feature-grid": featureGrid, + "feature-cards": featureCards, + })) + expect(resolved.map((c) => c.name).sort()).toEqual(["feature-cards", "feature-grid"]) + expect(warn).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() + }) + + it("cycle edges are reported as informational only with --verbose", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}) + const get = mockGetComponent({ "feature-grid": featureGrid, "feature-cards": featureCards }) + logger.setVerbose(true) + await resolveRegistryTree(["feature-grid"], TYPE, get) + const lines = log.mock.calls.map((c) => String(c[1] ?? c[0]) + " " + String(c[0])) + expect(lines.join("\n")).toMatch(/type-only dependency edge: feature-cards -> feature-grid/) + expect(lines.join("\n")).not.toMatch(/Circular|Unresolved/) + }) + + it("mixed cycle (value one way, type the other) is quiet by default", async () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}) + const frame = withContent("section-frame", ["section-header"], [ + ["components/section-frame.svelte", `import H from "./section-header.svelte"`], + ]) + const header = withContent("section-header", ["section-frame"], [ + ["components/section-header.svelte", `import type { X } from "./section-frame.types"`], + ["components/section-header.types.ts", ""], + ]) + const frameWithTypes = { ...frame, files: [...frame.files, { path: "components/section-frame.types.ts", target: "components/section-frame.types.ts", content: "" }] } + const resolved = await resolveRegistryTree(["section-frame"], TYPE, mockGetComponent({ "section-frame": frameWithTypes, "section-header": header })) + expect(resolved).toHaveLength(2) + expect(warn).not.toHaveBeenCalled() + }) + + it("a cycle made only of value imports gets one concise warning, items still installed once", async () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}) + const a = withContent("loop-a", ["loop-b"], [["c/loop-a.tsx", `import { b } from "./loop-b"`]]) + const b = withContent("loop-b", ["loop-a"], [["c/loop-b.tsx", `import { a } from "./loop-a"`]]) + const resolved = await resolveRegistryTree(["loop-a", "loop-b"], TYPE, mockGetComponent({ "loop-a": a, "loop-b": b })) + expect(resolved).toHaveLength(2) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]![0]).toMatch(/Runtime import cycle between/) + }) + + it("orders non-cyclic dependents after the cycle (back edges do not break topological order)", async () => { + const top = withContent("top", ["feature-grid"], [["blocks/top.svelte", ""]]) + const resolved = await resolveRegistryTree(["top"], TYPE, mockGetComponent({ + top, + "feature-grid": featureGrid, + "feature-cards": featureCards, + })) + expect(resolved[resolved.length - 1]!.name).toBe("top") + }) +}) diff --git a/tests/utils/paths.test.ts b/tests/utils/paths.test.ts new file mode 100644 index 0000000..9a3d3bf --- /dev/null +++ b/tests/utils/paths.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest" +import { applyPathOverrides, mapTargetByPaths, resolveFileInstallPath } from "../../src/utils/paths.js" +import type { Config } from "../../src/registry/schema.js" + +const base = (extra: Partial = {}): Config => + ({ + framework: "svelte", + typescript: true, + globalCss: "src/index.css", + dir: "src", + aliases: {}, + registry: "@ui8kit", + ...extra, + }) as Config + +const file = (target: string) => ({ path: target, target, content: "" }) + +describe("paths precedence (gap 3): CLI flag > config.paths by target prefix > file target", () => { + it("file target wins when no paths are configured (backward compatible)", () => { + expect(resolveFileInstallPath(file("primitives/button/button.svelte"), "registry:ui", base())).toBe( + "src/primitives/button/button.svelte", + ) + expect(resolveFileInstallPath(file("components/ui/button/Button.svelte"), "registry:ui", base())).toBe( + "src/components/ui/button/Button.svelte", + ) + }) + + it("legacy src/ and dir-prefixed targets behave as before without paths", () => { + expect(resolveFileInstallPath(file("src/utils/cn.ts"), "registry:utils", base())).toBe("src/utils/cn.ts") + expect(resolveFileInstallPath(file("src/utils/cn.ts"), "registry:utils", base({ dir: "web/src" }))).toBe("web/src/utils/cn.ts") + expect(resolveFileInstallPath(file("web/src/utils/cn.ts"), "registry:utils", base({ dir: "web/src" }))).toBe("web/src/utils/cn.ts") + }) + + it("config.paths remaps a canonical target prefix", () => { + const config = base({ paths: { ui: "kit" } }) + expect(resolveFileInstallPath(file("components/ui/button/Button.svelte"), "registry:ui", config)).toBe( + "src/kit/button/Button.svelte", + ) + }) + + it("config.paths remaps a registry-specific first folder via the item type", () => { + const config = base({ paths: { ui: "kit", lib: "shared" } }) + expect(resolveFileInstallPath(file("primitives/button/button.svelte"), "registry:ui", config)).toBe( + "src/kit/button/button.svelte", + ) + expect(resolveFileInstallPath(file("presets/defaults.ts"), "registry:lib", config)).toBe("src/shared/defaults.ts") + }) + + it("only keys that are configured are remapped", () => { + const config = base({ paths: { ui: "kit" } }) + expect(resolveFileInstallPath(file("blocks/hero.svelte"), "registry:block", config)).toBe("src/blocks/hero.svelte") + expect(resolveFileInstallPath(file("utils/cn.ts"), "registry:utils", config)).toBe("src/utils/cn.ts") + }) + + it("longest canonical prefix wins (components/ui before components)", () => { + const config = base({ paths: { components: "parts" } }) + expect(mapTargetByPaths("components/ui/x.svelte", "registry:ui", config)).toBe("components/ui/x.svelte") + expect(mapTargetByPaths("components/x.svelte", "registry:component", config)).toBe("parts/x.svelte") + }) + + it("remaps dir-prefixed targets too", () => { + const config = base({ paths: { utils: "helpers" } }) + expect(resolveFileInstallPath(file("src/utils/cn.ts"), "registry:utils", config)).toBe("src/helpers/cn.ts") + }) + + it("--path flags override config.paths (flag > config)", () => { + const merged = applyPathOverrides(base({ paths: { ui: "kit", blocks: "sections" } }), ["ui=flagged"]) + expect(resolveFileInstallPath(file("primitives/b/b.svelte"), "registry:ui", merged)).toBe("src/flagged/b/b.svelte") + expect(resolveFileInstallPath(file("blocks/h.svelte"), "registry:block", merged)).toBe("src/sections/h.svelte") + }) + + it("--path flag works without any config.paths and does not mutate the config", () => { + const config = base() + const merged = applyPathOverrides(config, ["ui=kit"]) + expect(config.paths).toBeUndefined() + expect(resolveFileInstallPath(file("components/ui/b/b.svelte"), "registry:ui", merged)).toBe("src/kit/b/b.svelte") + }) + + it("applyPathOverrides rejects malformed pairs and unknown keys", () => { + expect(() => applyPathOverrides(base(), ["ui"])).toThrow(/Expected key=dir/) + expect(() => applyPathOverrides(base(), ["ui="])).toThrow(/Expected key=dir/) + expect(() => applyPathOverrides(base(), ["nope=x"])).toThrow(/Unknown --path key "nope"/) + expect(applyPathOverrides(base(), [])).toEqual(base()) + }) + + it("files without a target keep using config.paths as before", () => { + const config = base({ paths: { ui: "kit" } }) + expect(resolveFileInstallPath({ path: "components/ui/button.svelte", content: "" }, "registry:ui", config)).toBe( + "src/kit/button.svelte", + ) + }) +}) diff --git a/tests/utils/runtime-files.test.ts b/tests/utils/runtime-files.test.ts index 2d785e4..4e83e65 100644 --- a/tests/utils/runtime-files.test.ts +++ b/tests/utils/runtime-files.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest" import { + extractRelativeImports, filterFilesForRuntime, + isTypeFile, + resolveImportKey, isSharedRuntimeFile, matchesRuntimeFile, } from "../../src/utils/runtime-files.js" @@ -72,3 +75,101 @@ describe("runtime file filter", () => { ).toHaveLength(2) }) }) + +describe("runtime file filter: type files and imported files (gap 1)", () => { + const f = (target: string, content = "") => ({ path: target, target, content }) + + it("isTypeFile recognises *.types.ts, *.types.tsx and *.d.ts", () => { + expect(isTypeFile("blocks/hero.types.ts")).toBe(true) + expect(isTypeFile("blocks/hero.types.tsx")).toBe(true) + expect(isTypeFile("global.d.ts")).toBe(true) + expect(isTypeFile("blocks/hero.ts")).toBe(false) + expect(isTypeFile("blocks/types.svelte")).toBe(false) + }) + + it("keeps *.types.ts for every runtime", () => { + const files = [f("blocks/hero.svelte"), f("blocks/hero.tsx"), f("blocks/hero.types.ts")] + for (const rt of ["svelte", "react", "vue", "solid", "templ"]) { + expect(filterFilesForRuntime(files, rt).map((x) => x.target)).toContain("blocks/hero.types.ts") + } + }) + + it("keeps a non-runtime file that a kept file imports", () => { + const files = [ + f("ui/icon.svelte", ``), + f("ui/icons.ts", "export const icons = {}"), + f("ui/unrelated.ts", "export {}"), + ] + const names = filterFilesForRuntime(files, "svelte").map((x) => x.target) + expect(names).toEqual(["ui/icon.svelte", "ui/icons.ts"]) + }) + + it("follows imports transitively (kept -> helper -> helper)", () => { + const files = [ + f("a/view.tsx", `import { x } from "./x"`), + f("a/x.ts", `export * from "./y"`), + f("a/y.ts", "export const y = 1"), + f("a/z.ts", "export const z = 1"), + ] + expect(filterFilesForRuntime(files, "react").map((x) => x.target)).toEqual(["a/view.tsx", "a/x.ts", "a/y.ts"]) + }) + + it("never drops a file another selected file imports (even another runtime's file)", () => { + const files = [ + f("a/view.tsx", `import Shared from "./shared.svelte"`), + f("a/shared.svelte", ""), + ] + expect(filterFilesForRuntime(files, "react")).toHaveLength(2) + }) + + it("resolves directory index imports and TS-style .js specifiers", () => { + const files = [ + f("a/view.vue", `import "./lib"\nimport { q } from "./q.js"`), + f("a/lib/index.ts", ""), + f("a/q.ts", ""), + ] + expect(filterFilesForRuntime(files, "vue")).toHaveLength(3) + }) + + it("does not follow package or alias imports", () => { + const files = [f("a/view.tsx", `import x from "react"\nimport y from "@/utils/cn"`), f("a/react.ts", "")] + expect(filterFilesForRuntime(files, "react").map((x) => x.target)).toEqual(["a/view.tsx"]) + }) + + it("works with path-only files (no target)", () => { + const files = [ + { path: "a/view.svelte", content: `import type { T } from "./view.types"` }, + { path: "a/view.types.ts", content: "" }, + ] + expect(filterFilesForRuntime(files, "svelte")).toHaveLength(2) + }) + + it("extractRelativeImports tags type-only statements", () => { + const refs = extractRelativeImports(` + import type { A } from "./a" + import { type B, type C } from "./b" + import { type D, e } from "./c" + import F from "./d" + export type { G } from "./g" + import "./side" + // import H from "./commented" + import pkg from "pkg" + `) + expect(refs).toEqual([ + { specifier: "./a", typeOnly: true }, + { specifier: "./b", typeOnly: true }, + { specifier: "./c", typeOnly: false }, + { specifier: "./d", typeOnly: false }, + { specifier: "./g", typeOnly: true }, + { specifier: "./side", typeOnly: false }, + ]) + }) + + it("resolveImportKey tries extensions and index files", () => { + const known = new Set(["a/b.ts", "a/c/index.ts", "a/d.svelte"]) + expect(resolveImportKey("a/x.tsx", "./b", known)).toBe("a/b.ts") + expect(resolveImportKey("a/x.tsx", "./c", known)).toBe("a/c/index.ts") + expect(resolveImportKey("a/sub/x.tsx", "../d.svelte", known)).toBe("a/d.svelte") + expect(resolveImportKey("a/x.tsx", "./nope", known)).toBeUndefined() + }) +}) From 1d761783f80dced47ee593619d66e8d87aae0b97 Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Sun, 11 Oct 2026 23:49:09 +0300 Subject: [PATCH 3/3] docs,chore: document runtime filter, utils/lib resolution, paths precedence; changelog and version 2.0.2 (unpublished) --- .project/known-issues.md | 2 ++ CHANGELOG.md | 19 +++++++++++++++++++ README.md | 12 ++++++++---- docs/commands.md | 14 ++++++++++++++ docs/configuration.md | 17 +++++++++++++++++ docs/registry-format.md | 2 +- docs/troubleshooting.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 3 ++- 9 files changed, 73 insertions(+), 8 deletions(-) diff --git a/.project/known-issues.md b/.project/known-issues.md index 4a749ec..2e4d524 100644 --- a/.project/known-issues.md +++ b/.project/known-issues.md @@ -1,5 +1,7 @@ # Known issues — confirmed on TestApp smoke (2026-08-11) +> **2.0.2 (branch `fix/registry-gaps`)**: P0-1 (utils/lib via `registryDependencies`) and P0-2 (`paths` + `target`) are fixed; runtime filter keeps type files; type-only cycles are quiet. See CHANGELOG. P0-3 (relative imports vs remap) remains by design: remap at the same depth. + Severity: **P0** blocks shadcn-like DX · **P1** layout / stack · **P2** polish. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd58e5..9b2710d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [2.0.2] - unreleased + +Registry gap fixes found while validating per-runtime registries (`r//`) with the real CLI. + +### Fixed +- **Runtime filter dropped type files** (`--runtime` / `config.runtime`): `*.types.ts`, `*.types.tsx` and `*.d.ts` are now always kept, and so is every file of the same item that a kept file imports (relative imports, transitively, extension/`index` resolution). A file imported by a selected file is never dropped. No more `init --framework none` workaround. +- **`registry:utils` / `registry:lib` not resolved through `registryDependencies`** (`Component utils not found in ui registry, skipping`): `utils`, `utils/`, `lib/` (and bare lib names in `index.json`) resolve against their own endpoints (`utils/utils.json`, `lib/presets.json`) and are installed by `add` when missing. Catalog exclusion (picker, `--all`) is kept; explicit names and dependencies are no longer filtered. Dependency-only utils/lib never overwrite existing files, even with `--force`. `ui8kit add utils` now works. +- **`paths` ignored when a registry file sets `target`**: precedence is now `--path key=dir` flag > `config.paths` mapped by target prefix > file `target` (documented in `docs/configuration.md`). Behaviour is unchanged when no `paths`/`--path` apply. +- **Dependency cycles** (type-only imports, e.g. `feature-grid` ↔ `feature-cards`): no more `Circular registry dependency` / `Unresolved dependency cycle` warnings. Each item is installed once and back edges no longer disturb install order. `--verbose` lists type-only edges; a cycle made only of value imports gets one concise warning. +- Registry item cache is now namespaced per `--registry-url`, so two registries (e.g. per runtime) no longer return each other's cached items. + +### Added +- `add --path ` (repeatable) to override install folders for one run. +- `logger.verbose` (informational lines shown only with `--verbose`). +- Tests with a generated synthetic fixture registry (`tests/fixtures/build-fixture-registry.mjs`) and an end-to-end script `npm run test:e2e` (`scripts/e2e-fixture-registry.mjs`, init/add for svelte and react). + +### Docs +- README, `docs/commands.md`, `docs/configuration.md`, `docs/registry-format.md`, `docs/troubleshooting.md`: runtime filter, utils/lib resolution, cycles, `paths` precedence. + ## [2.0.1] - 2026-08-11 ### Added diff --git a/README.md b/README.md index 799a250..2e31af2 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ After init, edit to pin CDN + runtime (example for Vite/Svelte under `src/`): } ``` -**Path remap note:** bricks import `../../utils`. Keep utils at `{dir}/utils`. If you want MentorYChat-style `src/kit`, set `"paths": { "ui": "kit" }` in config for documentation — **in 2.0.1 `paths` is ignored when registry files set `target`** (they still install under `components/ui`). Workaround: after `add`, move `src/components/ui/*` → `src/kit/` and leave `src/utils` in place. Do **not** set `"utils": "kit/utils"` without rewriting imports. +**Path remap note:** bricks import `../../utils` (relative). Keep utils at `{dir}/utils`, and remap folders at the same depth (e.g. `"paths": { "ui": "kit" }`). Since 2.0.2 `paths` **is honoured when a registry file sets `target`**: `--path key=dir` flag > `config.paths` (mapped by target prefix) > the file's `target` — see [Configuration → paths precedence](./docs/configuration.md#paths-precedence-with-registry-target). Do **not** set `"utils": "kit/utils"` without rewriting imports. ### 3. Add UI (runtime file filter) @@ -84,7 +84,7 @@ bunx ui8kit@2.0.1 add button stack title text --runtime svelte --force bunx ui8kit@2.0.1 add --all --runtime svelte --force ``` -`--runtime svelte` keeps `.svelte` + `*.shared.ts` + `*.variants.json` only. +`--runtime svelte` keeps `.svelte`, `*.shared.ts`, `*.variants.json`, **type files** (`*.types.ts`, `*.d.ts`) and **any file a kept file imports** (relative imports, followed transitively), so installs never end up with unresolved imports. Also install npm peers used by utils if missing: @@ -209,14 +209,18 @@ bunx ui8kit add button --registry-url https://unpkg.com/@ui8kit/registry@2.0.0/r Install **preserves nested paths** (e.g. `components/ui/button/Button.svelte`), no basename flatten. -**Runtime filter:** when `--runtime` or `config.runtime` is set, only that stack’s files are written, plus shared brick assets (`*.shared.ts`, `*.variants.json`). Utils/lib items are installed in full when reachable. Items that declare a different item-level `runtime` field are skipped entirely. +**Runtime filter:** when `--runtime` or `config.runtime` is set, only that stack’s files are written, plus shared brick assets (`*.shared.ts`, `*.variants.json`), type files (`*.types.ts`, `*.d.ts`) and every file of the same item that a kept file imports (transitively). A file imported by a selected file is never dropped. Utils/lib items are installed in full. Items that declare a different item-level `runtime` field are skipped entirely. + +**`utils` and `lib/*` dependencies:** `registryDependencies` entries `utils`, `lib/` (or a bare lib item name listed in `index.json`) are resolved against their own endpoints (`utils/utils.json`, `lib/.json`) and installed by `add` if missing — no more "not found in ui registry, skipping". Files that already exist are left untouched, even with `--force`, when the item was only pulled in as a dependency. + +**Dependency cycles** (e.g. `feature-grid` ↔ `feature-cards` through type-only imports) are resolved quietly: each item is installed once. `--verbose` lists type-only edges. ```bash # Multi-file brick on CDN → only Svelte + shared land on disk bunx ui8kit add button --runtime svelte ``` -Known gap: `ui8kit add utils` fails in 2.0.1 — use init core install or the [manual fallback](#manual-utils-fallback-if-init-skipped-core). +Since 2.0.2 `ui8kit add utils` works (explicit item; `--force` overwrites). The [manual fallback](#manual-utils-fallback-if-init-skipped-core) is only needed for older CLIs. ### `list` / `diff` / `info` / `cache` / `reset` / `scan` / `build` diff --git a/docs/commands.md b/docs/commands.md index 17c1cdb..5d8e7cb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -60,6 +60,7 @@ ui8kit add [components...] [options] | `-f, --force` | Overwrite existing files | | `-r, --registry ` | Registry type (default `ui`) | | `--runtime ` | Install only this runtime’s files (+ shared); skip items with a mismatched `runtime` field | +| `--path ` | Override an install folder for this run (repeatable), e.g. `--path ui=kit`. Beats `config.paths` and registry `target`s. Keys: `lib`, `utils`, `components`, `ui`, `blocks`, `layouts`, `variants`, `widgets` | | `--dry-run` | Plan only; no writes | | `--retry` | Retry failed CDN requests | | `--registry-url ` | Registry base override | @@ -92,8 +93,21 @@ Registry bricks often ship **all** stacks in one item (no per-item `runtime`). W | `latte` | `*.latte` | | `twig` | `*.twig` / `*.html.twig` | +Also always kept: + +- **Type files**: `*.types.ts`, `*.types.tsx`, `*.d.ts` (they are not tied to a runtime and blocks import them). +- **Imported files**: every file of the same item that a kept file imports with a relative specifier (`./x`, `../x`; extensions and `index` files are resolved; followed transitively). A file that another selected file imports is never dropped, even if it belongs to another runtime. + Shared always: `*.shared.ts`, `*.variants.json`. Types `registry:utils`, `registry:lib`, `registry:variants` are never sliced. If an item sets `runtime: "svelte"` and you pass `--runtime react`, the whole item is skipped. +### Dependency resolution + +`add` resolves `registryDependencies` recursively and installs every item once (dependencies first). + +- **`utils` and `lib/*`**: `utils`, `utils/`, `lib/` (and bare lib names present in `index.json`) are fetched from their own type endpoints (`utils/.json`, `lib/.json`). They are hidden from the interactive picker and from `--all` listings, but are installed whenever an item depends on them. When they are only pulled in as dependencies, existing files are **never overwritten** (not even with `--force`); `ui8kit add utils --force` overwrites explicitly. `init` still installs core `utils`/`lib`/`variants` unless `--skip-core`. +- **Cycles**: items may depend on each other (typically type-only imports such as `feature-grid` ↔ `feature-cards`). Cycles are not an error: no default warning, each item is installed once. With `--verbose` the type-only edges of a cycle are listed. A cycle where every edge is a value import gets a single one-line warning. +- **Missing items** still warn `Component not found in registry, skipping`. + --- ## `list` diff --git a/docs/configuration.md b/docs/configuration.md index 5ef58db..5390d02 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,6 +73,22 @@ All optional; values are relative to `dir` (no leading `./` required): `lib` · `utils` · `components` · `ui` · `blocks` · `layouts` · `variants` · `widgets` +### `paths` precedence with registry `target` + +Registry files usually carry a `target` (e.g. `components/ui/button/Button.svelte`, or registry-specific such as `primitives/button/button.svelte`). Where a file lands is decided in this order: + +1. **Explicit CLI flag**: `add --path ui=kit` (repeatable; keys as above). +2. **`config.paths`, mapped by target prefix**: + - if the target starts with a canonical folder — `components/ui` → `ui`, `components` → `components`, `blocks`, `layouts`, `variants`, `widgets`, `utils`, `lib` (longest match first) — that prefix is replaced by the configured folder; + - otherwise the target's first folder (e.g. `primitives/`, `presets/`) is treated as the item type's own folder and replaced by `paths[]` (`registry:ui` → `ui`, `registry:lib` → `lib`, …). +3. **The file's `target`** (unchanged), when no `paths` entry applies. + +Everything is relative to `dir`; keys you did not configure are not remapped. Without `paths` and without `--path` behaviour is exactly as in 2.0.0/2.0.1. Files without `target` keep using `paths` as before. + +Example — `{ "paths": { "ui": "kit" } }`: `components/ui/button/Button.svelte` → `src/kit/button/Button.svelte`; `primitives/button/button.svelte` → `src/kit/button/button.svelte`; `blocks/hero.svelte` stays in `src/blocks/`. + +Caveat: the CLI does not rewrite relative imports. Remap to folders at the same depth, and keep `utils` next to the remapped folders (bricks import `../../utils`, `../presets/defaults`, `../primitives/…`). + ## Default aliases (when `dir` is `src`) ```json @@ -101,6 +117,7 @@ Flags on `init` / `add` / `list` / `diff` override config for that invocation: | `--registry-version` | `registryVersion` | | `--strict-cdn` | `strictCdn` | | `--runtime` | Prefer that runtime: file-level filter (+ skip mismatched item `runtime`) | +| `--path key=dir` | (`add`) Override one `paths` key for this run; repeatable | | `--import-style` | `importStyle` (init) | Use flags for one-off CI pins; put stable values in config for daily work. diff --git a/docs/registry-format.md b/docs/registry-format.md index abd050c..45c80c8 100644 --- a/docs/registry-format.md +++ b/docs/registry-format.md @@ -85,7 +85,7 @@ Example item: | `digest` | optional | Digest / release id | | `dependencies` | optional | npm deps | | `devDependencies` | optional | npm devDeps | -| `registryDependencies` | optional | Other item names | +| `registryDependencies` | optional | Other item names. `utils`, `utils/` and `lib/` address `registry:utils` / `registry:lib` items (endpoints `utils/.json`, `lib/.json`); cycles are allowed | | `title` / `description` | optional | Docs / list UI | | `tailwind` / `cssVars` | optional | Metadata for consumers/tooling | | `meta` | optional | Free-form | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9f12c7c..6085e8d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -87,3 +87,11 @@ node -v ``` Attach these when filing issues. + +## "Unresolved import" after `add --runtime …` + +Since 2.0.2 the runtime filter keeps `*.types.ts` / `*.d.ts` and every file imported by a kept file. If an import is still unresolved it points at **another registry item** — check that item is listed in `registryDependencies`. On 2.0.0/2.0.1 the workaround was `init --framework none`; it is no longer needed. + +## "Circular registry dependency" / "Unresolved dependency cycle" + +Removed in 2.0.2: cycles (usually type-only imports) are installed once without a warning; run with `--verbose` to list type-only edges. A single `Runtime import cycle between …` warning means every edge in the cycle is a value import — check those files for circular runtime imports. diff --git a/package-lock.json b/package-lock.json index cced0cd..99bc54b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui8kit", - "version": "2.0.0", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui8kit", - "version": "2.0.0", + "version": "2.0.2", "license": "MIT", "dependencies": { "commander": "^11.1.0", diff --git a/package.json b/package.json index 0fdb307..aa02977 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ui8kit", - "version": "2.0.1", + "version": "2.0.2", "description": "CLI to install and maintain UI8Kit component/block registries (multi-runtime, file-based)", "type": "module", "bin": { @@ -57,6 +57,7 @@ "dev": "tsup --watch", "type-check": "tsc --noEmit", "get-cdn": "node scripts/get-cdn.js", + "test:e2e": "npm run build && node scripts/e2e-fixture-registry.mjs", "smoke": "bash scripts/smoke-cli-workflow.sh", "smoke:win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/smoke-cli-workflow.ps1", "clean:artifacts": "bash scripts/node-artifact-cleaner.sh --yes",