diff --git a/.changeset/short-sheep-brush.md b/.changeset/short-sheep-brush.md new file mode 100644 index 0000000..d65c1cb --- /dev/null +++ b/.changeset/short-sheep-brush.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Reduce repeated filesystem work during Intent CLI scans by sharing package.json/skill discovery caches across scan paths and de-duping package-root and node_modules scan attempts within a single scan. Debug output now includes package.json read/cache-hit counts. diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index f354487..0419152 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -27,6 +27,8 @@ function printListDebug(result: IntentSkillList): void { ['skills', result.debug.skillCount], ['warnings', result.debug.warningCount], ['conflicts', result.debug.conflictCount], + ['packageJsonReadCount', result.debug.scan.packageJsonReadCount], + ['packageJsonCacheHits', result.debug.scan.packageJsonCacheHits], ]) } diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 93d42fb..7a4ed35 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -28,6 +28,8 @@ function printLoadDebug(loaded: LoadedIntentSkill | ResolvedIntentSkill): void { ['skill', loaded.debug.skillName], ['path', loaded.debug.path], ['warnings', loaded.debug.warningCount], + ['packageJsonReadCount', loaded.debug.scan.packageJsonReadCount], + ['packageJsonCacheHits', loaded.debug.scan.packageJsonCacheHits], ]) } diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 6a23908..df8a46f 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -6,6 +6,7 @@ import { isPackageExcluded, warningMentionsPackage, } from './core/excludes.js' +import { createIntentFsCache, type IntentFsCache } from './fs-cache.js' import { rewriteLoadedSkillMarkdownDestinations } from './core/markdown.js' import { resolveSkillUseFastPath } from './core/load-resolution.js' import { resolveProjectContext } from './core/project-context.js' @@ -80,6 +81,13 @@ function getScanScope(options: ScanOptions): ScanScope { return options.scope ?? (options.includeGlobal ? 'local-and-global' : 'local') } +function withFsCache( + options: ScanOptions, + fsCache: IntentFsCache, +): ScanOptions & { fsCache: IntentFsCache } { + return { ...options, fsCache } +} + function resolveCoreCwd(options: IntentCoreOptions): string { return resolve(process.cwd(), options.cwd ?? process.cwd()) } @@ -89,8 +97,9 @@ export function listIntentSkills( ): IntentSkillList { const cwd = resolveCoreCwd(options) const scanOptions = toScanOptions(options) + const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) - const scanResult = scanForIntents(cwd, scanOptions) + const scanResult = scanForIntents(cwd, withFsCache(scanOptions, fsCache)) const excludePatterns = getEffectiveExcludePatterns(options, projectContext) const excludeMatchers = compileExcludePatterns(excludePatterns) const excludedPackages = scanResult.packages @@ -144,6 +153,7 @@ export function listIntentSkills( skillCount: result.skills.length, warningCount: result.warnings.length, conflictCount: result.conflicts.length, + scan: scanResult.stats ?? fsCache.getStats(), } } @@ -220,12 +230,14 @@ function toResolvedIntentSkill( function createLoadedSkillDebug({ cwd, excludes, + scan, resolution, resolved, scope, }: { cwd: string excludes: Array + scan: LoadedIntentSkillDebug['scan'] resolution: LoadedIntentSkillDebug['resolution'] resolved: ResolveSkillResult scope: ScanScope @@ -241,6 +253,7 @@ function createLoadedSkillDebug({ source: resolved.source, path: resolved.path, warningCount: resolved.warnings.length, + scan, } } @@ -263,6 +276,7 @@ function resolveIntentSkillInCwd( ) } + const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) const excludePatterns = getEffectiveExcludePatterns(options, projectContext) const excludeMatchers = compileExcludePatterns(excludePatterns) @@ -281,6 +295,7 @@ function resolveIntentSkillInCwd( options, projectContext, cwd, + fsCache, ) if (fastPathResolved) { return toResolvedIntentSkill( @@ -293,13 +308,14 @@ function resolveIntentSkillInCwd( excludes: excludePatterns, resolution: 'fast-path', resolved: fastPathResolved, + scan: fsCache.getStats(), scope, }) : undefined, ) } - const scanResult = scanForIntents(cwd, scanOptions) + const scanResult = scanForIntents(cwd, withFsCache(scanOptions, fsCache)) let resolved: ReturnType try { resolved = resolveSkillUse(use, scanResult) @@ -322,6 +338,7 @@ function resolveIntentSkillInCwd( excludes: excludePatterns, resolution: 'full-scan', resolved, + scan: scanResult.stats ?? fsCache.getStats(), scope, }) : undefined, diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index 851f732..892f57e 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -1,11 +1,11 @@ import { existsSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import { createIntentFsCache, type IntentFsCache } from '../fs-cache.js' import { resolveSkillEntry, type ResolveSkillResult } from '../resolver.js' import { scanIntentPackageAtRoot } from '../scanner.js' -import { resolveWorkspacePackages } from '../workspace-patterns.js' +import { findWorkspacePackages } from '../workspace-patterns.js' import { getDeps, resolveDepDir } from '../utils.js' import { warningMentionsPackage } from './excludes.js' -import { readPackageJson } from './package-json.js' import { resolveProjectContext, type ProjectContext, @@ -21,6 +21,7 @@ interface WorkspacePackageInfo { function readWorkspacePackageInfos( context: ProjectContext, + fsCache: IntentFsCache, ): Array { const dirs = new Set() @@ -31,16 +32,13 @@ function readWorkspacePackageInfos( if (context.workspaceRoot) { dirs.add(context.workspaceRoot) - for (const dir of resolveWorkspacePackages( - context.workspaceRoot, - context.workspacePatterns, - )) { + for (const dir of findWorkspacePackages(context.workspaceRoot)) { dirs.add(dir) } } return [...dirs].flatMap((dir) => { - const packageJson = readPackageJson(dir) + const packageJson = fsCache.readPackageJson(dir) if (!packageJson) return [] return [ @@ -154,10 +152,11 @@ function getDirectLoadFastPathCandidateDirs( function getWorkspaceLoadFastPathCandidateDirs( packageName: string, context: ProjectContext, + fsCache: IntentFsCache, ): Array { const candidates: Array = [] const seen = new Set() - const workspacePackages = readWorkspacePackageInfos(context) + const workspacePackages = readWorkspacePackageInfos(context, fsCache) for (const pkg of workspacePackages) { if (pkg.name === packageName) { @@ -212,10 +211,12 @@ function resolveFromPackageRoots( packageRoots: Array, parsedUse: SkillUse, cwd: string, + fsCache: IntentFsCache, ): ResolveSkillResult | null { for (const packageRoot of packageRoots) { const scanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, + fsCache, projectRoot: cwd, skillNameHint: parsedUse.skillName, }) @@ -225,6 +226,7 @@ function resolveFromPackageRoots( if (scanned.package?.name === parsedUse.packageName) { const fallbackScanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, + fsCache, projectRoot: cwd, }) const fallbackResolved = resolveScannedPackageSkill( @@ -243,6 +245,7 @@ export function resolveSkillUseFastPath( options: IntentCoreOptions, context = resolveProjectContext({ cwd: process.cwd() }), cwd = context.cwd, + fsCache = createIntentFsCache(), ): ResolveSkillResult | null { if (options.globalOnly) return null if (shouldSkipFastPathForYarnPnp(context, cwd)) return null @@ -251,6 +254,7 @@ export function resolveSkillUseFastPath( getDirectLoadFastPathCandidateDirs(parsedUse.packageName, context, cwd), parsedUse, cwd, + fsCache, ) if (directResolved) return directResolved @@ -259,8 +263,13 @@ export function resolveSkillUseFastPath( } return resolveFromPackageRoots( - getWorkspaceLoadFastPathCandidateDirs(parsedUse.packageName, context), + getWorkspaceLoadFastPathCandidateDirs( + parsedUse.packageName, + context, + fsCache, + ), parsedUse, cwd, + fsCache, ) } diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 9d5e03e..90ae9e2 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -1,4 +1,9 @@ -import type { IntentPackage, ScanScope, VersionConflict } from '../types.js' +import type { + IntentPackage, + ScanScope, + ScanStats, + VersionConflict, +} from '../types.js' export interface IntentCoreOptions { cwd?: string @@ -60,6 +65,7 @@ export interface IntentSkillListDebug { skillCount: number warningCount: number conflictCount: number + scan: IntentScanDebugStats } export interface LoadedIntentSkillDebug { @@ -73,8 +79,11 @@ export interface LoadedIntentSkillDebug { source: IntentPackage['source'] path: string warningCount: number + scan: IntentScanDebugStats } +export interface IntentScanDebugStats extends ScanStats {} + export type IntentCoreErrorCode = | 'invalid-options' | 'invalid-skill-use' diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index d6e31b3..7a0d4b1 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { join, sep } from 'node:path' +import { join, resolve, sep } from 'node:path' import { rewriteSkillLoadPaths } from '../skill-paths.js' import { listNodeModulesPackageDirs } from '../utils.js' import type { @@ -18,6 +18,10 @@ function isLocalToProject(dirPath: string, projectRoot: string): boolean { ) } +function getFsIdentity(path: string): string { + return resolve(path) +} + export interface CreatePackageRegistrarOptions { comparePackageVersions: (a: string, b: string) => number deriveIntentConfig: (pkgJson: PackageJson) => IntentConfig | null @@ -33,16 +37,38 @@ export interface CreatePackageRegistrarOptions { } export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { + const attemptedPackageRoots = new Set() + const scannedNodeModulesDirs = new Set() + + function shouldAttemptPackageRoot(dirPath: string): boolean { + const key = getFsIdentity(dirPath) + if (attemptedPackageRoots.has(key)) return false + attemptedPackageRoots.add(key) + return true + } + + function scanNodeModulesDir( + nodeModulesDir: string, + source: IntentPackage['source'] = 'local', + ): void { + if (!existsSync(nodeModulesDir)) return + + const key = getFsIdentity(nodeModulesDir) + if (scannedNodeModulesDirs.has(key)) return + scannedNodeModulesDirs.add(key) + + for (const dirPath of listNodeModulesPackageDirs(nodeModulesDir)) { + tryRegister(dirPath, 'unknown', source) + } + } + function scanTarget( target: NodeModulesScanTarget, source: IntentPackage['source'] = 'local', ): void { if (!target.path || !target.exists || target.scanned) return target.scanned = true - - for (const dirPath of listNodeModulesPackageDirs(target.path)) { - tryRegister(dirPath, 'unknown', source) - } + scanNodeModulesDir(target.path, source) } function tryRegister( @@ -50,6 +76,8 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { fallbackName: string, source: IntentPackage['source'] = 'local', ): boolean { + if (!shouldAttemptPackageRoot(dirPath)) return false + const skillsDir = join(dirPath, 'skills') if (!existsSync(skillsDir)) return false @@ -126,5 +154,5 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { return true } - return { scanTarget, tryRegister } + return { scanNodeModulesDir, scanTarget, tryRegister } } diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts index ac4da88..77f4216 100644 --- a/packages/intent/src/discovery/walk.ts +++ b/packages/intent/src/discovery/walk.ts @@ -1,17 +1,16 @@ -import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { listNodeModulesPackageDirs, resolveDepDir, getDeps } from '../utils.js' -import { - readWorkspacePatterns, - resolveWorkspacePackages, -} from '../workspace-patterns.js' +import { resolveDepDir, getDeps } from '../utils.js' +import { findWorkspacePackages } from '../workspace-patterns.js' +import type { IntentFsCache } from '../fs-cache.js' import type { IntentPackage } from '../types.js' type PackageJson = Record export interface CreateDependencyWalkerOptions { + fsCache: IntentFsCache projectRoot: string readPkgJson: (dirPath: string) => PackageJson | null + scanNodeModulesDir: (nodeModulesDir: string) => void tryRegister: (dirPath: string, fallbackName: string) => boolean packages: Array warnings: Array @@ -83,35 +82,27 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { dirPath: string, label: string, ): PackageJson | null { - try { - return JSON.parse( - readFileSync(join(dirPath, 'package.json'), 'utf8'), - ) as PackageJson - } catch (err) { - const code = (err as NodeJS.ErrnoException).code + const result = opts.fsCache.readPackageJsonResult(dirPath) + if (!result.packageJson) { + const code = (result.error as NodeJS.ErrnoException | null)?.code if (code !== 'ENOENT') { opts.warnings.push( - `Could not read ${label} package.json at ${dirPath}: ${(err as Error).message}`, + `Could not read ${label} package.json at ${dirPath}: ${ + result.error instanceof Error + ? result.error.message + : 'invalid package.json' + }`, ) } return null } + + return result.packageJson } function walkWorkspacePackages(): void { - const workspacePatterns = readWorkspacePatterns(opts.projectRoot) - if (!workspacePatterns) return - - for (const wsDir of resolveWorkspacePackages( - opts.projectRoot, - workspacePatterns, - )) { - const wsNodeModules = join(wsDir, 'node_modules') - if (existsSync(wsNodeModules)) { - for (const dirPath of listNodeModulesPackageDirs(wsNodeModules)) { - opts.tryRegister(dirPath, 'unknown') - } - } + for (const wsDir of findWorkspacePackages(opts.projectRoot)) { + opts.scanNodeModulesDir(join(wsDir, 'node_modules')) const wsPkg = readPkgJsonWithWarning(wsDir, 'workspace') if (wsPkg) { diff --git a/packages/intent/src/fs-cache.ts b/packages/intent/src/fs-cache.ts new file mode 100644 index 0000000..6385f2e --- /dev/null +++ b/packages/intent/src/fs-cache.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { findSkillFiles as findSkillFilesUncached } from './utils.js' + +type PackageJsonReadResult = { + packageJson: Record | null + error: unknown | null +} + +export type IntentFsCacheStats = { + packageJsonReadCount: number + packageJsonCacheHits: number +} + +export type IntentFsCache = { + readPackageJson: (dir: string) => Record | null + readPackageJsonResult: (dir: string) => PackageJsonReadResult + findSkillFiles: (dir: string) => Array + getStats: () => IntentFsCacheStats +} + +function normalizeCacheKey(path: string): string { + return resolve(path) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function createIntentFsCache(): IntentFsCache { + const packageJsonCache = new Map() + const skillFilesCache = new Map>() + const stats: IntentFsCacheStats = { + packageJsonReadCount: 0, + packageJsonCacheHits: 0, + } + + function readPackageJsonResult(dir: string): PackageJsonReadResult { + const key = normalizeCacheKey(dir) + const cached = packageJsonCache.get(key) + if (cached) { + stats.packageJsonCacheHits += 1 + return cached + } + + stats.packageJsonReadCount += 1 + try { + const parsed = JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf8'), + ) as unknown + const result = { + packageJson: isRecord(parsed) ? parsed : null, + error: null, + } + packageJsonCache.set(key, result) + return result + } catch (error) { + const result = { packageJson: null, error } + packageJsonCache.set(key, result) + return result + } + } + + function readPackageJson(dir: string): Record | null { + return readPackageJsonResult(dir).packageJson + } + + function findSkillFiles(dir: string): Array { + const key = normalizeCacheKey(dir) + const cached = skillFilesCache.get(key) + if (cached) { + return [...cached] + } + + const files = findSkillFilesUncached(dir) + skillFilesCache.set(key, files) + return [...files] + } + + return { + readPackageJson, + readPackageJsonResult, + findSkillFiles, + getStats: () => ({ ...stats }), + } +} diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index ed0cd16..0fd59ab 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' +import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import semver from 'semver' @@ -11,6 +11,7 @@ import { parseFrontmatter, toPosixPath, } from './utils.js' +import { createIntentFsCache, type IntentFsCache } from './fs-cache.js' import { findWorkspaceRoot } from './workspace-patterns.js' import type { InstalledVariant, @@ -28,6 +29,9 @@ import type { // --------------------------------------------------------------------------- type PackageManager = ScanResult['packageManager'] +type ScanOptionsWithFsCache = ScanOptions & { + fsCache?: IntentFsCache +} interface PnpPackageLocator { name: string | null @@ -271,31 +275,17 @@ function discoverSkillByNameHint( return skills } -function discoverSkills(skillsDir: string): Array { - const skills: Array = [] - - function walk(dir: string): void { - let entries: Array> - try { - entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) - } catch { - return - } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const childDir = join(dir, entry.name) - const skillFile = join(childDir, 'SKILL.md') - if (existsSync(skillFile)) { - skills.push(readSkillEntry(skillsDir, childDir, skillFile)) - } - // Always recurse into subdirectories so skills nested under - // intermediate grouping directories (dirs without SKILL.md) are found. - walk(childDir) - } - } - - walk(skillsDir) - return skills +function discoverSkills( + skillsDir: string, + fsCache: IntentFsCache, +): Array { + return fsCache + .findSkillFiles(skillsDir) + .flatMap((skillFile): Array => { + const childDir = dirname(skillFile) + if (childDir === skillsDir) return [] + return [readSkillEntry(skillsDir, childDir, skillFile)] + }) } function getPackageShortName(packageName: string): string { @@ -442,6 +432,8 @@ export function scanForIntents( ): ScanResult { const projectRoot = root ?? process.cwd() const scanScope = getScanScope(options) + const fsCache = + (options as ScanOptionsWithFsCache).fsCache ?? createIntentFsCache() const packageManager = detectPackageManager(projectRoot) const nodeModulesDir = join(projectRoot, 'node_modules') const explicitGlobalNodeModules = @@ -471,7 +463,6 @@ export function scanForIntents( } // Track registered package names to avoid duplicates across phases const packageIndexes = new Map() - const packageJsonCache = new Map | null>() const packageVariants = new Map< string, Map @@ -486,6 +477,10 @@ export function scanForIntents( return pnpApi } + function getStats(): ScanResult['stats'] { + return fsCache.getStats() + } + function rememberVariant(pkg: IntentPackage): void { let variants = packageVariants.get(pkg.name) if (!variants) { @@ -511,41 +506,31 @@ export function scanForIntents( } function readPkgJson(dirPath: string): Record | null { - if (packageJsonCache.has(dirPath)) { - return packageJsonCache.get(dirPath) ?? null - } - - try { - const pkgJson = JSON.parse( - readFileSync(join(dirPath, 'package.json'), 'utf8'), - ) as Record - packageJsonCache.set(dirPath, pkgJson) - return pkgJson - } catch { - packageJsonCache.set(dirPath, null) - return null - } + return fsCache.readPackageJson(dirPath) } - const { scanTarget, tryRegister } = createPackageRegistrar({ - comparePackageVersions, - deriveIntentConfig, - discoverSkills, - getPackageDepth, - packageIndexes, - packages, - projectRoot, - readPkgJson, - rememberVariant, - validateIntentField, - warnings, - }) + const { scanNodeModulesDir, scanTarget, tryRegister } = + createPackageRegistrar({ + comparePackageVersions, + deriveIntentConfig, + discoverSkills: (skillsDir) => discoverSkills(skillsDir, fsCache), + getPackageDepth, + packageIndexes, + packages, + projectRoot, + readPkgJson, + rememberVariant, + validateIntentField, + warnings, + }) const { walkKnownPackages, walkProjectDeps, walkWorkspacePackages } = createDependencyWalker({ + fsCache, packages, projectRoot, readPkgJson, + scanNodeModulesDir, tryRegister, warnings, }) @@ -632,7 +617,14 @@ export function scanForIntents( } if (!nodeModules.local.exists && !nodeModules.global.exists) { - return { packageManager, packages, warnings, conflicts, nodeModules } + return { + packageManager, + packages, + warnings, + conflicts, + nodeModules, + stats: getStats(), + } } for (const pkg of packages) { @@ -653,11 +645,19 @@ export function scanForIntents( // Sort by dependency order const sorted = topoSort(packages) - return { packageManager, packages: sorted, warnings, conflicts, nodeModules } + return { + packageManager, + packages: sorted, + warnings, + conflicts, + nodeModules, + stats: getStats(), + } } export interface ScanIntentPackageAtRootOptions { fallbackName?: string + fsCache?: IntentFsCache projectRoot?: string source?: IntentPackage['source'] skillNameHint?: string @@ -676,23 +676,10 @@ export function scanIntentPackageAtRoot( const packages: Array = [] const warnings: Array = [] const packageIndexes = new Map() - const packageJsonCache = new Map | null>() + const fsCache = options.fsCache ?? createIntentFsCache() function readPkgJson(dirPath: string): Record | null { - if (packageJsonCache.has(dirPath)) { - return packageJsonCache.get(dirPath) ?? null - } - - try { - const pkgJson = JSON.parse( - readFileSync(join(dirPath, 'package.json'), 'utf8'), - ) as Record - packageJsonCache.set(dirPath, pkgJson) - return pkgJson - } catch { - packageJsonCache.set(dirPath, null) - return null - } + return fsCache.readPackageJson(dirPath) } const { tryRegister } = createPackageRegistrar({ @@ -705,7 +692,7 @@ export function scanIntentPackageAtRoot( packageName, options.skillNameHint!, ) - : discoverSkills, + : (skillsDir) => discoverSkills(skillsDir, fsCache), getPackageDepth, packageIndexes, packages, diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index 9da6005..2f788ea 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -22,6 +22,7 @@ export interface ScanResult { local: NodeModulesScanTarget global: NodeModulesScanTarget } + stats?: ScanStats } export type ScanScope = 'local' | 'local-and-global' | 'global' @@ -31,6 +32,11 @@ export interface ScanOptions { scope?: ScanScope } +export interface ScanStats { + packageJsonReadCount: number + packageJsonCacheHits: number +} + export interface NodeModulesScanTarget { path: string | null detected: boolean diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index e70b98e..aecaf21 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -17,7 +17,15 @@ export function toPosixPath(p: string): string { export function findSkillFiles(dir: string): Array { const files: Array = [] if (!existsSync(dir)) return files - for (const entry of readdirSync(dir, { withFileTypes: true })) { + + let entries: Array> + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) + } catch { + return files + } + + for (const entry of entries) { const fullPath = join(dir, entry.name) if (entry.isDirectory()) { files.push(...findSkillFiles(fullPath)) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index f5fc53d..ac328c3 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -603,6 +603,8 @@ describe('cli commands', () => { expect(debugOutput).toContain('scope: local') expect(debugOutput).toContain('packages: 1') expect(debugOutput).toContain('skills: 1') + expect(debugOutput).toContain('packageJsonReadCount:') + expect(debugOutput).toContain('packageJsonCacheHits:') }) it('ignores configured global intent packages in list json output by default', async () => { @@ -1054,6 +1056,8 @@ describe('cli commands', () => { expect(debugOutput).toContain('resolution: fast-path') expect(debugOutput).toContain('package: @tanstack/query') expect(debugOutput).toContain('skill: fetching') + expect(debugOutput).toContain('packageJsonReadCount:') + expect(debugOutput).toContain('packageJsonCacheHits:') }) it('loads a skill use as json', async () => { diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 640f16c..d7e5d3e 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -154,6 +154,10 @@ describe('listIntentSkills', () => { skillCount: 1, warningCount: 0, conflictCount: 0, + scan: expect.objectContaining({ + packageJsonReadCount: expect.any(Number), + packageJsonCacheHits: expect.any(Number), + }), }) }) @@ -260,6 +264,10 @@ describe('loadIntentSkill', () => { source: 'local', path: 'node_modules/@tanstack/query/skills/fetching/SKILL.md', warningCount: 0, + scan: expect.objectContaining({ + packageJsonReadCount: expect.any(Number), + packageJsonCacheHits: expect.any(Number), + }), }, }) expect('content' in result).toBe(false) @@ -353,6 +361,10 @@ describe('loadIntentSkill', () => { source: 'local', path: 'node_modules/@tanstack/query/skills/fetching/SKILL.md', warningCount: 0, + scan: expect.objectContaining({ + packageJsonReadCount: expect.any(Number), + packageJsonCacheHits: expect.any(Number), + }), }) }) diff --git a/packages/intent/tests/fs-cache.test.ts b/packages/intent/tests/fs-cache.test.ts new file mode 100644 index 0000000..d90f0d0 --- /dev/null +++ b/packages/intent/tests/fs-cache.test.ts @@ -0,0 +1,52 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createIntentFsCache } from '../src/fs-cache.js' + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'intent-fs-cache-test-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('createIntentFsCache', () => { + it('caches package.json reads', () => { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'test-package' }), + ) + const cache = createIntentFsCache() + + expect(cache.readPackageJson(root)?.name).toBe('test-package') + expect(cache.readPackageJson(root)?.name).toBe('test-package') + + expect(cache.getStats()).toEqual( + expect.objectContaining({ + packageJsonReadCount: 1, + packageJsonCacheHits: 1, + }), + ) + }) + + it('caches skill file discovery without exposing cached arrays', () => { + const skillDir = join(root, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: core\n---\n') + const cache = createIntentFsCache() + + const first = cache.findSkillFiles(join(root, 'skills')) + first.push('mutated') + const laterSkillDir = join(root, 'skills', 'later') + mkdirSync(laterSkillDir, { recursive: true }) + writeFileSync(join(laterSkillDir, 'SKILL.md'), '---\nname: later\n---\n') + const second = cache.findSkillFiles(join(root, 'skills')) + + expect(second).toHaveLength(1) + expect(second[0]).toBe(join(skillDir, 'SKILL.md')) + }) +}) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 8883738..e5ddae7 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -119,6 +119,33 @@ describe('scanForIntents', () => { expect(result.packages[0]!.skills[0]!.description).toBe( 'Core database concepts', ) + expect(result.stats).toEqual( + expect.objectContaining({ + packageJsonReadCount: expect.any(Number), + packageJsonCacheHits: expect.any(Number), + }), + ) + expect(result.stats!.packageJsonReadCount).toBeGreaterThan(0) + }) + + it('does not throw when skills exists but is not a directory', () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'db') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/db', + version: '0.5.2', + intent: { + version: 1, + repo: 'TanStack/db', + docs: 'docs/', + }, + }) + writeFileSync(join(pkgDir, 'skills'), 'not a directory') + + const result = scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/db') + expect(result.packages[0]!.skills).toEqual([]) }) it('discovers packages through symlinks (pnpm layout)', () => { @@ -286,6 +313,50 @@ describe('scanForIntents', () => { expect(result.warnings).toHaveLength(0) }) + it('still discovers undeclared packages from broad node_modules scans', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'declared-skill-pkg': '1.0.0', + }, + }) + + const declaredDir = createDir(root, 'node_modules', 'declared-skill-pkg') + writeJson(join(declaredDir, 'package.json'), { + name: 'declared-skill-pkg', + version: '1.0.0', + intent: { version: 1, repo: 'test/declared', docs: 'docs/' }, + }) + writeSkillMd(createDir(declaredDir, 'skills', 'declared'), { + name: 'declared', + description: 'Declared skill', + }) + + const undeclaredDir = createDir( + root, + 'node_modules', + 'undeclared-skill-pkg', + ) + writeJson(join(undeclaredDir, 'package.json'), { + name: 'undeclared-skill-pkg', + version: '1.0.0', + intent: { version: 1, repo: 'test/undeclared', docs: 'docs/' }, + }) + writeSkillMd(createDir(undeclaredDir, 'skills', 'undeclared'), { + name: 'undeclared', + description: 'Undeclared skill', + }) + + const result = scanForIntents(root) + + expect(result.packages.map((pkg) => pkg.name).sort()).toEqual([ + 'declared-skill-pkg', + 'undeclared-skill-pkg', + ]) + expect(result.nodeModules.local.scanned).toBe(true) + }) + it('discovers global-only intent packages', () => { process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot @@ -477,6 +548,71 @@ describe('scanForIntents', () => { expect(versionWarning).toContain('Using 5.0.0') }) + it('keeps same-name packages at different installed roots as separate variants', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'router-consumer': '1.0.0', + }, + }) + + const hoistedRouterDir = createDir( + root, + 'node_modules', + '@tanstack', + 'router', + ) + writeJson(join(hoistedRouterDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd(createDir(hoistedRouterDir, 'skills', 'router-v1'), { + name: 'router-v1', + description: 'Router v1 skill', + }) + + const consumerDir = createDir(root, 'node_modules', 'router-consumer') + writeJson(join(consumerDir, 'package.json'), { + name: 'router-consumer', + version: '1.0.0', + dependencies: { + '@tanstack/router': '2.0.0', + }, + }) + + const nestedRouterDir = createDir( + consumerDir, + 'node_modules', + '@tanstack', + 'router', + ) + writeJson(join(nestedRouterDir, 'package.json'), { + name: '@tanstack/router', + version: '2.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd(createDir(nestedRouterDir, 'skills', 'router-v2'), { + name: 'router-v2', + description: 'Router v2 skill', + }) + + const result = scanForIntents(root) + const conflict = result.conflicts.find( + (item) => item.packageName === '@tanstack/router', + ) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/router') + expect(conflict?.variants).toEqual( + expect.arrayContaining([ + { version: '1.0.0', packageRoot: hoistedRouterDir }, + { version: '2.0.0', packageRoot: nestedRouterDir }, + ]), + ) + }) + it('prefers stable releases over prereleases at the same depth', () => { writeJson(join(root, 'package.json'), { name: 'app',