diff --git a/.changeset/fruity-toys-win.md b/.changeset/fruity-toys-win.md new file mode 100644 index 0000000..0572421 --- /dev/null +++ b/.changeset/fruity-toys-win.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Remove the abandoned `intent-library` bin and its `./intent-library` export. The legacy library-bin discovery model was replaced by the keyword-based model; anything invoking `intent-library` directly must move to the normal `intent` discovery flow (no compatibility shim). diff --git a/knip.json b/knip.json index 885afaf..46e71ba 100644 --- a/knip.json +++ b/knip.json @@ -7,14 +7,7 @@ "entry": ["scripts/*.ts"] }, "packages/intent": { - "entry": [ - "src/index.ts", - "src/cli.ts", - "src/core.ts", - "src/setup.ts", - "src/intent-library.ts", - "src/library-scanner.ts" - ], + "entry": ["src/index.ts", "src/cli.ts", "src/core.ts", "src/setup.ts"], "ignore": ["meta/**"], "ignoreDependencies": ["verdaccio", "@verdaccio/node-api"] } diff --git a/nx.json b/nx.json index 0888397..0e6a187 100644 --- a/nx.json +++ b/nx.json @@ -31,6 +31,11 @@ "inputs": ["default", "^production"], "outputs": ["{projectRoot}/coverage"] }, + "test:integration": { + "cache": true, + "dependsOn": ["build", "^build"], + "inputs": ["default", "^production"] + }, "test:eslint": { "cache": true, "dependsOn": ["^build"], diff --git a/package.json b/package.json index d5925d7..915b5f1 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,14 @@ "lint:fix": "nx affected --target=lint:fix --exclude=examples/**", "lint:fix:all": "nx run-many --targets=lint --fix", "test": "pnpm run test:ci", - "test:ci": "tsc --noEmit && nx run-many --targets=test:eslint,test:sherif,test:knip,test:docs,test:lib,test:types,build", + "test:ci": "tsc --noEmit && nx run-many --targets=test:eslint,test:sherif,test:knip,test:docs,test:lib,test:integration,test:types,build", "generate-docs": "node scripts/generate-docs.ts", "test:docs": "node scripts/verify-links.ts", "test:eslint": "nx affected --target=test:eslint --exclude=examples/**", "test:knip": "knip", "test:lib": "nx affected --targets=test:lib --exclude=examples/**", "test:lib:dev": "pnpm test:lib && nx watch --all -- pnpm test:lib", - "test:pr": "tsc --noEmit && nx affected --targets=test:eslint,test:sherif,test:knip,test:docs,test:lib,test:types,build", + "test:pr": "tsc --noEmit && nx affected --targets=test:eslint,test:sherif,test:knip,test:docs,test:lib,test:integration,test:types,build", "test:sherif": "sherif", "test:types": "nx affected --targets=test:types --exclude=examples/**", "watch": "pnpm run build:all && nx watch --all -- pnpm run build:all" diff --git a/packages/intent/package.json b/packages/intent/package.json index 1ea4a95..29b1112 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -13,18 +13,13 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, - "./intent-library": { - "import": "./dist/intent-library.mjs", - "types": "./dist/intent-library.d.mts" - }, "./core": { "import": "./dist/core.mjs", "types": "./dist/core.d.mts" } }, "bin": { - "intent": "dist/cli.mjs", - "intent-library": "dist/intent-library.mjs" + "intent": "dist/cli.mjs" }, "files": [ "dist", @@ -44,7 +39,7 @@ }, "scripts": { "prepack": "npm run build", - "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts src/core.ts --format esm --dts", + "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/core.ts --format esm --dts", "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null && node dist/cli.mjs load --help > /dev/null", "test:lib": "vitest run --exclude 'tests/integration/**'", "test:integration": "vitest run tests/integration/", diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 8ff3bb6..7f4570e 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -247,14 +247,22 @@ export async function main(argv: Array = process.argv.slice(2)) { } } -let isMain = false -try { - isMain = - process.argv[1] !== undefined && - fileURLToPath(import.meta.url) === realpathSync(process.argv[1]) -} catch {} +export function isMainModule( + metaUrl: string, + argvPath: string | undefined, + realpath: (path: string) => string = realpathSync, +): boolean { + if (argvPath === undefined) { + return false + } + try { + return fileURLToPath(metaUrl) === realpath(argvPath) + } catch { + return false + } +} -if (isMain) { +if (isMainModule(import.meta.url, process.argv[1])) { const exitCode = await main() process.exit(exitCode) } diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index a87b9c7..f724187 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -2,10 +2,10 @@ import { coreOptionsFromGlobalFlags, printDebugInfo, printWarnings, - type GlobalScanFlags, } from '../cli-support.js' import { formatIntentCommand } from '../command-runner.js' import { listIntentSkills } from '../core.js' +import type { GlobalScanFlags } from '../cli-support.js' import type { IntentPackageSummary, IntentSkillList, diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index e0dc10b..47f9efc 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -2,11 +2,9 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs' import { basename, dirname, join, relative, resolve, sep } from 'node:path' import { fail, isCliFailure } from '../cli-error.js' import { printWarnings } from '../cli-support.js' -import { - type ProjectContext, - resolveProjectContext, -} from '../core/project-context.js' +import { resolveProjectContext } from '../core/project-context.js' import { findWorkspacePackages } from '../workspace-patterns.js' +import type { ProjectContext } from '../core/project-context.js' interface ValidationError { file: string diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 0348a9a..06f76ed 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -6,25 +6,23 @@ import { isPackageExcluded, warningMentionsPackage, } from './core/excludes.js' -import { createIntentFsCache, type IntentFsCache } from './fs-cache.js' +import { createIntentFsCache } from './fs-cache.js' import { rewriteLoadedSkillMarkdownDestinations } from './core/markdown.js' import { resolveSkillUseFastPath } from './core/load-resolution.js' import { resolveProjectContext } from './core/project-context.js' -import { - ResolveSkillUseError, - resolveSkillUse, - type ResolveSkillResult, -} from './resolver.js' +import { ResolveSkillUseError, resolveSkillUse } from './resolver.js' import { formatSkillUse, parseSkillUse } from './skill-use.js' import { scanForIntents } from './scanner.js' +import type { ResolveSkillResult } from './resolver.js' +import type { IntentFsCache } from './fs-cache.js' import type { ScanOptions, ScanScope } from './types.js' import type { IntentCoreErrorCode, IntentCoreOptions, IntentSkillList, IntentSkillSummary, - LoadedIntentSkillDebug, LoadedIntentSkill, + LoadedIntentSkillDebug, ResolvedIntentSkill, } from './core/types.js' diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 1770a7b..668f4ed 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -1,9 +1,7 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path' -import { - resolveProjectContext, - type ProjectContext, -} from './project-context.js' +import { resolveProjectContext } from './project-context.js' import { readPackageJson } from './package-json.js' +import type { ProjectContext } from './project-context.js' import type { IntentCoreOptions } from './types.js' const MAX_EXCLUDE_PATTERN_LENGTH = 200 @@ -117,12 +115,9 @@ export function warningMentionsPackage( warning: string, packageName: string, ): boolean { - let fromIndex = 0 - - while (true) { - const idx = warning.indexOf(packageName, fromIndex) - if (idx === -1) return false + let idx = warning.indexOf(packageName) + while (idx !== -1) { const before = warning[idx - 1] const after = warning[idx + packageName.length] if ( @@ -132,6 +127,8 @@ export function warningMentionsPackage( return true } - fromIndex = idx + packageName.length + idx = warning.indexOf(packageName, idx + packageName.length) } + + return false } diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index 892f57e..97fda1c 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -1,15 +1,15 @@ 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 { createIntentFsCache } from '../fs-cache.js' +import { resolveSkillEntry } from '../resolver.js' import { scanIntentPackageAtRoot } from '../scanner.js' import { findWorkspacePackages } from '../workspace-patterns.js' import { getDeps, resolveDepDir } from '../utils.js' import { warningMentionsPackage } from './excludes.js' -import { - resolveProjectContext, - type ProjectContext, -} from './project-context.js' +import { resolveProjectContext } from './project-context.js' +import type { ResolveSkillResult } from '../resolver.js' +import type { IntentFsCache } from '../fs-cache.js' +import type { ProjectContext } from './project-context.js' import type { SkillUse } from '../skill-use.js' import type { IntentCoreOptions } from './types.js' @@ -70,15 +70,17 @@ function findVisibleDependencyDir( fromDir: string, ): string | null { let dir = fromDir + let prev: string | undefined - while (true) { + while (dir !== prev) { const candidate = join(dir, 'node_modules', packageName) if (existsSync(join(candidate, 'package.json'))) return candidate - const next = dirname(dir) - if (next === dir) return null - dir = next + prev = dir + dir = dirname(dir) } + + return null } function resolveDependencyPackageDir( @@ -134,7 +136,7 @@ function getDirectLoadFastPathCandidateDirs( seen, resolveDependencyPackageDir( packageName, - context.packageRoot ?? context.workspaceRoot ?? cwd, + context.packageRoot ?? context.workspaceRoot, ), ) diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts index 6768db7..8e5a703 100644 --- a/packages/intent/src/core/project-context.ts +++ b/packages/intent/src/core/project-context.ts @@ -54,19 +54,18 @@ export function resolveProjectContext({ function findOwningPackageRoot(startPath: string): string | null { let dir = toSearchDir(startPath) + let prev: string | undefined - while (true) { + while (dir !== prev) { if (existsSync(join(dir, 'package.json'))) { return dir } - const next = dirname(dir) - if (next === dir) { - return null - } - - dir = next + prev = dir + dir = dirname(dir) } + + return null } function toSearchDir(path: string): string { diff --git a/packages/intent/src/intent-library.ts b/packages/intent/src/intent-library.ts deleted file mode 100644 index 7d252e7..0000000 --- a/packages/intent/src/intent-library.ts +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node - -import { computeSkillNameWidth, printSkillTree, printTable } from './display.js' -import { INSTALL_PROMPT } from './commands/install.js' -import { scanLibrary } from './library-scanner.js' -import type { LibraryScanResult } from './library-scanner.js' - -// --------------------------------------------------------------------------- -// Commands -// --------------------------------------------------------------------------- - -function cmdList(): void { - let result: LibraryScanResult - try { - result = scanLibrary(process.argv[1]!, process.cwd()) - } catch (err) { - console.error((err as Error).message) - process.exit(1) - } - - if (result.packages.length === 0) { - console.log('No intent-enabled packages found.') - if (result.warnings.length > 0) { - console.log('\nWarnings:') - for (const w of result.warnings) console.log(` ⚠ ${w}`) - } - return - } - - const totalSkills = result.packages.reduce( - (sum, p) => sum + p.skills.length, - 0, - ) - console.log( - `\n${result.packages.length} intent-enabled packages, ${totalSkills} skills\n`, - ) - - // Summary table - const rows = result.packages.map((pkg) => [ - pkg.name, - pkg.version, - String(pkg.skills.length), - ]) - printTable(['PACKAGE', 'VERSION', 'SKILLS'], rows) - - // Skills detail - const allSkills = result.packages.map((p) => p.skills) - const nameWidth = computeSkillNameWidth(allSkills) - const showTypes = result.packages.some((p) => p.skills.some((s) => s.type)) - - console.log(`\nSkills:\n`) - for (const pkg of result.packages) { - console.log(` ${pkg.name}`) - printSkillTree(pkg.skills, { nameWidth, packageName: pkg.name, showTypes }) - console.log() - } - - console.log(`Feedback:`) - console.log( - ` Submit feedback on skill usage to help maintainers improve the skills.`, - ) - console.log( - ` Load: node_modules/@tanstack/intent/meta/feedback-collection/SKILL.md`, - ) - console.log() - - if (result.warnings.length > 0) { - console.log(`Warnings:`) - for (const w of result.warnings) console.log(` ⚠ ${w}`) - } -} - -function cmdInstall(): void { - console.log(INSTALL_PROMPT) -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -const USAGE = `TanStack Intent - -Usage: - intent list List all available skills from this library and its dependencies - intent install Print a skill that guides your coding agent to scan the project - and set up skill loading guidance in your agent config` - -const command = process.argv[2] - -switch (command) { - case 'list': - case undefined: - cmdList() - break - case 'install': - cmdInstall() - break - default: - console.log(USAGE) - process.exit(command ? 1 : 0) -} diff --git a/packages/intent/src/library-scanner.ts b/packages/intent/src/library-scanner.ts deleted file mode 100644 index e3d331e..0000000 --- a/packages/intent/src/library-scanner.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { dirname, join, relative } from 'node:path' -import { rewriteSkillLoadPaths } from './skill-paths.js' -import { - getDeps, - parseFrontmatter, - resolveDepDir, - toPosixPath, -} from './utils.js' -import type { SkillEntry } from './types.js' -import type { Dirent } from 'node:fs' - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface LibraryPackage { - name: string - version: string - description: string - skills: Array -} - -export interface LibraryScanResult { - packages: Array - warnings: Array -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function readPkgJson(dir: string): Record | null { - try { - return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) - } catch { - return null - } -} - -function findHomeDir(scriptPath: string): string | null { - let dir = dirname(scriptPath) - for (;;) { - if (existsSync(join(dir, 'package.json'))) return dir - const parent = dirname(dir) - if (parent === dir) return null - dir = parent - } -} - -function isIntentPackage(pkg: Record): boolean { - const keywords = pkg.keywords - if (Array.isArray(keywords) && keywords.includes('tanstack-intent')) { - return true - } - // Legacy fallback: packages published before the keyword-based detection - // change may only have bin.intent. Keep this until a breaking release. - const bin = pkg.bin - if ( - bin && - typeof bin === 'object' && - 'intent' in (bin as Record) - ) { - return true - } - return false -} - -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)) { - const fm = parseFrontmatter(skillFile) - const relName = toPosixPath(relative(skillsDir, childDir)) - skills.push({ - name: typeof fm?.name === 'string' ? fm.name : relName, - path: skillFile, - description: - typeof fm?.description === 'string' - ? fm.description.replace(/\s+/g, ' ').trim() - : '', - type: typeof fm?.type === 'string' ? fm.type : undefined, - framework: - typeof fm?.framework === 'string' ? fm.framework : undefined, - }) - } - // Always recurse into subdirectories so skills nested under - // intermediate grouping directories (dirs without SKILL.md) are found. - walk(childDir) - } - } - - walk(skillsDir) - return skills -} - -// --------------------------------------------------------------------------- -// Main scanner -// --------------------------------------------------------------------------- - -export function scanLibrary( - scriptPath: string, - projectRoot?: string, -): LibraryScanResult { - const packages: Array = [] - const warnings: Array = [] - const visited = new Set() - - const homeDir = findHomeDir(scriptPath) - if (!homeDir) { - return { - packages, - warnings: ['Could not determine home package directory'], - } - } - - const homePkg = readPkgJson(homeDir) - if (!homePkg) { - return { packages, warnings: ['Could not read home package.json'] } - } - - const homeName = typeof homePkg.name === 'string' ? homePkg.name : '' - const scanRoot = projectRoot ?? homeDir - - function processPackage(name: string, dir: string): void { - if (visited.has(name)) return - visited.add(name) - - const pkg = readPkgJson(dir) - if (!pkg) { - warnings.push(`Could not read package.json for ${name}`) - return - } - - const skillsDir = join(dir, 'skills') - const skills = existsSync(skillsDir) ? discoverSkills(skillsDir) : [] - - rewriteSkillLoadPaths({ - packageName: name, - packageRoot: dir, - projectRoot: scanRoot, - skills, - }) - - packages.push({ - name, - version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', - description: typeof pkg.description === 'string' ? pkg.description : '', - skills, - }) - - for (const depName of getDeps(pkg)) { - const depDir = resolveDepDir(depName, dir) - if (!depDir) continue - const depPkg = readPkgJson(depDir) - if (depPkg && isIntentPackage(depPkg)) { - processPackage(depName, depDir) - } - } - } - - processPackage(homeName, homeDir) - return { packages, warnings } -} diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index faef2bf..6040817 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -11,9 +11,10 @@ import { parseFrontmatter, toPosixPath, } from './utils.js' -import { createIntentFsCache, type IntentFsCache } from './fs-cache.js' +import { createIntentFsCache } from './fs-cache.js' import { detectPackageManager } from './package-manager.js' import { findWorkspaceRoot } from './workspace-patterns.js' +import type { IntentFsCache } from './fs-cache.js' import type { InstalledVariant, IntentConfig, @@ -53,17 +54,19 @@ const requireFromHere = createRequire(import.meta.url) function findPnpFile(start: string): string | null { let dir = resolve(start) + let prev: string | undefined - while (true) { + while (dir !== prev) { for (const fileName of ['.pnp.cjs', '.pnp.js']) { const pnpPath = join(dir, fileName) if (existsSync(pnpPath)) return pnpPath } - const next = dirname(dir) - if (next === dir) return null - dir = next + prev = dir + dir = dirname(dir) } + + return null } function assertLocalNodeModulesSupported(root: string): void { diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 0139644..178711c 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -145,7 +145,7 @@ function parseSyncState(value: unknown): SyncState | null { skills[skillName] = {} if (sourcesSha) { - skills[skillName]!.sources_sha = sourcesSha + skills[skillName].sources_sha = sourcesSha } } diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index d464e25..6a31e10 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -5,11 +5,11 @@ import { readFileSync, readdirSync, realpathSync, - type Dirent, } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, resolve, sep } from 'node:path' import { parse as parseYaml } from 'yaml' +import type { Dirent } from 'node:fs' /** * Convert a path to use forward slashes (for cross-platform consistency). @@ -268,12 +268,12 @@ export function resolveDepDir( // Fallback: walk up from parentDir checking node_modules/. // Handles packages with exports maps that don't expose ./package.json. let dir = parentDir - while (true) { + let prev: string | undefined + while (dir !== prev) { const candidate = join(dir, 'node_modules', depName) if (existsSync(join(candidate, 'package.json'))) return candidate - const parent = dirname(dir) - if (parent === dir) break - dir = parent + prev = dir + dir = dirname(dir) } return null diff --git a/packages/intent/src/workflow-review.ts b/packages/intent/src/workflow-review.ts index 7849a5b..e742ce8 100644 --- a/packages/intent/src/workflow-review.ts +++ b/packages/intent/src/workflow-review.ts @@ -18,33 +18,33 @@ export function collectStaleReviewItems( const items: Array = [] for (const report of reports) { - for (const skill of report.skills ?? []) { - if (!skill?.needsReview) continue + for (const skill of report.skills) { + if (!skill.needsReview) continue items.push({ type: 'stale-skill', library: report.library, subject: skill.name, - reasons: skill.reasons ?? [], + reasons: skill.reasons, }) } - for (const signal of report.signals ?? []) { - if (signal?.needsReview === false) continue + for (const signal of report.signals) { + if (signal.needsReview === false) continue items.push({ - type: signal?.type ?? 'review-signal', - library: signal?.library ?? report.library, + type: signal.type, + library: signal.library ?? report.library, subject: - signal?.packageName ?? - signal?.packageRoot ?? - signal?.skill ?? - signal?.artifactPath ?? - signal?.subject ?? + signal.packageName ?? + signal.packageRoot ?? + signal.skill ?? + signal.artifactPath ?? + signal.subject ?? report.library, - reasons: signal?.reasons ?? [], - artifactPath: signal?.artifactPath, - packageName: signal?.packageName, - packageRoot: signal?.packageRoot, - skill: signal?.skill, + reasons: signal.reasons, + artifactPath: signal.artifactPath, + packageName: signal.packageName, + packageRoot: signal.packageRoot, + skill: signal.skill, }) } } @@ -88,13 +88,13 @@ export function buildStaleReviewBody(items: Array): string { const itemRows = items.map((item) => { const subject = item.subject ? `\`${item.subject}\`` : '-' - const reasons = item.reasons?.length ? item.reasons.join('; ') : '-' + const reasons = item.reasons.length ? item.reasons.join('; ') : '-' return `| \`${item.type}\` | ${subject} | \`${item.library}\` | ${reasons} |` }) const reasonBullets = items.map((item) => { const subject = item.subject ? `\`${item.subject}\`` : '`unknown`' - const reasons = item.reasons?.length + const reasons = item.reasons.length ? item.reasons.join('; ') : 'Intent did not emit a detailed reason for this signal.' return `- \`${item.type}\` for ${subject}: ${reasons}` diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index a9851b8..7117565 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -1,8 +1,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' -import { parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { parse as parseJsonc } from 'jsonc-parser' import { parse as parseYaml } from 'yaml' import { findSkillFiles } from './utils.js' +import type { ParseError } from 'jsonc-parser' function normalizeWorkspacePattern(pattern: string): string { return pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') @@ -323,9 +324,10 @@ function readChildDirectories(dir: string): Array { export function findWorkspaceRoot(start: string): string | null { let dir = start + let prev: string | undefined const visited: Array = [] - while (true) { + while (dir !== prev) { const cached = workspaceRootCache.get(dir) if (cached !== undefined) { for (const visitedDir of visited) { @@ -343,15 +345,14 @@ export function findWorkspaceRoot(start: string): string | null { return dir } - const next = dirname(dir) - if (next === dir) { - for (const visitedDir of visited) { - workspaceRootCache.set(visitedDir, null) - } - return null - } - dir = next + prev = dir + dir = dirname(dir) } + + for (const visitedDir of visited) { + workspaceRootCache.set(visitedDir, null) + } + return null } export function findPackagesWithSkills(root: string): Array { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 43abedf..30483e4 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -9,11 +9,11 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { INSTALL_PROMPT } from '../src/commands/install.js' import { runLoadCommand } from '../src/commands/load.js' -import { main } from '../src/cli.js' +import { isMainModule, main } from '../src/cli.js' import type { ScanOptions, ScanResult } from '../src/types.js' const thisDir = dirname(fileURLToPath(import.meta.url)) @@ -433,7 +433,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('## Step 1') - expect(output).toContain('meta/domain-discovery/SKILL.md') + expect(output).toContain(join('meta', 'domain-discovery', 'SKILL.md')) }) it('updates package.json for skill publishing', async () => { @@ -2424,3 +2424,33 @@ describe('package metadata', () => { expect(packageJson.scripts?.prepack).toBe('npm run build') }) }) + +describe('isMainModule', () => { + const modulePath = fileURLToPath(import.meta.url) + const moduleUrl = pathToFileURL(modulePath).href + const otherPath = join(dirname(modulePath), 'other.mjs') + + it('returns false when there is no argv script path', () => { + expect(isMainModule(moduleUrl, undefined, () => modulePath)).toBe(false) + }) + + it('returns true when the resolved argv path matches the module', () => { + const symlinkPath = join(dirname(modulePath), 'link') + const realpath = (path: string) => + path === symlinkPath ? modulePath : path + + expect(isMainModule(moduleUrl, symlinkPath, realpath)).toBe(true) + }) + + it('returns false when the resolved argv path is a different module', () => { + expect(isMainModule(moduleUrl, otherPath, (path) => path)).toBe(false) + }) + + it('returns false when resolving the argv path throws', () => { + expect( + isMainModule(moduleUrl, otherPath, () => { + throw new Error('ENOENT') + }), + ).toBe(false) + }) +}) diff --git a/packages/intent/tests/integration/load-integration.test.ts b/packages/intent/tests/integration/load-integration.test.ts index ffbee95..9ea70a9 100644 --- a/packages/intent/tests/integration/load-integration.test.ts +++ b/packages/intent/tests/integration/load-integration.test.ts @@ -1,6 +1,8 @@ import { rmSync } from 'node:fs' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { + canSymlink, + isPackageManagerAvailable, publishFixtures, runLoad, scaffoldProject, @@ -11,7 +13,7 @@ import type { PackageManager, Registry } from './scaffold.js' const PACKAGE_MANAGERS: Array = ['npm', 'pnpm', 'yarn', 'bun'] const SKILL_USE = '@test-intent/skills-leaf#core' -let registry: Registry +let registry: Registry | undefined const tempDirs: Array = [] beforeAll(async () => { @@ -34,24 +36,28 @@ function expectSkillContent(stdout: string): void { } describe.each(PACKAGE_MANAGERS)('intent load via installed bin (%s)', (pm) => { - it('prints the resolved skill content', () => { - const { root, cwd } = scaffoldProject({ - pm, - structure: 'single', - dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, - }) - tempDirs.push(root) - - const result = runLoad(cwd, SKILL_USE) - - if (result.exitCode !== 0) { - throw new Error( - `intent load failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ) - } - expectSkillContent(result.stdout) - }, 60_000) + it.skipIf(!isPackageManagerAvailable(pm))( + 'prints the resolved skill content', + () => { + const { root, cwd } = scaffoldProject({ + pm, + structure: 'single', + dependency: '@test-intent/skills-leaf', + registryUrl: registry!.url, + }) + tempDirs.push(root) + + const result = runLoad(cwd, SKILL_USE) + + if (result.exitCode !== 0) { + throw new Error( + `intent load failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + } + expectSkillContent(result.stdout) + }, + 60_000, + ) }) describe('intent load resolution variants', () => { @@ -60,7 +66,7 @@ describe('intent load resolution variants', () => { pm: 'npm', structure: 'single', dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, + registryUrl: registry!.url, }) tempDirs.push(root) @@ -79,7 +85,7 @@ describe('intent load resolution variants', () => { pm: 'npm', structure: 'single', dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, + registryUrl: registry!.url, }) tempDirs.push(root) @@ -99,31 +105,35 @@ describe('intent load resolution variants', () => { expect(result.parsed.content).toContain('# Core Skill') }, 60_000) - it('prints content when invoked through a symlink (mimics node_modules/.bin)', () => { - const { root, cwd } = scaffoldProject({ - pm: 'npm', - structure: 'single', - dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, - }) - tempDirs.push(root) - - const result = runLoad(cwd, SKILL_USE, { method: 'symlink' }) - - if (result.exitCode !== 0) { - throw new Error( - `intent load via symlink failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ) - } - expectSkillContent(result.stdout) - }, 60_000) + it.skipIf(!canSymlink())( + 'prints content when invoked through a symlink (mimics node_modules/.bin)', + () => { + const { root, cwd } = scaffoldProject({ + pm: 'npm', + structure: 'single', + dependency: '@test-intent/skills-leaf', + registryUrl: registry!.url, + }) + tempDirs.push(root) + + const result = runLoad(cwd, SKILL_USE, { method: 'symlink' }) + + if (result.exitCode !== 0) { + throw new Error( + `intent load via symlink failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + } + expectSkillContent(result.stdout) + }, + 60_000, + ) it('resolves a workspace-dep skill from a monorepo workspace package', () => { const { root, cwd } = scaffoldProject({ pm: 'pnpm', structure: 'monorepo-workspace', dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, + registryUrl: registry!.url, }) tempDirs.push(root) diff --git a/packages/intent/tests/integration/scaffold.ts b/packages/intent/tests/integration/scaffold.ts index f36f617..e58232b 100644 --- a/packages/intent/tests/integration/scaffold.ts +++ b/packages/intent/tests/integration/scaffold.ts @@ -7,6 +7,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs' +import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,6 +17,19 @@ const fixturesDir = join(thisDir, '..', 'fixtures', 'integration') const cliPath = join(thisDir, '..', '..', 'dist', 'cli.mjs') const realTmpdir = realpathSync(tmpdir()) +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + server.close(() => resolve(port)) + }) + }) +} + // --------------------------------------------------------------------------- // Verdaccio lifecycle // --------------------------------------------------------------------------- @@ -27,7 +41,7 @@ export interface Registry { export async function startRegistry(): Promise { const storageDir = mkdtempSync(join(realTmpdir, 'verdaccio-storage-')) - const port = 6000 + Math.floor(Math.random() * 4000) + const port = await getFreePort() const configPath = join(storageDir, 'config.yaml') const htpasswdPath = join(storageDir, 'htpasswd') @@ -37,7 +51,7 @@ export async function startRegistry(): Promise { configPath, [ `storage: ${storageDir}`, - `listen: 0.0.0.0:${port}`, + `listen: 127.0.0.1:${port}`, 'auth:', ' htpasswd:', ` file: ${htpasswdPath}`, @@ -56,6 +70,7 @@ export async function startRegistry(): Promise { ].join('\n'), ) + const isWindows = process.platform === 'win32' const verdaccioBin = join( thisDir, '..', @@ -67,11 +82,14 @@ export async function startRegistry(): Promise { return new Promise((resolve, reject) => { const child = spawn( - verdaccioBin, - ['--config', configPath, '--listen', String(port)], + isWindows ? `"${verdaccioBin}"` : verdaccioBin, + isWindows + ? ['--config', `"${configPath}"`, '--listen', `127.0.0.1:${port}`] + : ['--config', configPath, '--listen', `127.0.0.1:${port}`], { stdio: ['ignore', 'pipe', 'pipe'], detached: false, + shell: isWindows, }, ) @@ -85,12 +103,12 @@ export async function startRegistry(): Promise { const onData = (chunk: Buffer) => { const text = chunk.toString() - if (text.includes('http address') || text.includes(`localhost:${port}`)) { + if (text.includes('http address') || text.includes(`:${port}`)) { if (!started) { started = true clearTimeout(timeout) resolve({ - url: `http://localhost:${port}`, + url: `http://127.0.0.1:${port}`, stop: () => { child.kill('SIGTERM') rmSync(storageDir, { recursive: true, force: true }) @@ -125,24 +143,28 @@ export function publishFixtures(registryUrl: string): void { const host = new URL(registryUrl).host const npmrc = `//${host}/:_authToken=test-token\nregistry=${registryUrl}\n` - // Isolate npm cache to avoid EPERM on the host's ~/.npm/_cacache + // Isolate npm cache + config to avoid EPERM on the host's ~/.npm and to keep + // the shared fixture dirs read-only. Two integration suites publish in + // parallel, so writing a .npmrc into the shared fixture dirs races (one + // suite's cleanup deletes it before the other's publish reads it, causing + // ENEEDAUTH). A per-call userconfig file avoids both that race and the + // Windows-invalid `/dev/null` path. const cacheDir = mkdtempSync(join(realTmpdir, 'intent-npm-cache-')) + const userconfigPath = join(cacheDir, 'npmrc') + writeFileSync(userconfigPath, npmrc) - // Order matters: leaf first, then wrappers that depend on it - for (const pkg of ['skills-leaf', 'wrapper-1', 'wrapper-2', 'wrapper-3']) { - const pkgDir = join(fixturesDir, pkg) - writeFileSync(join(pkgDir, '.npmrc'), npmrc) - try { + try { + // Order matters: leaf first, then wrappers that depend on it + for (const pkg of ['skills-leaf', 'wrapper-1', 'wrapper-2', 'wrapper-3']) { + const pkgDir = join(fixturesDir, pkg) execSync( - `npm publish --registry ${registryUrl} --access public --provenance=false --cache=${cacheDir} --userconfig=/dev/null`, + `npm publish --registry ${registryUrl} --access public --provenance=false --cache="${cacheDir}" --userconfig="${userconfigPath}"`, { cwd: pkgDir, stdio: 'pipe' }, ) - } finally { - rmSync(join(pkgDir, '.npmrc'), { force: true }) } + } finally { + rmSync(cacheDir, { recursive: true, force: true }) } - - rmSync(cacheDir, { recursive: true, force: true }) } // --------------------------------------------------------------------------- @@ -323,6 +345,56 @@ export interface CliResult { parsed: any } +const packageManagerAvailability = new Map() + +export function isPackageManagerAvailable(pm: PackageManager): boolean { + const cached = packageManagerAvailability.get(pm) + if (cached !== undefined) return cached + + let available: boolean + try { + execSync(`${pm} --version`, { stdio: 'ignore', cwd: realTmpdir }) + available = true + } catch { + available = false + } + + packageManagerAvailability.set(pm, available) + return available +} + +let symlinkCapable: boolean | undefined + +export function canSymlink(): boolean { + if (symlinkCapable !== undefined) return symlinkCapable + + const probeDir = mkdtempSync(join(realTmpdir, 'intent-symlink-probe-')) + try { + const target = join(probeDir, 'target') + writeFileSync(target, '') + symlinkSync(target, join(probeDir, 'link')) + symlinkCapable = true + } catch { + symlinkCapable = false + } finally { + rmSync(probeDir, { recursive: true, force: true }) + } + + return symlinkCapable +} + +export function isYarnClassic(): boolean { + try { + const version = execSync('yarn --version', { + encoding: 'utf8', + cwd: realTmpdir, + }).trim() + return Number.parseInt(version.split('.')[0]!, 10) === 1 + } catch { + return false + } +} + export function runScanner( cwd: string, method: 'direct' | 'symlink' = 'direct', diff --git a/packages/intent/tests/integration/scanner-integration.test.ts b/packages/intent/tests/integration/scanner-integration.test.ts index ca3b1fa..6db1790 100644 --- a/packages/intent/tests/integration/scanner-integration.test.ts +++ b/packages/intent/tests/integration/scanner-integration.test.ts @@ -1,13 +1,21 @@ import { existsSync, rmSync } from 'node:fs' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import type { PackageManager, ProjectStructure, Registry } from './scaffold.js' import { + canSymlink, + isPackageManagerAvailable, + isYarnClassic, publishFixtures, runScanner, scaffoldProject, startRegistry, } from './scaffold.js' +import type { + CliResult, + PackageManager, + ProjectStructure, + Registry, +} from './scaffold.js' const PACKAGE_MANAGERS: Array = ['npm', 'pnpm', 'yarn', 'bun'] const STRUCTURES: Array = [ @@ -22,9 +30,29 @@ const DEPENDENCY_CHAINS: Array<{ label: string; dep: string }> = [ { label: 'transitive+3', dep: '@test-intent/wrapper-3' }, ] -let registry: Registry +let registry: Registry | undefined const tempDirs: Array = [] +function expectLeafCoreSkill(parsed: CliResult['parsed']): void { + expect(parsed.packages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: '@test-intent/skills-leaf', + version: '1.0.0', + }), + ]), + ) + expect(parsed.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packageName: '@test-intent/skills-leaf', + skillName: 'core', + type: 'core', + }), + ]), + ) +} + beforeAll(async () => { registry = await startRegistry() publishFixtures(registry.url) @@ -40,60 +68,58 @@ afterAll(() => { describe.each(PACKAGE_MANAGERS)('package manager: %s', (pm) => { describe.each(STRUCTURES)('structure: %s', (structure) => { describe.each(DEPENDENCY_CHAINS)('dependency: $label', ({ dep }) => { - it('discovers @test-intent/skills-leaf and its core skill', () => { - const { root, cwd } = scaffoldProject({ - pm, - structure, - dependency: dep, - registryUrl: registry.url, - }) - tempDirs.push(root) - - const result = runScanner(cwd) - - expect(result.exitCode).toBe(0) - expect(result.parsed).toBeTruthy() - expect(result.parsed.packages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: '@test-intent/skills-leaf', - version: '1.0.0', - skills: expect.arrayContaining([ - expect.objectContaining({ name: 'core', type: 'core' }), - ]), - }), - ]), - ) - }, 60_000) + it.skipIf(!isPackageManagerAvailable(pm))( + 'discovers @test-intent/skills-leaf and its core skill', + () => { + const { root, cwd } = scaffoldProject({ + pm, + structure, + dependency: dep, + registryUrl: registry!.url, + }) + tempDirs.push(root) + + const result = runScanner(cwd) + + expect(result.exitCode).toBe(0) + expect(result.parsed).toBeTruthy() + expectLeafCoreSkill(result.parsed) + }, + 60_000, + ) }) }) }) describe('symlink invocation', () => { - it('finds skills when CLI is invoked through a symlink', () => { - const { root, cwd } = scaffoldProject({ - pm: 'npm', - structure: 'single', - dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, - }) - tempDirs.push(root) - - const result = runScanner(cwd, 'symlink') - - expect(result.exitCode).toBe(0) - expect(result.parsed.packages).toHaveLength(1) - expect(result.parsed.packages[0].name).toBe('@test-intent/skills-leaf') - }, 60_000) + it.skipIf(!canSymlink())( + 'finds skills when CLI is invoked through a symlink', + () => { + const { root, cwd } = scaffoldProject({ + pm: 'npm', + structure: 'single', + dependency: '@test-intent/skills-leaf', + registryUrl: registry!.url, + }) + tempDirs.push(root) + + const result = runScanner(cwd, 'symlink') + + expect(result.exitCode).toBe(0) + expect(result.parsed.packages).toHaveLength(1) + expect(result.parsed.packages[0].name).toBe('@test-intent/skills-leaf') + }, + 60_000, + ) }) -describe('Yarn PnP', () => { +describe.skipIf(!isYarnClassic())('Yarn PnP', () => { it('discovers installed package skills without node_modules', () => { const { root, cwd } = scaffoldProject({ pm: 'yarn', structure: 'single', dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, + registryUrl: registry!.url, pnp: true, }) tempDirs.push(root) @@ -110,19 +136,7 @@ describe('Yarn PnP', () => { `intent list failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ) } - expect(result.parsed.packageManager).toBe('yarn') - expect(result.parsed.nodeModules.local.exists).toBe(false) - expect(result.parsed.packages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: '@test-intent/skills-leaf', - version: '1.0.0', - skills: expect.arrayContaining([ - expect.objectContaining({ name: 'core', type: 'core' }), - ]), - }), - ]), - ) + expectLeafCoreSkill(result.parsed) }, 60_000) it('discovers workspace dependency skills from a nested PnP workspace', () => { @@ -130,7 +144,7 @@ describe('Yarn PnP', () => { pm: 'yarn', structure: 'monorepo-workspace', dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, + registryUrl: registry!.url, pnp: true, }) tempDirs.push(root) @@ -147,17 +161,7 @@ describe('Yarn PnP', () => { `intent list failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ) } - expect(result.parsed.packageManager).toBe('yarn') - expect(result.parsed.packages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: '@test-intent/skills-leaf', - skills: expect.arrayContaining([ - expect.objectContaining({ name: 'core' }), - ]), - }), - ]), - ) + expectLeafCoreSkill(result.parsed) }, 60_000) it('discovers workspace dependency skills from a PnP monorepo root', () => { @@ -165,7 +169,7 @@ describe('Yarn PnP', () => { pm: 'yarn', structure: 'monorepo-root', dependency: '@test-intent/skills-leaf', - registryUrl: registry.url, + registryUrl: registry!.url, pnp: true, }) tempDirs.push(root) @@ -183,16 +187,6 @@ describe('Yarn PnP', () => { `intent list failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ) } - expect(result.parsed.packageManager).toBe('yarn') - expect(result.parsed.packages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: '@test-intent/skills-leaf', - skills: expect.arrayContaining([ - expect.objectContaining({ name: 'core' }), - ]), - }), - ]), - ) + expectLeafCoreSkill(result.parsed) }, 60_000) }) diff --git a/packages/intent/tests/library-scanner.test.ts b/packages/intent/tests/library-scanner.test.ts deleted file mode 100644 index 5865b29..0000000 --- a/packages/intent/tests/library-scanner.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { scanLibrary } from '../src/library-scanner.js' - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createDir(...segments: Array): string { - const dir = join(...segments) - mkdirSync(dir, { recursive: true }) - return dir -} - -function writeJson(filePath: string, data: unknown): void { - writeFileSync(filePath, JSON.stringify(data, null, 2)) -} - -function writeSkillMd(dir: string, frontmatter: Record): void { - const yamlLines = Object.entries(frontmatter) - .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v}"` : v}`) - .join('\n') - writeFileSync( - join(dir, 'SKILL.md'), - `---\n${yamlLines}\n---\n\nSkill content here.\n`, - ) -} - -// Construct a script path inside the package directory. -// findHomeDir walks up from dirname(scriptPath) to find the nearest package.json. -function scriptPath(pkgDir: string): string { - return join(pkgDir, 'bin', 'intent.js') -} - -// --------------------------------------------------------------------------- -// Setup / Teardown -// --------------------------------------------------------------------------- - -let root: string - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'library-scanner-test-')) -}) - -afterEach(() => { - rmSync(root, { recursive: true, force: true }) -}) - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('scanLibrary', () => { - it('returns the home package with its skills', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.2.0', - description: 'Type-safe router for React', - keywords: ['tanstack-intent'], - }) - const skillDir = createDir(pkgDir, 'skills', 'routing') - writeSkillMd(skillDir, { - name: 'routing', - description: 'File-based route definitions', - }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - expect(result.warnings).toEqual([]) - expect(result.packages).toHaveLength(1) - expect(result.packages[0]!.name).toBe('@tanstack/router') - expect(result.packages[0]!.version).toBe('1.2.0') - expect(result.packages[0]!.description).toBe('Type-safe router for React') - expect(result.packages[0]!.skills).toHaveLength(1) - expect(result.packages[0]!.skills[0]!.name).toBe('routing') - expect(result.packages[0]!.skills[0]!.description).toBe( - 'File-based route definitions', - ) - }) - - it('includes the full path to each SKILL.md', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - }) - const skillDir = createDir(pkgDir, 'skills', 'routing') - writeSkillMd(skillDir, { name: 'routing', description: 'Routing patterns' }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - const skill = result.packages[0]!.skills[0]! - expect(skill.path).toBe( - 'node_modules/@tanstack/router/skills/routing/SKILL.md', - ) - }) - - it('recursively discovers deps with tanstack-intent keyword', () => { - // Home package: @tanstack/router, depends on @tanstack/query - const routerDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(routerDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - description: 'Router', - keywords: ['tanstack-intent'], - dependencies: { '@tanstack/query': '^5.0.0' }, - }) - const routerSkill = createDir(routerDir, 'skills', 'routing') - writeSkillMd(routerSkill, { - name: 'routing', - description: 'Route definitions', - }) - - // Dep package: @tanstack/query - const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') - writeJson(join(queryDir, 'package.json'), { - name: '@tanstack/query', - version: '5.0.0', - description: 'Async state management', - keywords: ['tanstack-intent'], - }) - const querySkill = createDir(queryDir, 'skills', 'fetching') - writeSkillMd(querySkill, { - name: 'fetching', - description: 'Query and mutation patterns', - }) - - const result = scanLibrary(scriptPath(routerDir), root) - - expect(result.warnings).toEqual([]) - expect(result.packages).toHaveLength(2) - - const names = result.packages.map((p) => p.name) - expect(names).toContain('@tanstack/router') - expect(names).toContain('@tanstack/query') - - const query = result.packages.find((p) => p.name === '@tanstack/query')! - expect(query.skills[0]!.name).toBe('fetching') - expect(query.skills[0]!.description).toBe('Query and mutation patterns') - }) - - it('does not invent top-level load paths for pnpm-style transitive deps', () => { - const routerStoreDir = createDir( - root, - 'node_modules', - '.pnpm', - '@tanstack+router@1.0.0', - 'node_modules', - '@tanstack', - 'router', - ) - writeJson(join(routerStoreDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - dependencies: { '@tanstack/query': '^5.0.0' }, - }) - const routerSkill = createDir(routerStoreDir, 'skills', 'routing') - writeSkillMd(routerSkill, { name: 'routing', description: 'Routing' }) - - const queryStoreDir = createDir( - root, - 'node_modules', - '.pnpm', - '@tanstack+query@5.0.0', - 'node_modules', - '@tanstack', - 'query', - ) - writeJson(join(queryStoreDir, 'package.json'), { - name: '@tanstack/query', - version: '5.0.0', - keywords: ['tanstack-intent'], - }) - const querySkill = createDir(queryStoreDir, 'skills', 'fetching') - writeSkillMd(querySkill, { name: 'fetching', description: 'Fetching' }) - - createDir(routerStoreDir, 'node_modules', '@tanstack') - symlinkSync( - queryStoreDir, - join(routerStoreDir, 'node_modules', '@tanstack', 'query'), - ) - createDir(root, 'node_modules', '@tanstack') - symlinkSync( - routerStoreDir, - join(root, 'node_modules', '@tanstack', 'router'), - ) - - const result = scanLibrary( - scriptPath(join(root, 'node_modules', '@tanstack', 'router')), - root, - ) - - const query = result.packages.find((p) => p.name === '@tanstack/query')! - expect(query.skills[0]!.path).not.toBe( - 'node_modules/@tanstack/query/skills/fetching/SKILL.md', - ) - expect(query.skills[0]!.path).toContain('node_modules/.pnpm/') - }) - - it('discovers deps via peerDependencies', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - peerDependencies: { '@tanstack/query': '^5.0.0' }, - }) - - const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') - writeJson(join(queryDir, 'package.json'), { - name: '@tanstack/query', - version: '5.0.0', - keywords: ['tanstack-intent'], - }) - const querySkill = createDir(queryDir, 'skills', 'fetching') - writeSkillMd(querySkill, { name: 'fetching', description: 'Fetching' }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - const names = result.packages.map((p) => p.name) - expect(names).toContain('@tanstack/query') - }) - - it('skips deps without tanstack-intent keyword or bin.intent', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - dependencies: { react: '^18.0.0' }, - }) - - const reactDir = createDir(root, 'node_modules', 'react') - writeJson(join(reactDir, 'package.json'), { - name: 'react', - version: '18.0.0', - // no tanstack-intent keyword or bin.intent - }) - const reactSkill = createDir(reactDir, 'skills', 'hooks') - writeSkillMd(reactSkill, { name: 'hooks', description: 'React hooks' }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - const names = result.packages.map((p) => p.name) - expect(names).not.toContain('react') - }) - - it('follows deps with legacy bin.intent (backwards compat)', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - dependencies: { '@tanstack/query': '^5.0.0' }, - }) - const routerSkill = createDir(pkgDir, 'skills', 'routing') - writeSkillMd(routerSkill, { name: 'routing', description: 'Routing' }) - - // Legacy package: only has bin.intent, no keyword - const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') - writeJson(join(queryDir, 'package.json'), { - name: '@tanstack/query', - version: '5.0.0', - bin: { intent: './bin/intent.js' }, - }) - const querySkill = createDir(queryDir, 'skills', 'fetching') - writeSkillMd(querySkill, { name: 'fetching', description: 'Fetching' }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - const names = result.packages.map((p) => p.name) - expect(names).toContain('@tanstack/query') - }) - - it('skips deps with other keywords but not tanstack-intent', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - dependencies: { 'some-lib': '^1.0.0' }, - }) - - const libDir = createDir(root, 'node_modules', 'some-lib') - writeJson(join(libDir, 'package.json'), { - name: 'some-lib', - version: '1.0.0', - keywords: ['react', 'state-management'], - }) - const libSkill = createDir(libDir, 'skills', 'core') - writeSkillMd(libSkill, { name: 'core', description: 'Core patterns' }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - const names = result.packages.map((p) => p.name) - expect(names).not.toContain('some-lib') - }) - - it('handles packages with no skills/ directory', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - }) - // No skills/ directory - - const result = scanLibrary(scriptPath(pkgDir), root) - - expect(result.packages).toHaveLength(1) - expect(result.packages[0]!.skills).toEqual([]) - }) - - it('does not visit the same package twice (cycle detection)', () => { - // router -> query -> router (circular) - const routerDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(routerDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - dependencies: { '@tanstack/query': '^5.0.0' }, - }) - - const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') - writeJson(join(queryDir, 'package.json'), { - name: '@tanstack/query', - version: '5.0.0', - keywords: ['tanstack-intent'], - dependencies: { '@tanstack/router': '^1.0.0' }, // circular back - }) - - const result = scanLibrary(scriptPath(routerDir), root) - - // Each package appears exactly once - const names = result.packages.map((p) => p.name) - expect(names).toHaveLength(2) - expect(new Set(names).size).toBe(2) - }) - - it('discovers sub-skills within a package', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - }) - const routingDir = createDir(pkgDir, 'skills', 'routing') - writeSkillMd(routingDir, { - name: 'routing', - description: 'Routing overview', - }) - const nestedDir = createDir(routingDir, 'nested-routes') - writeSkillMd(nestedDir, { - name: 'routing/nested-routes', - description: 'Nested route patterns', - }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - const skills = result.packages[0]!.skills - expect(skills).toHaveLength(2) - const names = skills.map((s) => s.name) - expect(names).toContain('routing') - expect(names).toContain('routing/nested-routes') - }) - - it('discovers skills nested under intermediate dirs without SKILL.md', () => { - const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') - writeJson(join(pkgDir, 'package.json'), { - name: '@tanstack/router', - version: '1.0.0', - keywords: ['tanstack-intent'], - }) - // intermediate directory has no SKILL.md - const groupDir = createDir(pkgDir, 'skills', 'group') - const nestedDir = createDir(groupDir, 'nested-skill') - writeSkillMd(nestedDir, { - name: 'group/nested-skill', - description: 'A nested skill under a grouping dir', - }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - const skills = result.packages[0]!.skills - expect(skills).toHaveLength(1) - expect(skills[0]!.name).toBe('group/nested-skill') - }) - - it('handles missing package name without producing double slashes in paths', () => { - const pkgDir = createDir(root, 'node_modules', 'no-name-pkg') - writeJson(join(pkgDir, 'package.json'), { - version: '1.0.0', - keywords: ['tanstack-intent'], - }) - const skillDir = createDir(pkgDir, 'skills', 'core') - writeSkillMd(skillDir, { name: 'core', description: 'Core skill' }) - - const result = scanLibrary(scriptPath(pkgDir), root) - - expect(result.packages).toHaveLength(1) - const skill = result.packages[0]!.skills[0]! - expect(skill.path).not.toContain('//') - }) - - it('returns a warning when home package.json cannot be found', () => { - const fakeScript = join(root, 'nowhere', 'bin', 'intent.js') - - const result = scanLibrary(fakeScript, root) - - expect(result.packages).toEqual([]) - expect(result.warnings).toHaveLength(1) - expect(result.warnings[0]).toMatch(/home package/i) - }) -}) diff --git a/packages/intent/tests/skill-use.test.ts b/packages/intent/tests/skill-use.test.ts index 92ed3c1..da4db33 100644 --- a/packages/intent/tests/skill-use.test.ts +++ b/packages/intent/tests/skill-use.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { + SkillUseParseError, formatSkillUse, parseSkillUse, - SkillUseParseError, } from '../src/skill-use.js' describe('skill use helpers', () => { diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts index 64dcf78..db9f559 100644 --- a/packages/intent/tests/stale-command.test.ts +++ b/packages/intent/tests/stale-command.test.ts @@ -9,8 +9,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { - getCheckSkillsWorkflowAdvisories, INTENT_CHECK_SKILLS_WORKFLOW_VERSION, + getCheckSkillsWorkflowAdvisories, } from '../src/cli-support.js' import { runStaleCommand } from '../src/commands/stale.js'