From 11fd40ae420f51c7b2f99175818c86efaadec022 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 4 Sep 2026 20:35:09 -0700 Subject: [PATCH 1/2] perf: avoid repeated work in skill commands --- .changeset/quick-skill-reads.md | 5 + benchmarks/intent/list.bench.ts | 14 +- benchmarks/intent/load.bench.ts | 63 ++-- benchmarks/intent/stale.bench.ts | 44 ++- benchmarks/intent/startup.bench.ts | 22 +- benchmarks/intent/tsconfig.json | 3 +- benchmarks/intent/validate.bench.ts | 20 +- benchmarks/intent/vitest.config.ts | 2 + packages/intent/src/commands/stale.ts | 6 +- packages/intent/src/commands/support.ts | 42 ++- packages/intent/src/commands/validate.ts | 8 +- packages/intent/src/core/excludes.ts | 16 +- packages/intent/src/core/intent-core.ts | 8 +- packages/intent/src/core/markdown.ts | 8 +- packages/intent/src/core/package-json.ts | 37 +-- packages/intent/src/core/source-policy.ts | 19 +- .../intent/src/discovery/package-manager.ts | 28 +- packages/intent/src/discovery/scanner.ts | 63 +++- packages/intent/src/staleness/check.ts | 84 +++-- packages/intent/tests/repeated-work.test.ts | 308 ++++++++++++++++++ 20 files changed, 619 insertions(+), 181 deletions(-) create mode 100644 .changeset/quick-skill-reads.md create mode 100644 packages/intent/tests/repeated-work.test.ts diff --git a/.changeset/quick-skill-reads.md b/.changeset/quick-skill-reads.md new file mode 100644 index 00000000..eac12350 --- /dev/null +++ b/.changeset/quick-skill-reads.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Reduce repeated filesystem reads and path calculations in list, load, validate, and stale commands. Reuse command-scoped manifests, skill discovery, and shared workspace artifacts; index artifact matches once per package and batch workspace identity checks. diff --git a/benchmarks/intent/list.bench.ts b/benchmarks/intent/list.bench.ts index 48d09c9a..686a6965 100644 --- a/benchmarks/intent/list.bench.ts +++ b/benchmarks/intent/list.bench.ts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs' import { join } from 'node:path' -import { afterAll, beforeAll, bench, describe } from 'vitest' +import { afterAll, beforeAll, describe, test } from 'vitest' import { createBenchOptions, createCliRunner, @@ -9,7 +9,7 @@ import { writeFile, writeJson, writePackage, -} from './helpers.js' +} from './helpers.ts' type ListFixture = { globalNodeModules: string @@ -148,14 +148,12 @@ describe('intent list', () => { beforeAll(setup) afterAll(teardown) - bench( - 'scans a consumer workspace', - async () => { + test('scans a consumer workspace', { timeout: 30_000 }, async ({ bench }) => { + await bench('scans a consumer workspace', async () => { const state = getFixture() for (let index = 0; index < 3; index++) { await state.runner.run(['list', '--json']) } - }, - createBenchOptions(setup, teardown), - ) + }).run(createBenchOptions(setup, teardown)) + }) }) diff --git a/benchmarks/intent/load.bench.ts b/benchmarks/intent/load.bench.ts index f31aa1a6..2f3052d1 100644 --- a/benchmarks/intent/load.bench.ts +++ b/benchmarks/intent/load.bench.ts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs' import { join } from 'node:path' -import { afterAll, beforeAll, bench, describe } from 'vitest' +import { afterAll, beforeAll, describe, test } from 'vitest' import { createBenchOptions, createCliRunner, @@ -9,7 +9,7 @@ import { writeFile, writeJson, writePackage, -} from './helpers.js' +} from './helpers.ts' type LoadFixture = { root: string @@ -171,38 +171,51 @@ describe('intent load', () => { beforeAll(setup) afterAll(teardown) - bench( + test( 'loads a direct dependency skill', - async () => { - const state = getFixture() - for (let index = 0; index < 10; index++) { - await state.runner.run(['load', '@bench/query#query/cache', '--path']) - } + { timeout: 30_000 }, + async ({ bench }) => { + await bench('loads a direct dependency skill', async () => { + const state = getFixture() + for (let index = 0; index < 10; index++) { + await state.runner.run(['load', '@bench/query#query/cache', '--path']) + } + }).run(createBenchOptions(setup, teardown)) }, - createBenchOptions(setup, teardown), ) - bench( + test( 'loads direct dependency content as json', - async () => { - const state = getFixture() - for (let index = 0; index < 10; index++) { - await state.runner.run(['load', '@bench/query#query/cache', '--json']) - } + { timeout: 30_000 }, + async ({ bench }) => { + await bench('loads direct dependency content as json', async () => { + const state = getFixture() + for (let index = 0; index < 10; index++) { + await state.runner.run(['load', '@bench/query#query/cache', '--json']) + } + }).run(createBenchOptions(setup, teardown)) }, - createBenchOptions(setup, teardown), ) - bench( + test( 'loads a direct dependency from a large workspace', - async () => { - const state = getFixture() - await runInCwd(state.workspaceRoot, async () => { - for (let index = 0; index < 10; index++) { - await state.runner.run(['load', '@bench/query#query/cache', '--path']) - } - }) + { timeout: 30_000 }, + async ({ bench }) => { + await bench( + 'loads a direct dependency from a large workspace', + async () => { + const state = getFixture() + await runInCwd(state.workspaceRoot, async () => { + for (let index = 0; index < 10; index++) { + await state.runner.run([ + 'load', + '@bench/query#query/cache', + '--path', + ]) + } + }) + }, + ).run(createBenchOptions(setup, teardown)) }, - createBenchOptions(setup, teardown), ) }) diff --git a/benchmarks/intent/stale.bench.ts b/benchmarks/intent/stale.bench.ts index 9de8bbef..5e0a3438 100644 --- a/benchmarks/intent/stale.bench.ts +++ b/benchmarks/intent/stale.bench.ts @@ -1,6 +1,6 @@ -import { rmSync } from 'node:fs' +import { readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' -import { afterAll, beforeAll, bench, describe } from 'vitest' +import { afterAll, beforeAll, describe, test } from 'vitest' import { createBenchOptions, createCliRunner, @@ -9,7 +9,7 @@ import { writeFile, writeJson, writeSkill, -} from './helpers.js' +} from './helpers.ts' type StaleFixture = { root: string @@ -125,6 +125,25 @@ async function setup(): Promise { await getFixture().runner.setup() } +async function setupWithArtifacts(): Promise { + await setup() + const { root } = getFixture() + const skills = ['alpha', 'beta', 'gamma', 'delta'].flatMap((name) => { + const state = JSON.parse( + readFileSync( + join(root, 'packages', name, 'skills', 'sync-state.json'), + 'utf8', + ), + ) as { skills: Record } + return Object.keys(state.skills).map((skill) => ({ + package: `packages/${name}`, + slug: skill, + path: `packages/${name}/skills/${skill}/SKILL.md`, + })) + }) + writeJson(join(root, '_artifacts', 'skill_tree.yaml'), { skills }) +} + function teardown(): void { if (fixture) { fixture.runner.teardown() @@ -139,14 +158,25 @@ describe('intent stale', () => { beforeAll(setup) afterAll(teardown) - bench( - 'reports workspace drift', - async () => { + test('reports workspace drift', { timeout: 30_000 }, async ({ bench }) => { + await bench('reports workspace drift', async () => { const state = getFixture() for (let index = 0; index < 3; index++) { await state.runner.run(['stale', '--json']) } + }).run(createBenchOptions(setup, teardown)) + }) + + test( + 'reports workspace drift with shared artifacts', + { timeout: 30_000 }, + async ({ bench }) => { + await bench('reports workspace drift with shared artifacts', async () => { + const state = getFixture() + for (let index = 0; index < 3; index++) { + await state.runner.run(['stale', '--json']) + } + }).run(createBenchOptions(setupWithArtifacts, teardown)) }, - createBenchOptions(setup, teardown), ) }) diff --git a/benchmarks/intent/startup.bench.ts b/benchmarks/intent/startup.bench.ts index cf0159d2..68eb1dfe 100644 --- a/benchmarks/intent/startup.bench.ts +++ b/benchmarks/intent/startup.bench.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { bench, describe } from 'vitest' +import { describe, test } from 'vitest' const cliPath = fileURLToPath( new URL('../../packages/intent/dist/cli.mjs', import.meta.url), @@ -24,19 +24,19 @@ function runNode(args: Array): void { } describe('cold start', () => { - bench( + test( 'empty node process (baseline)', - () => { - runNode(['-e', '']) + { timeout: 30_000 }, + async ({ bench }) => { + await bench('empty node process (baseline)', () => { + runNode(['-e', '']) + }).run(coldStartBenchOptions) }, - coldStartBenchOptions, ) - bench( - 'intent --help', - () => { + test('intent --help', { timeout: 30_000 }, async ({ bench }) => { + await bench('intent --help', () => { runNode([cliPath, '--help']) - }, - coldStartBenchOptions, - ) + }).run(coldStartBenchOptions) + }) }) diff --git a/benchmarks/intent/tsconfig.json b/benchmarks/intent/tsconfig.json index fa8caa6e..b2de48f4 100644 --- a/benchmarks/intent/tsconfig.json +++ b/benchmarks/intent/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": ".", - "noEmit": true + "noEmit": true, + "allowImportingTsExtensions": true }, "include": ["*.ts"] } diff --git a/benchmarks/intent/validate.bench.ts b/benchmarks/intent/validate.bench.ts index b05e998e..879e25f0 100644 --- a/benchmarks/intent/validate.bench.ts +++ b/benchmarks/intent/validate.bench.ts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs' import { join } from 'node:path' -import { afterAll, beforeAll, bench, describe } from 'vitest' +import { afterAll, beforeAll, describe, test } from 'vitest' import { createBenchOptions, createCliRunner, @@ -9,7 +9,7 @@ import { writeFile, writeJson, writeSkill, -} from './helpers.js' +} from './helpers.ts' type ValidateFixture = { root: string @@ -120,14 +120,16 @@ describe('intent validate', () => { beforeAll(setup) afterAll(teardown) - bench( + test( 'checks a shipped skills tree', - async () => { - const state = getFixture() - for (let index = 0; index < 3; index++) { - await state.runner.run(['validate']) - } + { timeout: 30_000 }, + async ({ bench }) => { + await bench('checks a shipped skills tree', async () => { + const state = getFixture() + for (let index = 0; index < 3; index++) { + await state.runner.run(['validate']) + } + }).run(createBenchOptions(setup, teardown)) }, - createBenchOptions(setup, teardown), ) }) diff --git a/benchmarks/intent/vitest.config.ts b/benchmarks/intent/vitest.config.ts index d37b1780..5957ca8c 100644 --- a/benchmarks/intent/vitest.config.ts +++ b/benchmarks/intent/vitest.config.ts @@ -10,5 +10,7 @@ export default defineConfig({ name: '@benchmarks/intent', watch: false, environment: 'node', + // Measure the built CLI without module-export getter wrappers. + experimental: { viteModuleRunner: false }, }, }) diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index 1d55cda8..1dd8def0 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -5,6 +5,7 @@ import { isSkillExcluded, } from '../core/excludes.js' import { resolveProjectContext } from '../core/project-context.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' import { isSourcePermitted, readSkillSourcesConfig, @@ -135,9 +136,10 @@ function filterStaleReportSkills( ): Array { const cwd = resolve(process.cwd(), targetDir ?? process.cwd()) const context = resolveProjectContext({ cwd }) - const config = readSkillSourcesConfig(cwd, context) + const fsCache = createIntentFsCache() + const config = readSkillSourcesConfig(cwd, context, fsCache) const excludeMatchers = compileExcludePatterns( - getEffectiveExcludePatterns({}, context), + getEffectiveExcludePatterns({}, context, fsCache), ) return reports.map((report) => ({ diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index d6ea3811..d46990cb 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -3,6 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { fail } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' import type { IntentCoreOptions } from '../core/index.js' import type { ScanOptions, @@ -154,6 +155,7 @@ export function printDebugInfo( export async function resolveStaleTargets( targetDir?: string, ): Promise { + const fsCache = createIntentFsCache() const resolvedRoot = targetDir ? resolve(process.cwd(), targetDir) : process.cwd() @@ -178,41 +180,45 @@ export async function resolveStaleTargets( reports: [ await checkStaleness( context.packageRoot, - readPackageName(context.packageRoot), + readPackageName(context.packageRoot, fsCache), context.workspaceRoot ?? context.packageRoot, + { fsCache }, ), ], workflowAdvisories, } } - const { findWorkspaceRoot, getWorkspaceInfo } = + const { findWorkspaceRoot, findWorkspacePackages } = await import('../setup/workspace-patterns.js') const workspaceRoot = findWorkspaceRoot(resolvedRoot) - const workspaceInfo = workspaceRoot ? getWorkspaceInfo(workspaceRoot) : null - if (workspaceInfo) { + if (workspaceRoot) { + const packageDirs = findWorkspacePackages(workspaceRoot) + const packageDirsWithSkills = packageDirs.filter( + (dir) => fsCache.findSkillFiles(join(dir, 'skills')).length > 0, + ) + const { readIntentArtifacts } = + await import('../staleness/artifact-coverage.js') + const artifacts = readIntentArtifacts(workspaceRoot) const reports = await Promise.all( - workspaceInfo.packageDirsWithSkills.map((packageDir) => + packageDirsWithSkills.map((packageDir) => checkStaleness( packageDir, - readPackageName(packageDir), - workspaceInfo.root, + readPackageName(packageDir, fsCache), + workspaceRoot, + { fsCache, artifacts }, ), ), ) - const { readIntentArtifacts } = - await import('../staleness/artifact-coverage.js') - const artifacts = existsSync(join(workspaceInfo.root, '_artifacts')) - ? readIntentArtifacts(workspaceInfo.root) - : null const coverageSignals = buildWorkspaceCoverageSignals({ - artifactRoot: workspaceInfo.root, + artifactRoot: workspaceRoot, artifacts, - packageDirs: workspaceInfo.packageDirs, + packageDirs, + fsCache, }) if (coverageSignals.length > 0) { reports.push({ - library: relative(process.cwd(), workspaceInfo.root) || 'workspace', + library: relative(process.cwd(), workspaceRoot) || 'workspace', currentVersion: null, skillVersion: null, versionDrift: null, @@ -232,7 +238,9 @@ export async function resolveStaleTargets( if (existsSync(join(resolvedRoot, 'skills'))) { return { reports: [ - await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)), + await checkStaleness(resolvedRoot, undefined, resolvedRoot, { + fsCache, + }), ], workflowAdvisories, } @@ -242,7 +250,7 @@ export async function resolveStaleTargets( return { reports: await Promise.all( staleResult.packages.map((pkg) => - checkStaleness(pkg.packageRoot, pkg.name), + checkStaleness(pkg.packageRoot, pkg.name, pkg.packageRoot, { fsCache }), ), ), workflowAdvisories, diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 961f0d99..71c6344e 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -8,6 +8,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { fail, isCliFailure } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import { findWorkspacePackages } from '../setup/workspace-patterns.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' import { printWarnings } from './support.js' import type { ProjectContext } from '../core/project-context.js' @@ -388,8 +389,11 @@ async function runValidateCommandInternal( dir?: string, options: ValidateCommandOptions = {}, ): Promise { - const [{ parse: parseYaml }, { findSkillFiles, readScalarField }] = - await Promise.all([import('yaml'), import('../shared/utils.js')]) + const [{ parse: parseYaml }, { readScalarField }] = await Promise.all([ + import('yaml'), + import('../shared/utils.js'), + ]) + const { findSkillFiles } = createIntentFsCache() const context = resolveProjectContext({ cwd: process.cwd(), targetPath: dir, diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 81b3e3e7..aaa74bf4 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -3,6 +3,7 @@ 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' +import type { IntentFsCache } from '../discovery/fs-cache.js' const MAX_EXCLUDE_PATTERN_LENGTH = 200 const PACKAGE_NAME_BOUNDARY = /[^a-zA-Z0-9_.-]/ @@ -27,8 +28,11 @@ function isWithinOrEqual(path: string, parentDir: string): boolean { return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) } -function readPackageExcludes(dir: string): Array { - const pkg = readPackageJson(dir) +function readPackageExcludes( + dir: string, + fsCache?: IntentFsCache, +): Array { + const pkg = readPackageJson(dir, fsCache) const intent = pkg?.intent if (!intent || typeof intent !== 'object') return [] @@ -58,18 +62,22 @@ export function getConfigDirs( function getConfigExcludePatterns( cwd: string, context = resolveProjectContext({ cwd }), + fsCache?: IntentFsCache, ): Array { - return [...getConfigDirs(cwd, context)].reverse().flatMap(readPackageExcludes) + return [...getConfigDirs(cwd, context)] + .reverse() + .flatMap((dir) => readPackageExcludes(dir, fsCache)) } export function getEffectiveExcludePatterns( options: IntentCoreOptions = {}, context?: ProjectContext, + fsCache?: IntentFsCache, ): Array { const cwd = context?.cwd ?? resolve(process.cwd(), options.cwd ?? process.cwd()) return [ - ...getConfigExcludePatterns(cwd, context), + ...getConfigExcludePatterns(cwd, context, fsCache), ...normalizeExcludePatterns(options.exclude), ] } diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index 2deb24f2..6b433177 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -283,9 +283,13 @@ function resolveIntentSkillInCwd( const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) - const excludePatterns = getEffectiveExcludePatterns(options, projectContext) + const excludePatterns = getEffectiveExcludePatterns( + options, + projectContext, + fsCache, + ) const excludeMatchers = compileExcludePatterns(excludePatterns) - const config = readSkillSourcesConfig(cwd, projectContext) + const config = readSkillSourcesConfig(cwd, projectContext, fsCache) const refusal = checkLoadAllowed(use, parsedUse, { config, excludeMatchers }) if (refusal) { diff --git a/packages/intent/src/core/markdown.ts b/packages/intent/src/core/markdown.ts index 57a6ed49..c6d15f11 100644 --- a/packages/intent/src/core/markdown.ts +++ b/packages/intent/src/core/markdown.ts @@ -43,6 +43,7 @@ interface MarkdownDestinationRewriteContext { cwd: string resolvedPackageRoot: string skillDir: string + rewrittenDestinations: Map } function findClosingBracket(line: string, start: number): number { @@ -164,6 +165,8 @@ function rewriteMarkdownDestination({ destination: string }): string { if (isExternalOrAbsoluteDestination(destination)) return destination + const cached = context.rewrittenDestinations.get(destination) + if (cached !== undefined) return cached const { pathPart, suffix } = splitDestinationSuffix(destination) if (isExternalOrAbsoluteDestination(pathPart)) return destination @@ -188,7 +191,9 @@ function rewriteMarkdownDestination({ ? relativeToCwd : resolvedDestinationPath - return `${toPosixPath(rewrittenPath)}${suffix}` + const rewritten = `${toPosixPath(rewrittenPath)}${suffix}` + context.rewrittenDestinations.set(destination, rewritten) + return rewritten } function rewriteMarkdownLineDestinations({ @@ -283,6 +288,7 @@ export function rewriteLoadedSkillMarkdownDestinations({ cwd, resolvedPackageRoot: resolveFromCwd(packageRoot), skillDir: dirname(skillFilePath), + rewrittenDestinations: new Map(), } let inFence: '`' | '~' | null = null const parts = content.split(/(\r?\n)/) diff --git a/packages/intent/src/core/package-json.ts b/packages/intent/src/core/package-json.ts index a3c5d9c0..8be2e9f9 100644 --- a/packages/intent/src/core/package-json.ts +++ b/packages/intent/src/core/package-json.ts @@ -1,41 +1,38 @@ -import { lstatSync, readFileSync } from 'node:fs' import { join } from 'node:path' +import { createIntentFsCache } from '../discovery/fs-cache.js' /** * Reads a project policy manifest, returning null only when the file is absent. * Unreadable or invalid manifests throw so failures cannot remove restrictions. */ -export function readPackageJson(dir: string): Record | null { +export function readPackageJson( + dir: string, + fsCache = createIntentFsCache(), +): Record | null { const filePath = join(dir, 'package.json') - let content: string - try { - content = readFileSync(filePath, 'utf8') - } catch (err) { + const { packageJson, error } = fsCache.readPackageJsonResult(dir) + if (error instanceof SyntaxError) { + throw new Error( + `Failed to parse Intent policy from ${filePath}: invalid JSON.`, + ) + } + if (error) { if ( - (err as NodeJS.ErrnoException).code === 'ENOENT' && - !lstatSync(filePath, { throwIfNoEntry: false }) + (error as NodeJS.ErrnoException).code === 'ENOENT' && + !fsCache.getReadFs().lstatSync(filePath, { throwIfNoEntry: false }) ) { return null } throw new Error( - `Failed to read Intent policy from ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - ) - } - - let parsed: unknown - try { - parsed = JSON.parse(content) - } catch { - throw new Error( - `Failed to parse Intent policy from ${filePath}: invalid JSON.`, + `Failed to read Intent policy from ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ) } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!packageJson) { throw new Error( `Invalid Intent policy manifest ${filePath}: expected a JSON object.`, ) } - return parsed as Record + return packageJson } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 48cf2848..97c1e0a4 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -1,4 +1,5 @@ import { scanForIntents } from '../discovery/scanner.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' import { detectIntentAudience } from '../shared/environment.js' import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js' import { @@ -13,6 +14,7 @@ import { import { readPackageJson } from './package-json.js' import { parseSkillSources } from './skill-sources.js' import { resolveProjectContext } from './project-context.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' import type { SkillUse } from '../skills/use.js' import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' import type { ExcludeMatcher } from './excludes.js' @@ -269,9 +271,10 @@ export function applySourcePolicy( export function readSkillSourcesConfig( cwd: string, context: ProjectContext = resolveProjectContext({ cwd }), + fsCache?: IntentFsCache, ): SkillSourcesConfig { for (const dir of getConfigDirs(cwd, context)) { - const intent = readPackageJson(dir)?.intent + const intent = readPackageJson(dir, fsCache)?.intent if (!intent || typeof intent !== 'object') continue if ('skills' in intent) { @@ -302,9 +305,17 @@ export function scanForPolicedIntents(params: { const context = params.context ?? resolveProjectContext({ cwd }) const audience = detectIntentAudience(coreOptions.audience) - const scanResult = scanForIntents(cwd, scanOptions) - const config = readSkillSourcesConfig(cwd, context) - const excludePatterns = getEffectiveExcludePatterns(coreOptions, context) + const fsCache = + (scanOptions as ScanOptions & { fsCache?: IntentFsCache }).fsCache ?? + createIntentFsCache() + const cachedScanOptions = { ...scanOptions, fsCache } + const scanResult = scanForIntents(cwd, cachedScanOptions) + const config = readSkillSourcesConfig(cwd, context, fsCache) + const excludePatterns = getEffectiveExcludePatterns( + coreOptions, + context, + fsCache, + ) const excludeMatchers = compileExcludePatterns(excludePatterns) const policy = applySourcePolicy(scanResult, { diff --git a/packages/intent/src/discovery/package-manager.ts b/packages/intent/src/discovery/package-manager.ts index 97482e87..55c92089 100644 --- a/packages/intent/src/discovery/package-manager.ts +++ b/packages/intent/src/discovery/package-manager.ts @@ -1,15 +1,19 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import { createIntentFsCache } from './fs-cache.js' import type { PackageManager } from '../shared/types.js' +import type { IntentFsCache } from './fs-cache.js' -function readPackageManagerField(dir: string): PackageManager | null { +function readPackageManagerField( + dir: string, + fsCache: IntentFsCache, +): PackageManager | null { + if (!existsSync(join(dir, 'package.json'))) return null try { - const parsed = JSON.parse( - readFileSync(join(dir, 'package.json'), 'utf8'), - ) as unknown - if (!parsed || typeof parsed !== 'object') return null + const parsed = fsCache.readPackageJson(dir) + if (!parsed) return null - const value = (parsed as Record).packageManager + const value = parsed.packageManager if (typeof value !== 'string') return null if (value.startsWith('pnpm@')) return 'pnpm' @@ -23,8 +27,11 @@ function readPackageManagerField(dir: string): PackageManager | null { return null } -function detectPackageManagerInDir(dir: string): PackageManager | null { - const packageManager = readPackageManagerField(dir) +function detectPackageManagerInDir( + dir: string, + fsCache: IntentFsCache, +): PackageManager | null { + const packageManager = readPackageManagerField(dir, fsCache) if (packageManager) return packageManager if (existsSync(join(dir, '.pnp.cjs')) || existsSync(join(dir, '.pnp.js'))) { @@ -43,6 +50,7 @@ function detectPackageManagerInDir(dir: string): PackageManager | null { export function detectPackageManager( cwd = process.cwd(), extraDirs: Array = [], + fsCache = createIntentFsCache(), ): PackageManager { const seen = new Set() const startDirs = [cwd, ...extraDirs].filter((dir): dir is string => @@ -55,7 +63,7 @@ export function detectPackageManager( while (!seen.has(dir)) { seen.add(dir) - const packageManager = detectPackageManagerInDir(dir) + const packageManager = detectPackageManagerInDir(dir, fsCache) if (packageManager) return packageManager const next = dirname(dir) diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 2215a7b1..b81ac1a2 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -4,7 +4,15 @@ // roots. Enforced by the `intent/static-discovery` ESLint rule. import { constants, existsSync } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path' import semver from 'semver' import { detectGlobalNodeModules, @@ -507,13 +515,45 @@ function getScanScope(options: ScanOptions): ScanScope { function createWorkspacePackageKeySet( workspaceRoot: string | null, - getFsIdentity: (path: string) => string, + fsCache: IntentFsCache, ): Set { if (!workspaceRoot) return new Set() - return new Set( - findWorkspacePackages(workspaceRoot).map((dir) => getFsIdentity(dir)), - ) + const packagesByParent = new Map>() + for (const dir of findWorkspacePackages(workspaceRoot)) { + const parent = dirname(dir) + const dirs = packagesByParent.get(parent) + if (dirs) dirs.push(dir) + else packagesByParent.set(parent, [dir]) + } + + const keys = new Set() + for (const [parent, dirs] of packagesByParent) { + if (dirs.length === 1) { + keys.add(fsCache.getFsIdentity(dirs[0]!)) + continue + } + const ordinaryEntries = new Set() + try { + for (const entry of fsCache + .getReadFs() + .readdirSync(parent, { withFileTypes: true })) { + if (!entry.isSymbolicLink()) ordinaryEntries.add(entry.name) + } + } catch { + // Fall back to individual identity checks if the parent cannot be read. + } + // Directory entries supply the same symlink test as lstat, in one read + // per parent. Refresh each invocation so retargeted links change kind. + for (const dir of dirs) { + keys.add( + ordinaryEntries.has(basename(dir)) + ? resolve(dir) + : fsCache.getFsIdentity(dir), + ) + } + } + return keys } function createPackageKindResolver( @@ -536,7 +576,11 @@ export function scanForIntents( const fsCache = (options as ScanOptionsWithFsCache).fsCache ?? createIntentFsCache() const workspaceRoot = findWorkspaceRoot(projectRoot) - const packageManager = detectPackageManager(projectRoot, [workspaceRoot]) + const packageManager = detectPackageManager( + projectRoot, + [workspaceRoot], + fsCache, + ) const nodeModulesDir = join(projectRoot, 'node_modules') const explicitGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES?.trim() || null @@ -572,7 +616,7 @@ export function scanForIntents( let pnpApi: PnpApi | null | undefined const getPackageKind = createPackageKindResolver( - createWorkspacePackageKeySet(workspaceRoot, fsCache.getFsIdentity), + createWorkspacePackageKeySet(workspaceRoot, fsCache), fsCache.getFsIdentity, ) @@ -810,10 +854,7 @@ export function scanIntentPackageAtRoot( const packageIndexes = new Map() const fsCache = options.fsCache ?? createIntentFsCache() const getPackageKind = createPackageKindResolver( - createWorkspacePackageKeySet( - findWorkspaceRoot(projectRoot), - fsCache.getFsIdentity, - ), + createWorkspacePackageKeySet(findWorkspaceRoot(projectRoot), fsCache), fsCache.getFsIdentity, ) diff --git a/packages/intent/src/staleness/check.ts b/packages/intent/src/staleness/check.ts index 561b0b3f..628d391f 100644 --- a/packages/intent/src/staleness/check.ts +++ b/packages/intent/src/staleness/check.ts @@ -1,13 +1,14 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import semver from 'semver' +import { createIntentFsCache } from '../discovery/fs-cache.js' import { - findSkillFiles, parseFrontmatter, readScalarField, toPosixPath, } from '../shared/utils.js' import { readIntentArtifacts } from './artifact-coverage.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' import type { IntentArtifactSet, IntentArtifactSkill, @@ -79,17 +80,6 @@ function normalizeVersion(version: string): string | null { // Version resolution // --------------------------------------------------------------------------- -function readLocalVersion(packageDir: string): string | null { - try { - const pkgJson = JSON.parse( - readFileSync(join(packageDir, 'package.json'), 'utf8'), - ) as Record - return typeof pkgJson.version === 'string' ? pkgJson.version : null - } catch { - return null - } -} - const NPM_REGISTRY_FETCH_TIMEOUT_MS = 5_000 async function fetchNpmVersion(packageName: string): Promise { @@ -109,8 +99,10 @@ async function fetchNpmVersion(packageName: string): Promise { async function fetchCurrentVersion( packageDir: string, packageName: string, + fsCache: IntentFsCache, ): Promise { - return readLocalVersion(packageDir) ?? (await fetchNpmVersion(packageName)) + const version = fsCache.readPackageJson(packageDir)?.version + return typeof version === 'string' ? version : fetchNpmVersion(packageName) } // --------------------------------------------------------------------------- @@ -171,23 +163,16 @@ function readSyncState(packageDir: string): SyncState | null { } } -export function readPackageName(packageDir: string): string { - const packageJson = readPackageJson(packageDir) +export function readPackageName( + packageDir: string, + fsCache = createIntentFsCache(), +): string { + const packageJson = fsCache.readPackageJson(packageDir) return typeof packageJson?.name === 'string' ? packageJson.name : relative(process.cwd(), packageDir) || 'unknown' } -function readPackageJson(packageDir: string): Record | null { - try { - return JSON.parse( - readFileSync(join(packageDir, 'package.json'), 'utf8'), - ) as Record - } catch { - return null - } -} - // --------------------------------------------------------------------------- // Artifact signals // --------------------------------------------------------------------------- @@ -258,14 +243,11 @@ function resolveArtifactSkillPaths( function findMatchingSkill( artifact: IntentArtifactSkill, - skillMetas: Array, + skillsByPath: Map, + skillsByName: Map, packageDir: string, artifactRoot: string, ): SkillMeta | null { - const skillsByPath = new Map( - skillMetas.map((skill) => [normalizeFilePath(skill.filePath), skill]), - ) - for (const candidatePath of resolveArtifactSkillPaths( artifact, packageDir, @@ -275,11 +257,6 @@ function findMatchingSkill( if (match) return match } - const skillsByName = new Map() - for (const skill of skillMetas) { - skillsByName.set(skill.relName, skill) - } - return ( (artifact.slug ? skillsByName.get(artifact.slug) : undefined) ?? (artifact.name ? skillsByName.get(artifact.name) : undefined) ?? @@ -302,6 +279,13 @@ function buildArtifactSignals({ }): Array { if (!artifacts) return [] + const skillsByPath = new Map( + skillMetas.map((skill) => [normalizeFilePath(skill.filePath), skill]), + ) + const skillsByName = new Map( + skillMetas.map((skill) => [skill.relName, skill]), + ) + const artifactFiles = new Map( [...artifacts.skillTrees, ...artifacts.domainMaps].map((file) => [ file.path, @@ -326,7 +310,8 @@ function buildArtifactSignals({ const subject = artifact.slug ?? artifact.name ?? artifact.path const matchingSkill = findMatchingSkill( artifact, - skillMetas, + skillsByPath, + skillsByName, packageDir, artifactRoot, ) @@ -423,19 +408,21 @@ export function buildWorkspaceCoverageSignals({ artifactRoot, artifacts, packageDirs, + fsCache = createIntentFsCache(), }: { artifactRoot: string artifacts: IntentArtifactSet | null packageDirs: Array + fsCache?: IntentFsCache }): Array { if (!artifacts) return [] const signals: Array = [] for (const packageDir of packageDirs) { - const packageJson = readPackageJson(packageDir) + const packageJson = fsCache.readPackageJson(packageDir) if (packageJson?.private === true) continue - const packageName = readPackageName(packageDir) + const packageName = readPackageName(packageDir, fsCache) if ( artifactIgnoresPackage(artifacts, packageDir, packageName, artifactRoot) ) { @@ -443,7 +430,7 @@ export function buildWorkspaceCoverageSignals({ } const hasGeneratedSkill = - findSkillFiles(join(packageDir, 'skills')).length > 0 + fsCache.findSkillFiles(join(packageDir, 'skills')).length > 0 const hasArtifactCoverage = artifacts.skills.some((artifact) => artifactCoversPackage(artifact, packageDir, packageName, artifactRoot), ) @@ -474,12 +461,19 @@ export async function checkStaleness( packageDir: string, packageName?: string, artifactRoot = packageDir, + { + fsCache = createIntentFsCache(), + artifacts = readIntentArtifacts(artifactRoot), + }: { + fsCache?: IntentFsCache + artifacts?: IntentArtifactSet | null + } = {}, ): Promise { const skillsDir = join(packageDir, 'skills') - const library = packageName ?? readPackageName(packageDir) + const library = packageName ?? readPackageName(packageDir, fsCache) // Find all skills - const skillFiles = findSkillFiles(skillsDir) + const skillFiles = fsCache.findSkillFiles(skillsDir) const skillMetas: Array = skillFiles.map((filePath) => { const fm = parseFrontmatter(filePath) const relName = toPosixPath(relative(skillsDir, filePath)).replace( @@ -496,16 +490,12 @@ export async function checkStaleness( } }) - const artifacts = existsSync(join(artifactRoot, '_artifacts')) - ? readIntentArtifacts(artifactRoot) - : null - // Get the version from frontmatter (use first skill that has it) const skillVersion = skillMetas.find((s) => s.libraryVersion)?.libraryVersion ?? null // Resolve current version: prefer local package.json, fall back to npm registry - const currentVersion = await fetchCurrentVersion(packageDir, library) + const currentVersion = await fetchCurrentVersion(packageDir, library, fsCache) // Classify drift const versionDrift = diff --git a/packages/intent/tests/repeated-work.test.ts b/packages/intent/tests/repeated-work.test.ts new file mode 100644 index 00000000..be6ff4c6 --- /dev/null +++ b/packages/intent/tests/repeated-work.test.ts @@ -0,0 +1,308 @@ +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { main } from '../src/cli.js' +import { listIntentSkills, resolveIntentSkill } from '../src/core/index.js' +import { rewriteLoadedSkillMarkdownDestinations } from '../src/core/markdown.js' +import { checkStaleness } from '../src/staleness/check.js' +import type * as NodeFs from 'node:fs' +import type * as NodePath from 'node:path' + +vi.mock('node:fs', async (importOriginal) => { + const fs = await importOriginal() + return { + ...fs, + readFileSync: vi.fn(fs.readFileSync), + readdirSync: vi.fn(fs.readdirSync), + lstatSync: vi.fn(fs.lstatSync), + } +}) + +vi.mock('node:path', async (importOriginal) => { + const path = await importOriginal() + return { ...path, resolve: vi.fn(path.resolve) } +}) + +let root: string +let previousCwd: string + +function write(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function writePackage(dir: string, name: string): void { + write( + join(dir, 'package.json'), + JSON.stringify({ + name, + version: '1.0.0', + intent: { version: 1, repo: 'example/test', docs: 'docs/' }, + }), + ) + write( + join(dir, 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: Core workflow\nmetadata:\n library_version: "1.0.0"\n---\nGuide.\n', + ) +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'intent-repeated-work-'))) + previousCwd = process.cwd() + process.chdir(root) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.clearAllMocks() +}) + +afterEach(() => { + process.chdir(previousCwd) + rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('command work budgets', () => { + it('indexes each skill path once when matching many artifact entries by name', async () => { + write( + join(root, 'package.json'), + JSON.stringify({ name: 'example', version: '1.0.0' }), + ) + const names = Array.from({ length: 16 }, (_, index) => `skill-${index}`) + for (const name of names) { + write( + join(root, 'skills', name, 'SKILL.md'), + `---\nname: ${name}\ndescription: Guide\n---\nGuide.\n`, + ) + } + write( + join(root, '_artifacts', 'skill_tree.yaml'), + JSON.stringify({ skills: names.map((name) => ({ slug: name })) }), + ) + const report = await checkStaleness(root) + expect(report.skills).toHaveLength(16) + expect(report.signals).toEqual([]) + for (const name of names) { + expect( + vi + .mocked(resolve) + .mock.calls.filter( + ([path]) => path === join(root, 'skills', name, 'SKILL.md'), + ), + ).toHaveLength(1) + } + }) + it('classifies a direct dependency without statting every workspace package', () => { + write( + join(root, 'package.json'), + JSON.stringify({ + name: 'consumer', + dependencies: { example: '1.0.0' }, + intent: { skills: ['example'] }, + }), + ) + write(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n') + const packageDirs = Array.from({ length: 120 }, (_, index) => + join(root, 'packages', `pkg-${index}`), + ) + for (const dir of packageDirs) write(join(dir, 'package.json'), '{}') + writePackage(join(root, 'node_modules', 'example'), 'example') + + expect(resolveIntentSkill('example#core', { cwd: root }).skillName).toBe( + 'core', + ) + expect( + vi + .mocked(lstatSync) + .mock.calls.filter(([path]) => packageDirs.includes(String(path))), + ).toHaveLength(0) + }) + + it.each([false, true])( + 'refreshes explicitly symlinked workspace identities between loads (siblings: %s)', + (hasSibling) => { + write( + join(root, 'package.json'), + JSON.stringify({ + name: 'consumer', + intent: { skills: ['workspace:example'] }, + }), + ) + write( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - linked-package\n', + ) + if (hasSibling) { + write( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - linked-package\n - sibling-package\n', + ) + writePackage(join(root, 'sibling-package'), 'sibling') + } + const first = join(root, 'first') + const second = join(root, 'second') + writePackage(first, 'example') + writePackage(second, 'example') + const workspaceLink = join(root, 'linked-package') + const dependencyLink = join(root, 'node_modules', 'example') + mkdirSync(dirname(dependencyLink), { recursive: true }) + symlinkSync(first, workspaceLink, 'dir') + symlinkSync(first, dependencyLink, 'dir') + expect(resolveIntentSkill('example#core', { cwd: root }).skillName).toBe( + 'core', + ) + + unlinkSync(workspaceLink) + symlinkSync(second, workspaceLink, 'dir') + expect(() => resolveIntentSkill('example#core', { cwd: root })).toThrow( + 'not listed', + ) + unlinkSync(dependencyLink) + symlinkSync(second, dependencyLink, 'dir') + expect(resolveIntentSkill('example#core', { cwd: root }).skillName).toBe( + 'core', + ) + expect( + vi.mocked(readdirSync).mock.calls.filter(([path]) => path === root), + ).toHaveLength(hasSibling ? 3 : 0) + }, + ) + it('reuses repeated Markdown destinations only within a document', () => { + const content = `${'[Guide](guide.md#one)\n'.repeat(10)}[Other](guide.md#two)` + for (const name of ['first', 'second']) { + const packageRoot = join(root, name) + const skillDir = join(packageRoot, 'skills', 'core') + const result = rewriteLoadedSkillMarkdownDestinations({ + content, + cwd: root, + packageRoot, + skillFilePath: join(skillDir, 'SKILL.md'), + }) + expect(result).toBe( + `${`[Guide](${name}/skills/core/guide.md#one)\n`.repeat(10)}[Other](${name}/skills/core/guide.md#two)`, + ) + expect( + vi + .mocked(resolve) + .mock.calls.filter( + ([from, to]) => from === skillDir && to === 'guide.md', + ), + ).toHaveLength(2) + } + }) + it.each(['list', 'load'])( + 'shares manifest reads during %s and refreshes policy on the next call', + (command) => { + const manifest = join(root, 'package.json') + const project = { + name: 'consumer', + packageManager: 'npm@10.0.0', + dependencies: { example: '1.0.0' }, + } + write(manifest, JSON.stringify(project)) + writePackage(join(root, 'node_modules', 'example'), 'example') + listIntentSkills({ cwd: root }) + vi.clearAllMocks() + + if (command === 'list') + expect(listIntentSkills({ cwd: root }).skills).toHaveLength(1) + else + expect( + resolveIntentSkill('example#core', { cwd: root }).skillName, + ).toBe('core') + expect( + vi + .mocked(readFileSync) + .mock.calls.filter(([path]) => path === manifest), + ).toHaveLength(1) + + write(manifest, JSON.stringify({ ...project, intent: { skills: [] } })) + if (command === 'list') + expect(listIntentSkills({ cwd: root }).skills).toHaveLength(0) + else + expect(() => resolveIntentSkill('example#core', { cwd: root })).toThrow( + 'not listed', + ) + }, + ) + it.each([[], ['skills']])( + 'walks each validation directory once: %j', + async (...args) => { + writePackage(root, 'example') + expect(await main(['validate', ...args])).toBe(0) + for (const dir of [join(root, 'skills'), join(root, 'skills', 'core')]) { + expect( + vi.mocked(readdirSync).mock.calls.filter(([path]) => path === dir), + ).toHaveLength(1) + } + expect( + vi + .mocked(readFileSync) + .mock.calls.filter( + ([path]) => path === join(root, 'skills', 'core', 'SKILL.md'), + ), + ).toHaveLength(1) + }, + ) + + it('reads workspace artifacts, manifests, and skill trees once per stale invocation', async () => { + write( + join(root, 'package.json'), + JSON.stringify({ name: 'workspace', private: true }), + ) + write(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n') + const packageDirs = ['one', 'two'].map((name) => { + const dir = join(root, 'packages', name) + writePackage(dir, name) + return dir + }) + const artifactPath = join(root, '_artifacts', 'skill_tree.yaml') + write( + artifactPath, + JSON.stringify({ + skills: packageDirs.map((dir) => ({ + path: `${dir}/skills/core/SKILL.md`, + })), + }), + ) + + expect(await main(['stale', '--json'])).toBe(0) + expect( + vi + .mocked(readFileSync) + .mock.calls.filter(([path]) => path === artifactPath), + ).toHaveLength(1) + for (const dir of packageDirs) { + expect( + vi + .mocked(readFileSync) + .mock.calls.filter(([path]) => path === join(dir, 'package.json')), + ).toHaveLength(1) + expect( + vi + .mocked(readdirSync) + .mock.calls.filter(([path]) => path === join(dir, 'skills')), + ).toHaveLength(1) + } + const reports = JSON.parse( + vi.mocked(console.log).mock.calls.at(-1)![0] as string, + ) + expect(reports).toHaveLength(2) + expect( + reports.every( + (report: { signals: Array }) => report.signals.length === 0, + ), + ).toBe(true) + }) +}) From 9dedcc3418b500b98f121c2d55804f8f41529cfa Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 4 Sep 2026 20:55:41 -0700 Subject: [PATCH 2/2] perf: reuse discovery cache in stale fallback --- packages/intent/src/commands/support.ts | 7 +++- packages/intent/tests/repeated-work.test.ts | 42 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index d46990cb..765655c2 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -5,6 +5,7 @@ import { fail } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import { createIntentFsCache } from '../discovery/fs-cache.js' import type { IntentCoreOptions } from '../core/index.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' import type { ScanOptions, ScanResult, @@ -83,13 +84,15 @@ export function getCheckSkillsWorkflowAdvisories(root: string): Array { export async function scanIntentsOrFail( coreOptions: IntentCoreOptions = {}, + fsCache?: IntentFsCache, ): Promise { const { scanForPolicedIntents } = await import('../core/source-policy.js') try { + const scanOptions = { ...scanOptionsFromGlobalFlags(coreOptions), fsCache } const { scan } = scanForPolicedIntents({ cwd: process.cwd(), - scanOptions: scanOptionsFromGlobalFlags(coreOptions), + scanOptions, coreOptions, }) return scan @@ -246,7 +249,7 @@ export async function resolveStaleTargets( } } - const staleResult = await scanIntentsOrFail() + const staleResult = await scanIntentsOrFail({}, fsCache) return { reports: await Promise.all( staleResult.packages.map((pkg) => diff --git a/packages/intent/tests/repeated-work.test.ts b/packages/intent/tests/repeated-work.test.ts index be6ff4c6..76917a66 100644 --- a/packages/intent/tests/repeated-work.test.ts +++ b/packages/intent/tests/repeated-work.test.ts @@ -256,6 +256,48 @@ describe('command work budgets', () => { }, ) + it('reuses discovered manifests and skill files in the fallback stale path', async () => { + write( + join(root, 'package.json'), + JSON.stringify({ + name: 'consumer', + private: true, + dependencies: { example: '1.0.0' }, + }), + ) + write(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n') + write(join(root, 'packages', 'app', 'package.json'), '{"name":"app"}') + const packageDir = join(root, 'node_modules', 'example') + writePackage(packageDir, 'example') + + expect(await main(['stale', '--json'])).toBe(0) + const reports = JSON.parse( + vi.mocked(console.log).mock.calls.at(-1)![0] as string, + ) + expect(reports).toHaveLength(1) + expect(reports[0]).toMatchObject({ + library: 'example', + currentVersion: '1.0.0', + signals: [], + }) + expect(reports[0].skills).toHaveLength(1) + expect( + vi + .mocked(readFileSync) + .mock.calls.filter( + ([path]) => path === join(packageDir, 'package.json'), + ), + ).toHaveLength(1) + for (const dir of [ + join(packageDir, 'skills'), + join(packageDir, 'skills', 'core'), + ]) { + expect( + vi.mocked(readdirSync).mock.calls.filter(([path]) => path === dir), + ).toHaveLength(1) + } + }) + it('reads workspace artifacts, manifests, and skill trees once per stale invocation', async () => { write( join(root, 'package.json'),