diff --git a/.changeset/calm-skills-select.md b/.changeset/calm-skills-select.md new file mode 100644 index 00000000..8d40177c --- /dev/null +++ b/.changeset/calm-skills-select.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': minor +--- + +Add exact npm and workspace skill selectors to `intent.skills`. diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 270fcc60..349e98da 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -8,7 +8,7 @@ Intent reads consumer configuration from the `intent` object in `package.json`. ```json { "intent": { - "skills": ["@tanstack/query", "workspace:@scope/internal"], + "skills": ["@tanstack/query", "@acme/*", "@tanstack/start#routing", "workspace:@scope/internal"], "exclude": ["@tanstack/router#experimental-*"] } } @@ -21,7 +21,7 @@ Intent reads consumer configuration from the `intent` object in `package.json`. ## `intent.skills` -`intent.skills` is a package-source allowlist. A permitted package can: +`intent.skills` is a package-source and skill allowlist. A permitted package or skill can: - Appear in `list` and `stale`. - Resolve through `load`. @@ -29,7 +29,7 @@ Intent reads consumer configuration from the `intent` object in `package.json`. The default `install` command writes generic loading guidance without scanning packages. See [Trust model](./trust-model) for the reasoning and lifecycle boundaries. -The allowlist permits packages, not individual skills. An entry containing `#` is invalid; use `intent.exclude` for skill-specific filtering. +Package selectors permit every skill in the package. Exact selectors use `#` and permit only the named skill. If the same package matches both forms, the package selector takes precedence and permits every skill. `intent.exclude` is applied afterward and can still remove a permitted package or skill. ### Source entries @@ -38,12 +38,16 @@ Each array entry names one source: | Entry | Kind | Meaning | | ----- | ---- | ------- | | `@scope/pkg` or `pkg` | npm | An npm package reachable through the dependency tree, direct or transitive. | +| `@scope/pkg#skill` | npm | One exact skill in an npm package. | | `workspace:@scope/pkg` | workspace | A package in the current workspace. | +| `workspace:@scope/pkg#skill` | workspace | One exact skill in a workspace package. | | `@scope/*` | npm | Every discovered npm package whose name matches the pattern. | | `workspace:@scope/*` | workspace | Every discovered workspace package whose name matches the pattern. | | `git:/#` | git | Reserved. Not yet supported, and rejected until a future version adds it. | -A malformed entry fails the whole command, and every bad entry is reported at once. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`. Intent matches both the package name and source kind: a bare entry permits only an npm source, and a `workspace:` entry permits only a workspace source. +A malformed entry fails the whole command, and every bad entry is reported at once. Exact selectors require one non-empty package name and one non-empty, non-wildcard skill name. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`, but cannot be combined with an exact skill selector. + +Intent matches both the package name and source kind: a bare package or exact selector permits only an npm source, and a `workspace:` selector permits only a workspace source. `git:` entries remain unsupported and are rejected, including entries that contain `#` for a Git ref. ### Special forms diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index 462be936..1d55cda8 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -1,3 +1,14 @@ +import { resolve } from 'node:path' +import { + compileExcludePatterns, + getEffectiveExcludePatterns, + isSkillExcluded, +} from '../core/excludes.js' +import { resolveProjectContext } from '../core/project-context.js' +import { + isSourcePermitted, + readSkillSourcesConfig, +} from '../core/source-policy.js' import { isCliFailure } from '../shared/cli-error.js' import type { StalenessReport } from '../shared/types.js' @@ -20,8 +31,9 @@ export async function runStaleCommand( return } - const { reports, workflowAdvisories = [] } = + const { reports: unfilteredReports, workflowAdvisories = [] } = await resolveStaleTargets(targetDir) + const reports = filterStaleReportSkills(unfilteredReports, targetDir) if (options.json) { console.log(JSON.stringify(reports, null, 2)) @@ -91,8 +103,9 @@ async function runGithubReview( const packageLabel = options.packageLabel ?? 'workspace' try { - const { reports, workflowAdvisories = [] } = + const { reports: unfilteredReports, workflowAdvisories = [] } = await resolveStaleTargets(targetDir) + const reports = filterStaleReportSkills(unfilteredReports, targetDir) const items = [ ...collectStaleReviewItems(reports), ...createWorkflowAdvisoryReviewItems(packageLabel, workflowAdvisories), @@ -115,3 +128,24 @@ async function runGithubReview( console.log('Wrote a review PR body so maintainers can inspect the logs.') } } + +function filterStaleReportSkills( + reports: Array, + targetDir: string | undefined, +): Array { + const cwd = resolve(process.cwd(), targetDir ?? process.cwd()) + const context = resolveProjectContext({ cwd }) + const config = readSkillSourcesConfig(cwd, context) + const excludeMatchers = compileExcludePatterns( + getEffectiveExcludePatterns({}, context), + ) + + return reports.map((report) => ({ + ...report, + skills: report.skills.filter( + (skill) => + isSourcePermitted(config, report.library, undefined, skill.name) && + !isSkillExcluded(report.library, skill.name, excludeMatchers), + ), + })) +} diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index d24f7a95..2deb24f2 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -303,7 +303,12 @@ function resolveIntentSkillInCwd( ) if (fastPathResolved) { if ( - !isSourcePermitted(config, parsedUse.packageName, fastPathResolved.kind) + !isSourcePermitted( + config, + parsedUse.packageName, + fastPathResolved.kind, + parsedUse.skillName, + ) ) { const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) throw new IntentCoreError(lateRefusal.code, lateRefusal.message) diff --git a/packages/intent/src/core/skill-sources.ts b/packages/intent/src/core/skill-sources.ts index a26435a5..de21c015 100644 --- a/packages/intent/src/core/skill-sources.ts +++ b/packages/intent/src/core/skill-sources.ts @@ -3,7 +3,7 @@ type SkillSource = | ({ raw: string; kind: 'npm' | 'workspace' } & ( - { id: string } | { pattern: string } + { id: string; skill?: string } | { pattern: string } )) | { raw: string; id: string; kind: 'git'; ref: string } @@ -112,7 +112,8 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig { } const selector = 'pattern' in parsed ? parsed.pattern : parsed.id - const identity = `${parsed.kind}\u0000${selector}` + const skill = 'skill' in parsed ? parsed.skill : undefined + const identity = `${parsed.kind}\u0000${selector}\u0000${skill ?? ''}` if (seenIdentity.has(identity)) continue seenIdentity.add(identity) sources.push(parsed) @@ -178,16 +179,35 @@ function parseEntry( function packageSource( raw: string, - id: string, + selector: string, kind: 'npm' | 'workspace', -): SkillSource { - return id.includes('*') ? { raw, pattern: id, kind } : { raw, id, kind } +): SkillSource | SkillSourceIssue { + const hashIndex = selector.indexOf('#') + if (hashIndex === -1) { + const invalid = validateId(selector) + if (invalid) return { raw, message: invalid } + return selector.includes('*') + ? { raw, pattern: selector, kind } + : { raw, id: selector, kind } + } + + const id = selector.slice(0, hashIndex) + const skill = selector.slice(hashIndex + 1) + const invalidId = validateId(id) + if (invalidId) return { raw, message: invalidId } + if (id.includes('*')) { + return { + raw, + message: 'exact package selectors cannot contain "*".', + } + } + const invalidSkill = validateSkill(skill) + if (invalidSkill) return { raw, message: invalidSkill } + return { raw, id, kind, skill } } function validateId(id: string): string | null { - if (id.includes('#')) { - return 'skill-level granularity (#) is not supported in intent.skills (it is package-level); use intent.exclude for skill-level control.' - } + if (id === '') return 'package name is empty.' if (/\s/.test(id)) { return 'package names cannot contain whitespace.' } @@ -197,6 +217,14 @@ function validateId(id: string): string | null { return null } +function validateSkill(skill: string): string | null { + if (skill === '') return 'skill name is empty.' + if (/\s/.test(skill)) return 'skill names cannot contain whitespace.' + if (skill.includes('#')) return 'skill names cannot contain "#".' + if (skill.includes('*')) return 'exact skill selectors cannot contain "*".' + return null +} + function describeType(value: unknown): string { if (value === null) return 'null' return Array.isArray(value) ? 'array' : typeof value diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 93e3ebc5..48cf2848 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -81,7 +81,11 @@ function compileSkillSourceMatcher( function compileSkillSourcePolicy(config: SkillSourcesConfig): { matchers: Array - permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean + permits: ( + packageName: string, + packageKind?: 'npm' | 'workspace', + skillName?: string, + ) => boolean } { switch (config.mode) { case 'absent': @@ -93,9 +97,14 @@ function compileSkillSourcePolicy(config: SkillSourcesConfig): { const matchers = config.sources.map(compileSkillSourceMatcher) return { matchers, - permits: (packageName, packageKind) => - matchers.some((matcher) => - matcher.matchesPackage(packageName, packageKind), + permits: (packageName, packageKind, skillName) => + matchers.some( + (matcher) => + matcher.matchesPackage(packageName, packageKind) && + (!('skill' in matcher.source) || + matcher.source.skill === undefined || + skillName === undefined || + matcher.source.skill === skillName), ), } } @@ -106,8 +115,13 @@ export function isSourcePermitted( config: SkillSourcesConfig, packageName: string, packageKind?: 'npm' | 'workspace', + skillName?: string, ): boolean { - return compileSkillSourcePolicy(config).permits(packageName, packageKind) + return compileSkillSourcePolicy(config).permits( + packageName, + packageKind, + skillName, + ) } export function packageNotListedRefusal( @@ -141,7 +155,7 @@ export function checkLoadAllowed( // Name-only pre-check: kind isn't known yet at this point in the load path. // A late, kind-aware isSourcePermitted call happens once resolution reveals // the actual kind (see intent-core.ts). - if (!isSourcePermitted(config, packageName)) { + if (!isSourcePermitted(config, packageName, undefined, skillName)) { return packageNotListedRefusal(use, packageName) } @@ -212,7 +226,9 @@ export function applySourcePolicy( } const skills = pkg.skills.filter( - (skill) => !isSkillExcluded(pkg.name, skill.name, excludeMatchers), + (skill) => + sourcePolicy.permits(pkg.name, pkg.kind, skill.name) && + !isSkillExcluded(pkg.name, skill.name, excludeMatchers), ) packages.push( skills.length === pkg.skills.length ? pkg : { ...pkg, skills }, diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index b2664cc1..97ed3acf 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -5,11 +5,13 @@ import { rmSync, writeFileSync, } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' import { main } from '../../src/cli.js' +import { buildHookRunnerScript } from '../../src/hooks/install.js' const realTmpdir = realpathSync(tmpdir()) @@ -21,7 +23,7 @@ function writeJson(filePath: string, data: unknown): void { function writeIntentPackage( baseDir: string, name: string, - skillName: string, + skillNames: string | Array, ): void { const pkgDir = join(baseDir, 'node_modules', ...name.split('/')) writeJson(join(pkgDir, 'package.json'), { @@ -29,11 +31,15 @@ function writeIntentPackage( version: '1.0.0', intent: { version: 1, repo: 'owner/repo', docs: 'docs/' }, }) - mkdirSync(join(pkgDir, 'skills', skillName), { recursive: true }) - writeFileSync( - join(pkgDir, 'skills', skillName, 'SKILL.md'), - `---\nname: "${skillName}"\ndescription: "${name} ${skillName}"\n---\n\nContent.\n`, - ) + for (const skillName of Array.isArray(skillNames) + ? skillNames + : [skillNames]) { + mkdirSync(join(pkgDir, 'skills', skillName), { recursive: true }) + writeFileSync( + join(pkgDir, 'skills', skillName, 'SKILL.md'), + `---\nname: "${skillName}"\ndescription: "${name} ${skillName}"\nlibrary_version: "1.0.0"\n---\n\nContent.\n`, + ) + } } const LISTED = '@scope/listed' @@ -167,4 +173,77 @@ describe('source policy — all four surfaces filter excluded and unlisted', () fetchSpy.mockRestore() }) + + it('applies an exact selector across list, load, install map, stale, and hook catalogs', async () => { + writeJson(join(root, 'package.json'), { + name: 'monorepo', + private: true, + workspaces: ['packages/*'], + intent: { skills: [`${LISTED}#allowed`] }, + }) + writeJson(join(root, 'packages', 'app', 'package.json'), { + name: '@scope/app', + }) + writeIntentPackage(root, LISTED, ['allowed', 'hidden']) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-g4-global-'), + ) + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + + const listed = listIntentSkills({ cwd: root }) + expect(listed.skills.map((entry) => entry.use)).toEqual([ + `${LISTED}#allowed`, + ]) + expect(loadIntentSkill(`${LISTED}#allowed`, { cwd: root }).skillName).toBe( + 'allowed', + ) + expect(() => loadIntentSkill(`${LISTED}#hidden`, { cwd: root })).toThrow( + 'is not listed in intent.skills', + ) + + process.chdir(root) + expect(await main(['install', '--map', '--dry-run'])).toBe(0) + const installOutput = logSpy.mock.calls.flat().join('\n') + expect(installOutput).toContain(`id: "${LISTED}#allowed"`) + expect(installOutput).not.toContain(`id: "${LISTED}#hidden"`) + + const catalogPath = join(root, 'catalog.mjs') + const hookPath = join(root, 'intent-hook.mjs') + writeFileSync( + catalogPath, + `console.log(${JSON.stringify(JSON.stringify(listed))})\n`, + ) + writeFileSync( + hookPath, + buildHookRunnerScript( + 'claude', + `${JSON.stringify(process.execPath)} ${JSON.stringify(catalogPath)}`, + ), + ) + const hookResult = spawnSync(process.execPath, [hookPath], { + encoding: 'utf8', + input: JSON.stringify({ + cwd: root, + hook_event_name: 'SessionStart', + session_id: 'exact-selector', + }), + }) + const hookContext = JSON.parse(hookResult.stdout).hookSpecificOutput + .additionalContext as string + expect(hookContext).toContain(`${LISTED}#allowed`) + expect(hookContext).not.toContain(`${LISTED}#hidden`) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '2.0.0' }), + } as Response) + expect(await main(['stale', '--json'])).toBe(0) + const staleOutput = String(logSpy.mock.calls.at(-1)?.[0]) + const reports = JSON.parse(staleOutput) as Array<{ library: string }> + expect(reports.map((report) => report.library)).toEqual([LISTED]) + expect(staleOutput).not.toContain('hidden') + + fetchSpy.mockRestore() + rmSync(isolatedGlobalRoot, { recursive: true, force: true }) + }) }) diff --git a/packages/intent/tests/skill-sources.test.ts b/packages/intent/tests/skill-sources.test.ts index c170c182..d310241c 100644 --- a/packages/intent/tests/skill-sources.test.ts +++ b/packages/intent/tests/skill-sources.test.ts @@ -256,9 +256,56 @@ describe('parseSkillSources — wildcard composition', () => { }) describe('parseSkillSources — id validation', () => { - it('rejects skill-level granularity (#) in an npm entry', () => { - const error = expectParseError(['@scope/pkg#skill']) - expect(error.issues[0]?.message).toContain('skill-level granularity') + it('parses exact npm and workspace skill selectors', () => { + expect( + parseSkillSources([ + '@tanstack/query#fetching', + 'workspace:@scope/pkg#routing', + ]), + ).toEqual({ + mode: 'explicit', + sources: [ + { + raw: '@tanstack/query#fetching', + id: '@tanstack/query', + kind: 'npm', + skill: 'fetching', + }, + { + raw: 'workspace:@scope/pkg#routing', + id: '@scope/pkg', + kind: 'workspace', + skill: 'routing', + }, + ], + }) + }) + + it('rejects malformed exact selectors and reports every entry', () => { + const error = expectParseError([ + '#fetching', + '@tanstack/query#', + '@tanstack/query#fetching#extra', + '@tanstack/*#fetching', + 'workspace:@scope/pkg#*', + ]) + + expect(error.issues).toEqual([ + { raw: '#fetching', message: 'package name is empty.' }, + { raw: '@tanstack/query#', message: 'skill name is empty.' }, + { + raw: '@tanstack/query#fetching#extra', + message: 'skill names cannot contain "#".', + }, + { + raw: '@tanstack/*#fetching', + message: 'exact package selectors cannot contain "*".', + }, + { + raw: 'workspace:@scope/pkg#*', + message: 'exact skill selectors cannot contain "*".', + }, + ]) }) it('rejects internal whitespace in a package name', () => { diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 1f6e3554..8191d23f 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -234,6 +234,55 @@ describe('applySourcePolicy — allowlist matrix', () => { ) expect(input.skills.map((s) => s.name)).toEqual(['keep', 'drop']) }) + + it('permits only the named skill for an exact selector', () => { + const result = applySourcePolicy( + { packages: [pkg('@tanstack/query', ['fetching', 'mutations'])] }, + { + config: config(['@tanstack/query#fetching']), + excludeMatchers: [], + }, + ) + + expect(result.packages[0]?.skills.map((entry) => entry.name)).toEqual([ + 'fetching', + ]) + expect(result.hiddenSourceCount).toBe(0) + expect(result.notices).toEqual([]) + }) + + it('lets a package selector take precedence over exact selectors', () => { + const result = applySourcePolicy( + { packages: [pkg('@tanstack/query', ['fetching', 'mutations'])] }, + { + config: config(['@tanstack/query#fetching', '@tanstack/query']), + excludeMatchers: [], + }, + ) + + expect(result.packages[0]?.skills.map((entry) => entry.name)).toEqual([ + 'fetching', + 'mutations', + ]) + }) + + it('keeps exact workspace selectors kind-specific', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/pkg', ['routing'], 'npm'), + pkg('@scope/pkg', ['routing'], 'workspace'), + ], + }, + { + config: config(['workspace:@scope/pkg#routing']), + excludeMatchers: [], + }, + ) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]?.kind).toBe('workspace') + }) }) describe('applySourcePolicy — permit-all and empty modes', () => { @@ -345,6 +394,20 @@ describe('applySourcePolicy — exclude interaction', () => { expect(result.packages).toHaveLength(1) expect(result.packages[0]?.skills.map((s) => s.name)).toEqual(['keep']) }) + + it('applies skill excludes after exact selector permission', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['keep', 'drop'])] }, + { + config: config(['@scope/a#keep', '@scope/a#drop']), + excludeMatchers: compileExcludePatterns(['@scope/a#drop']), + }, + ) + + expect(result.packages[0]?.skills.map((entry) => entry.name)).toEqual([ + 'keep', + ]) + }) }) describe('applySourcePolicy — warning dedup', () => {