diff --git a/.changeset/yellow-queens-doubt.md b/.changeset/yellow-queens-doubt.md new file mode 100644 index 0000000..ff0426e --- /dev/null +++ b/.changeset/yellow-queens-doubt.md @@ -0,0 +1,17 @@ +--- +'@tanstack/intent': patch +--- + +Fix skill discovery in Yarn Berry (PnP) projects. With `nodeLinker: pnp` and no +`node_modules`, dependencies live in `.yarn/cache/*.zip` archives readable only +through Yarn's libzip-patched filesystem. `intent list` and `intent load` now +read package metadata and `SKILL.md` files from those archives — including when +Intent runs via `npx`/`dlx` from outside the project's PnP graph. A failed PnP +load fails closed with a clear diagnostic, and the PnP resolution hook is no +longer left installed in Intent's process. + +Speed up skill discovery. Frontmatter parsing now reads only the leading region +of each `SKILL.md` instead of the whole file (~4x faster on large skill bodies), +and dependency resolution reuses its module resolver per package instead of +rebuilding it for every dependency. Also drops redundant filesystem checks in the +skill-file walk. diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 06f76ed..6b7f67d 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,4 +1,3 @@ -import { readFileSync, realpathSync } from 'node:fs' import { isAbsolute, relative, resolve } from 'node:path' import { compileExcludePatterns, @@ -15,6 +14,7 @@ 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 { ReadFs } from './utils.js' import type { ScanOptions, ScanScope } from './types.js' import type { IntentCoreErrorCode, @@ -178,22 +178,26 @@ function toResolvedIntentSkill( cwd: string, use: string, resolved: ResolveSkillResult, + readFs: ReadFs, debug?: LoadedIntentSkillDebug, ): { realPackageRoot: string realResolvedPath: string + readFs: ReadFs result: ResolvedIntentSkill } { let realResolvedPath: string try { - realResolvedPath = realpathSync.native(resolveFromCwd(cwd, resolved.path)) + realResolvedPath = readFs.realpathSync.native( + resolveFromCwd(cwd, resolved.path), + ) } catch { throw new IntentCoreError( 'skill-file-not-found', `Resolved skill file was not found: ${resolved.path}`, ) } - const realPackageRoot = realpathSync.native( + const realPackageRoot = readFs.realpathSync.native( resolveFromCwd(cwd, resolved.packageRoot), ) @@ -222,6 +226,7 @@ function toResolvedIntentSkill( return { realPackageRoot, realResolvedPath, + readFs, result, } } @@ -263,6 +268,7 @@ function resolveIntentSkillInCwd( ): { realPackageRoot: string realResolvedPath: string + readFs: ReadFs result: ResolvedIntentSkill } { let parsedUse: ReturnType @@ -301,6 +307,7 @@ function resolveIntentSkillInCwd( cwd, use, fastPathResolved, + fsCache.getReadFs(), options.debug ? createLoadedSkillDebug({ cwd, @@ -331,6 +338,7 @@ function resolveIntentSkillInCwd( cwd, use, resolved, + fsCache.getReadFs(), options.debug ? createLoadedSkillDebug({ cwd, @@ -358,7 +366,7 @@ export function loadIntentSkill( const cwd = resolveCoreCwd(options) const resolved = resolveIntentSkillInCwd(cwd, use, options) const content = rewriteLoadedSkillMarkdownDestinations({ - content: readFileSync(resolved.realResolvedPath, 'utf8'), + content: resolved.readFs.readFileSync(resolved.realResolvedPath, 'utf8'), cwd, packageRoot: resolved.realPackageRoot, skillFilePath: resolved.realResolvedPath, diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index 368b4a0..b734e95 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -28,6 +28,12 @@ export interface CreatePackageRegistrarOptions { projectRoot: string readPkgJson: (dirPath: string) => PackageJson | null getFsIdentity: (path: string) => string + /** + * Existence check routed through the scan's active filesystem, so package + * roots inside a Yarn PnP zip cache are seen (the static `node:fs` binding + * does not see Yarn's libzip patch). + */ + exists: (path: string) => boolean rememberVariant: (pkg: IntentPackage) => void validateIntentField: (pkgName: string, intent: unknown) => IntentConfig | null warnings: Array @@ -76,7 +82,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { if (!shouldAttemptPackageRoot(dirPath)) return false const skillsDir = join(dirPath, 'skills') - if (!existsSync(skillsDir)) return false + if (!opts.exists(skillsDir)) return false const pkgJson = opts.readPkgJson(dirPath) if (!pkgJson) { diff --git a/packages/intent/src/fs-cache.ts b/packages/intent/src/fs-cache.ts index eca9406..7471776 100644 --- a/packages/intent/src/fs-cache.ts +++ b/packages/intent/src/fs-cache.ts @@ -1,9 +1,10 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createFsIdentityCache, findSkillFiles as findSkillFilesUncached, + nodeReadFs, } from './utils.js' +import type { ReadFs } from './utils.js' type PackageJsonReadResult = { packageJson: Record | null @@ -21,6 +22,16 @@ export type IntentFsCache = { findSkillFiles: (dir: string) => Array getFsIdentity: (path: string) => string getStats: () => IntentFsCacheStats + /** + * Swap the filesystem used for all reads. Under Yarn PnP the scanner installs + * Yarn's libzip-patched `fs` here once, so subsequent reads reach files inside + * `.yarn/cache/*.zip`. The patched `fs` also serves real paths, so it is safe + * to use for every read after the swap. + */ + useFs: (fs: ReadFs) => void + /** The filesystem currently used for reads (patched under Yarn PnP). */ + getReadFs: () => ReadFs + exists: (path: string) => boolean } function isRecord(value: unknown): value is Record { @@ -30,7 +41,8 @@ function isRecord(value: unknown): value is Record { export function createIntentFsCache(): IntentFsCache { const packageJsonCache = new Map() const skillFilesCache = new Map>() - const getFsIdentity = createFsIdentityCache() + let activeFs: ReadFs = nodeReadFs + const getFsIdentity = createFsIdentityCache(() => activeFs) const stats: IntentFsCacheStats = { packageJsonReadCount: 0, packageJsonCacheHits: 0, @@ -47,7 +59,7 @@ export function createIntentFsCache(): IntentFsCache { stats.packageJsonReadCount += 1 try { const parsed = JSON.parse( - readFileSync(join(dir, 'package.json'), 'utf8'), + activeFs.readFileSync(join(dir, 'package.json'), 'utf8'), ) as unknown const result = { packageJson: isRecord(parsed) ? parsed : null, @@ -73,7 +85,7 @@ export function createIntentFsCache(): IntentFsCache { return [...cached] } - const files = findSkillFilesUncached(dir) + const files = findSkillFilesUncached(dir, activeFs) skillFilesCache.set(key, files) return [...files] } @@ -84,5 +96,10 @@ export function createIntentFsCache(): IntentFsCache { findSkillFiles, getFsIdentity, getStats: () => ({ ...stats }), + useFs: (fs: ReadFs) => { + activeFs = fs + }, + getReadFs: () => activeFs, + exists: (path: string) => activeFs.existsSync(path), } } diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 6040817..27ad110 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -8,6 +8,7 @@ import { } from './discovery/index.js' import { detectGlobalNodeModules, + nodeReadFs, parseFrontmatter, toPosixPath, } from './utils.js' @@ -15,6 +16,7 @@ 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 { ReadFs } from './utils.js' import type { InstalledVariant, IntentConfig, @@ -50,6 +52,21 @@ interface PnpApi { topLevel?: PnpPackageLocator } +interface LoadedPnp { + api: PnpApi + /** + * Yarn's libzip-patched `fs`, captured after `.pnp.cjs` `setup()` runs. The + * scanner installs it as the active read filesystem so package roots inside + * `.yarn/cache/*.zip` are readable. + */ + readFs: ReadFs +} + +interface NodeModuleInternals { + _resolveFilename: (...args: Array) => unknown + findPnpApi?: (lookupSource: string) => PnpApi | null +} + const requireFromHere = createRequire(import.meta.url) function findPnpFile(start: string): string | null { @@ -80,32 +97,48 @@ function assertLocalNodeModulesSupported(root: string): void { } } -function loadPnpApi(root: string): PnpApi | null { +function loadPnpApi(root: string): LoadedPnp | null { const pnpPath = findPnpFile(root) if (!pnpPath) return null + const moduleApi = requireFromHere('node:module') as NodeModuleInternals + const originalResolveFilename = moduleApi._resolveFilename + // Capture `fs` before setup(). Yarn's `setup()` patches the `fs` module in + // place (libzip layer for reading inside `.yarn/cache/*.zip`), so this + // reference becomes patched without routing a post-setup `require('node:fs')` + // through Yarn's resolver hook (which rejects the `node:fs` specifier under + // Yarn 1 PnP). + const readFs = requireFromHere('node:fs') as unknown as ReadFs + try { const pnpModule = requireFromHere(pnpPath) as PnpApi if (typeof pnpModule.setup === 'function') { pnpModule.setup() } + // setup() also installs a global CommonJS module-resolution hook. Restore + // the resolver: Intent reads package data as files and never requires + // candidate package code, so leaving the resolver installed is an + // unnecessary, process-wide side effect (notably for a long-running + // `mcp serve`). The in-place `fs` patch survives the restore. + moduleApi._resolveFilename = originalResolveFilename + if ( typeof pnpModule.getDependencyTreeRoots === 'function' && typeof pnpModule.getPackageInformation === 'function' ) { - return pnpModule + return { api: pnpModule, readFs } } const projectRequire = createRequire(join(dirname(pnpPath), 'package.json')) - return projectRequire('pnpapi') as PnpApi + return { api: projectRequire('pnpapi') as PnpApi, readFs } } catch (err) { + moduleApi._resolveFilename = originalResolveFilename try { - const moduleApi = requireFromHere('node:module') as { - findPnpApi?: (lookupSource: string) => PnpApi | null - } const foundApi = moduleApi.findPnpApi?.(root) - if (foundApi) return foundApi + if (foundApi) { + return { api: foundApi, readFs } + } } catch { // Ignore and report the project PnP load error below. } @@ -215,8 +248,9 @@ function readSkillEntry( skillsDir: string, childDir: string, skillFile: string, + readFs: ReadFs = nodeReadFs, ): SkillEntry { - const fm = parseFrontmatter(skillFile) + const fm = parseFrontmatter(skillFile, readFs) const relName = toPosixPath(relative(skillsDir, childDir)) const desc = typeof fm?.description === 'string' @@ -236,6 +270,7 @@ function discoverSkillByNameHint( skillsDir: string, packageName: string, skillNameHint: string, + readFs: ReadFs = nodeReadFs, ): Array { const skills: Array = [] const seen = new Set() @@ -246,9 +281,9 @@ function discoverSkillByNameHint( if (!resolvedHint) continue const { childDir, skillFile } = resolvedHint - if (!existsSync(skillFile)) continue + if (!readFs.existsSync(skillFile)) continue - const skill = readSkillEntry(skillsDir, childDir, skillFile) + const skill = readSkillEntry(skillsDir, childDir, skillFile, readFs) if (skill.name !== hint || seen.has(skill.name)) continue seen.add(skill.name) @@ -262,12 +297,13 @@ function discoverSkills( skillsDir: string, fsCache: IntentFsCache, ): Array { + const readFs = fsCache.getReadFs() return fsCache .findSkillFiles(skillsDir) .flatMap((skillFile): Array => { const childDir = dirname(skillFile) if (childDir === skillsDir) return [] - return [readSkillEntry(skillsDir, childDir, skillFile)] + return [readSkillEntry(skillsDir, childDir, skillFile, readFs)] }) } @@ -456,7 +492,11 @@ export function scanForIntents( function getPnpApi(): PnpApi | null { if (scanScope === 'global') return null if (pnpApi === undefined) { - pnpApi = loadPnpApi(projectRoot) + const loaded = loadPnpApi(projectRoot) + pnpApi = loaded?.api ?? null + // Install Yarn's libzip-patched fs before any package inside the zip + // cache is read (scanPnpPackages runs after this). + if (loaded) fsCache.useFs(loaded.readFs) } return pnpApi } @@ -500,6 +540,7 @@ export function scanForIntents( discoverSkills: (skillsDir) => discoverSkills(skillsDir, fsCache), getPackageDepth, getFsIdentity: fsCache.getFsIdentity, + exists: fsCache.exists, packageIndexes, packages, projectRoot, @@ -691,10 +732,12 @@ export function scanIntentPackageAtRoot( skillsDir, packageName, options.skillNameHint!, + fsCache.getReadFs(), ) : (skillsDir) => discoverSkills(skillsDir, fsCache), getPackageDepth, getFsIdentity: fsCache.getFsIdentity, + exists: fsCache.exists, packageIndexes, packages, projectRoot, diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index 6a31e10..70cbcf2 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -1,8 +1,11 @@ import { execFileSync } from 'node:child_process' import { + closeSync, existsSync, lstatSync, + openSync, readFileSync, + readSync, readdirSync, realpathSync, } from 'node:fs' @@ -11,6 +14,40 @@ import { dirname, join, resolve, sep } from 'node:path' import { parse as parseYaml } from 'yaml' import type { Dirent } from 'node:fs' +/** + * The subset of `node:fs` the scanner reads through. Under Yarn PnP this is + * swapped for Yarn's libzip-patched `fs` so reads can reach files inside + * `.yarn/cache/*.zip` (see scanner `loadPnpApi`). The static `node:fs` named + * imports cannot be used directly for that, because their bindings are captured + * before Yarn's `setup()` patches the CommonJS `fs` module. + */ +export interface ReadFs { + existsSync: typeof existsSync + lstatSync: typeof lstatSync + readFileSync: typeof readFileSync + readdirSync: typeof readdirSync + realpathSync: typeof realpathSync + /** + * Optional low-level read primitives. When present (always on `node:fs` and + * Yarn's libzip-patched module) `parseFrontmatter` reads only the leading + * region of a file instead of its whole body. + */ + openSync?: typeof openSync + readSync?: typeof readSync + closeSync?: typeof closeSync +} + +export const nodeReadFs: ReadFs = { + existsSync, + lstatSync, + readFileSync, + readdirSync, + realpathSync, + openSync, + readSync, + closeSync, +} + /** * Convert a path to use forward slashes (for cross-platform consistency). */ @@ -18,7 +55,9 @@ export function toPosixPath(p: string): string { return p.split(sep).join('/') } -export function createFsIdentityCache(): (path: string) => string { +export function createFsIdentityCache( + getFs: () => ReadFs = () => nodeReadFs, +): (path: string) => string { const cache = new Map() return (path: string): string => { @@ -26,10 +65,11 @@ export function createFsIdentityCache(): (path: string) => string { const cached = cache.get(resolved) if (cached) return cached + const fs = getFs() let identity: string try { - identity = lstatSync(resolved).isSymbolicLink() - ? realpathSync(resolved) + identity = fs.lstatSync(resolved).isSymbolicLink() + ? fs.realpathSync(resolved) : resolved } catch { identity = resolved @@ -43,26 +83,35 @@ export function createFsIdentityCache(): (path: string) => string { /** * Recursively find all SKILL.md files under a directory. */ -export function findSkillFiles(dir: string): Array { +export function findSkillFiles( + dir: string, + fs: ReadFs = nodeReadFs, +): Array { const files: Array = [] - if (!existsSync(dir)) return files + collectSkillFiles(dir, fs, files) + return files +} +function collectSkillFiles( + dir: string, + fs: ReadFs, + files: Array, +): void { let entries: Array> try { - entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) + entries = fs.readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) } catch { - return files + return } for (const entry of entries) { const fullPath = join(dir, entry.name) if (entry.isDirectory()) { - files.push(...findSkillFiles(fullPath)) + collectSkillFiles(fullPath, fs, files) } else if (entry.name === 'SKILL.md') { files.push(fullPath) } } - return files } /** @@ -91,8 +140,6 @@ export function getDeps( export function listNodeModulesPackageDirs( nodeModulesDir: string, ): Array { - if (!existsSync(nodeModulesDir)) return [] - let topEntries: Array> try { topEntries = readdirSync(nodeModulesDir, { @@ -240,13 +287,33 @@ export function detectGlobalNodeModules(packageManager: string): { * (handles pnpm symlinks), then falls back to walking up node_modules * directories (handles packages with export maps that block ./package.json). */ +/** + * `createRequire` builds a full module-resolution context; constructing it is + * non-trivial and `resolveDepDir` is called once per dependency, often many + * times from the same `parentDir` (every sibling dep of one package). Cache the + * require function by its base `package.json` path. `req.resolve` still hits the + * live filesystem on each call, so cached entries never go stale. + */ +const requireForBaseCache = new Map>() + +function getRequireForBase( + basePackageJson: string, +): ReturnType { + let req = requireForBaseCache.get(basePackageJson) + if (!req) { + req = createRequire(basePackageJson) + requireForBaseCache.set(basePackageJson, req) + } + return req +} + export function resolveDepDir( depName: string, parentDir: string, ): string | null { // Try createRequire — works for most packages including pnpm virtual store try { - const req = createRequire(join(parentDir, 'package.json')) + const req = getRequireForBase(join(parentDir, 'package.json')) const pkgJsonPath = req.resolve(join(depName, 'package.json')) return dirname(pkgJsonPath) } catch (err: unknown) { @@ -284,17 +351,61 @@ export function resolveDepDir( */ export function parseFrontmatter( filePath: string, + fs: ReadFs = nodeReadFs, ): Record | null { - let content: string + const content = readFrontmatterRegion(filePath, fs) + if (content === null) return null + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match?.[1]) return null try { - content = readFileSync(filePath, 'utf8') + return parseYaml(match[1]) as Record } catch { return null } - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!match?.[1]) return null +} + +/** Max bytes read when probing a file's leading frontmatter region. */ +const FRONTMATTER_READ_LIMIT = 16 * 1024 +/** Reused across calls; safe because reads are synchronous and single-threaded. */ +const frontmatterBuffer = Buffer.allocUnsafe(FRONTMATTER_READ_LIMIT) + +/** + * Read just the leading region of a file, enough to cover its frontmatter, + * instead of its whole body. Falls back to a full read when the bounded read + * primitives are unavailable or the frontmatter exceeds the probe limit. + */ +function readFrontmatterRegion(filePath: string, fs: ReadFs): string | null { + if (fs.openSync && fs.readSync && fs.closeSync) { + let region: string | null = null + let truncated = false + let fd: number + try { + fd = fs.openSync(filePath, 'r') + } catch { + return null + } + try { + const bytesRead = fs.readSync( + fd, + frontmatterBuffer, + 0, + FRONTMATTER_READ_LIMIT, + 0, + ) + region = frontmatterBuffer.toString('utf8', 0, bytesRead) + // A full buffer means the file may extend past the probe window; only + // trust the bounded read when it captured the closing fence. + truncated = + bytesRead === FRONTMATTER_READ_LIMIT && + !/\r?\n---/.test(region.slice(3)) + } finally { + fs.closeSync(fd) + } + if (!truncated) return region + } + try { - return parseYaml(match[1]) as Record + return fs.readFileSync(filePath, 'utf8') } catch { return null } diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts new file mode 100644 index 0000000..31965bc --- /dev/null +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -0,0 +1,151 @@ +import { execFileSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' + +/** + * Regression guard for discussion #119: skill discovery in a real Yarn Berry + * (v4) project that uses `nodeLinker: pnp` and has no `node_modules`, where + * dependencies live inside `.yarn/cache/*.zip`. The project is generated with + * `corepack` at test time and a skill-bearing dependency is installed as a + * tarball so Yarn stores it in the zip cache (the shape that triggered #119). + * + * Reading inside the zip cache requires Yarn's libzip-patched `fs`. A synthetic + * `.pnp.cjs` with a no-op `setup()` does not reproduce that, so this uses a real + * Yarn install. The built CLI is run from the project cwd while Intent itself + * lives outside the project's PnP graph — the exact `npx`/`dlx` invocation from + * the report. + * + * On CI this test must run (it does not skip silently), so a #119 regression + * always surfaces. Locally it is skipped only when corepack/Yarn Berry cannot be + * set up (e.g. offline), to keep the suite runnable without network. + */ + +const YARN_VERSION = '4.12.0' +// Bound every external command so a stalled corepack/npm/node cannot hang CI: +// execFileSync is synchronous, so Vitest's test timeout cannot interrupt it. +const CMD_TIMEOUT_MS = 90_000 +const isCI = Boolean(process.env.CI) +const thisDir = dirname(fileURLToPath(import.meta.url)) +const cliPath = join(thisDir, '..', '..', 'dist', 'cli.mjs') +const realTmpdir = realpathSync(tmpdir()) + +// Never block on corepack's interactive download prompt in a non-TTY shell. +const corepackEnv = { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' } + +function berryAvailable(): boolean { + try { + // Run in a neutral cwd so a repo `packageManager` pin does not interfere. + execFileSync('corepack', [`yarn@${YARN_VERSION}`, '--version'], { + cwd: realTmpdir, + env: corepackEnv, + stdio: 'ignore', + timeout: CMD_TIMEOUT_MS, + }) + return true + } catch { + return false + } +} + +// On CI, always run so a regression is loud. Locally, skip when Berry is +// unavailable (offline) instead of failing the suite. +const shouldRun = isCI || berryAvailable() + +const tempDirs: Array = [] + +afterAll(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function writeJson(path: string, data: unknown): void { + writeFileSync(path, JSON.stringify(data, null, 2)) +} + +function scaffoldBerryProject(): string { + const dir = mkdtempSync(join(realTmpdir, 'intent-berry-corepack-')) + tempDirs.push(dir) + + // A skill-bearing package, packed to a tarball so Yarn stores it in the zip + // cache (the shape that triggered discussion #119). + const pkgSrc = join(dir, 'leaf-src') + mkdirSync(join(pkgSrc, 'skills', 'core'), { recursive: true }) + writeJson(join(pkgSrc, 'package.json'), { + name: '@repro/skills-leaf', + version: '1.0.0', + intent: { version: 1, repo: 'repro/leaf', docs: 'https://example.com' }, + repository: { type: 'git', url: 'git+https://github.com/repro/leaf.git' }, + }) + writeFileSync( + join(pkgSrc, 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: Core skill from the leaf package.\n---\n# Core\n', + ) + execFileSync('npm', ['pack', '--pack-destination', dir], { + cwd: pkgSrc, + timeout: CMD_TIMEOUT_MS, + }) + const tarball = readdirSync(dir).find((f) => f.endsWith('.tgz')) + if (!tarball) throw new Error('npm pack did not produce a tarball') + + writeFileSync( + join(dir, '.yarnrc.yml'), + 'nodeLinker: pnp\nenableGlobalCache: false\n', + ) + writeJson(join(dir, 'package.json'), { + name: 'berry-corepack-repro', + packageManager: `yarn@${YARN_VERSION}`, + dependencies: { '@repro/skills-leaf': `file:./${tarball}` }, + }) + + execFileSync('corepack', ['yarn', 'install'], { + cwd: dir, + stdio: 'pipe', + env: corepackEnv, + timeout: CMD_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, + }) + return dir +} + +describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { + it('discovers and loads skills from a zip-backed dependency', () => { + const cwd = scaffoldBerryProject() + + const list = execFileSync('node', [cliPath, 'list', '--json'], { + cwd, + encoding: 'utf8', + timeout: CMD_TIMEOUT_MS, + maxBuffer: 5 * 1024 * 1024, + }) + const parsed = JSON.parse(list) + expect(parsed.packages.map((p: { name: string }) => p.name)).toContain( + '@repro/skills-leaf', + ) + expect( + parsed.skills.map((s: { skillName: string }) => s.skillName), + ).toContain('core') + + const load = execFileSync( + 'node', + [cliPath, 'load', '@repro/skills-leaf#core'], + { + cwd, + encoding: 'utf8', + timeout: CMD_TIMEOUT_MS, + maxBuffer: 5 * 1024 * 1024, + }, + ) + expect(load).toContain('# Core') + }, 120_000) +}) diff --git a/packages/intent/tests/parse-frontmatter.test.ts b/packages/intent/tests/parse-frontmatter.test.ts new file mode 100644 index 0000000..89e4aef --- /dev/null +++ b/packages/intent/tests/parse-frontmatter.test.ts @@ -0,0 +1,65 @@ +import { 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 { parseFrontmatter } from '../src/utils.js' + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'intent-parse-frontmatter-test-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function write(name: string, content: string): string { + const path = join(root, name) + writeFileSync(path, content) + return path +} + +describe('parseFrontmatter', () => { + it('parses frontmatter from a small file', () => { + const path = write( + 'small.md', + '---\nname: core\ndescription: A skill\n---\n\nBody.\n', + ) + + expect(parseFrontmatter(path)).toEqual({ + name: 'core', + description: 'A skill', + }) + }) + + it('parses frontmatter without reading a large body', () => { + const body = 'x'.repeat(64 * 1024) + const path = write('large-body.md', `---\nname: big\n---\n\n${body}\n`) + + expect(parseFrontmatter(path)).toEqual({ name: 'big' }) + }) + + it('parses frontmatter that exceeds the bounded read probe', () => { + const longValue = 'y'.repeat(32 * 1024) + const path = write( + 'large-frontmatter.md', + `---\nname: big\ndescription: ${longValue}\n---\n\nBody.\n`, + ) + + expect(parseFrontmatter(path)).toEqual({ + name: 'big', + description: longValue, + }) + }) + + it('returns null when there is no frontmatter', () => { + const path = write('none.md', '# Just a heading\n\nNo frontmatter here.\n') + + expect(parseFrontmatter(path)).toBeNull() + }) + + it('returns null for a missing file', () => { + expect(parseFrontmatter(join(root, 'does-not-exist.md'))).toBeNull() + }) +}) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 8d904fe..23ede7b 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -1,4 +1,5 @@ import { + existsSync, mkdirSync, mkdtempSync, realpathSync, @@ -1125,6 +1126,63 @@ describe('scanForIntents', () => { } }) + it('fails closed with a diagnostic when the PnP API cannot be loaded', () => { + writeJson(join(root, 'package.json'), { + name: 'pnp-broken', + private: true, + packageManager: 'yarn@4.12.0', + dependencies: { '@scope/candidate': '1.0.0' }, + }) + writeFileSync(join(root, '.yarnrc.yml'), 'nodeLinker: pnp\n') + + // A candidate package whose code must never be executed during discovery. + const candidateDir = createDir( + root, + '.yarn', + 'cache', + 'candidate.zip', + 'node_modules', + '@scope', + 'candidate', + ) + writeJson(join(candidateDir, 'package.json'), { + name: '@scope/candidate', + version: '1.0.0', + }) + const sideEffectMarker = join(root, 'candidate-was-imported') + writeFileSync( + join(candidateDir, 'index.js'), + `require('node:fs').writeFileSync(${JSON.stringify(sideEffectMarker)}, 'x')\n`, + ) + + // A .pnp.cjs that throws on load: Intent must surface a diagnostic and must + // not fall back to importing candidate package code. + writeFileSync( + join(root, '.pnp.cjs'), + "throw new Error('corrupt pnp runtime')\n", + ) + + const moduleApi = requireFromTest('node:module') as { + findPnpApi?: () => unknown + } + const previousFindPnpApi = moduleApi.findPnpApi + // Ensure no ambient PnP API masks the load failure. + moduleApi.findPnpApi = () => null + + try { + expect(() => scanForIntents(root)).toThrow( + /Yarn PnP project detected, but Intent could not load Yarn's PnP API/, + ) + expect(existsSync(sideEffectMarker)).toBe(false) + } finally { + if (previousFindPnpApi) { + moduleApi.findPnpApi = previousFindPnpApi + } else { + delete moduleApi.findPnpApi + } + } + }) + it('falls back to Yarn PnP when workspace discovery finds packages first', () => { const reactStartDir = createDir( root,