Skip to content

Commit 78b77b1

Browse files
feat(intent): support per-skill source selectors (#224)
* feat(intent): support per-skill source selectors * ci: apply automated fixes * docs(intent): restore selector examples --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 7b6e7cd commit 78b77b1

9 files changed

Lines changed: 312 additions & 31 deletions

File tree

.changeset/calm-skills-select.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/intent': minor
3+
---
4+
5+
Add exact npm and workspace skill selectors to `intent.skills`.

docs/concepts/configuration.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Intent reads consumer configuration from the `intent` object in `package.json`.
88
```json
99
{
1010
"intent": {
11-
"skills": ["@tanstack/query", "workspace:@scope/internal"],
11+
"skills": ["@tanstack/query", "@acme/*", "@tanstack/start#routing", "workspace:@scope/internal"],
1212
"exclude": ["@tanstack/router#experimental-*"]
1313
}
1414
}
@@ -21,15 +21,15 @@ Intent reads consumer configuration from the `intent` object in `package.json`.
2121

2222
## `intent.skills`
2323

24-
`intent.skills` is a package-source allowlist. A permitted package can:
24+
`intent.skills` is a package-source and skill allowlist. A permitted package or skill can:
2525

2626
- Appear in `list` and `stale`.
2727
- Resolve through `load`.
2828
- Contribute mappings to `install --map`.
2929

3030
The default `install` command writes generic loading guidance without scanning packages. See [Trust model](./trust-model) for the reasoning and lifecycle boundaries.
3131

32-
The allowlist permits packages, not individual skills. An entry containing `#` is invalid; use `intent.exclude` for skill-specific filtering.
32+
Package selectors permit every skill in the package. Exact selectors use `<package>#<skill>` 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.
3333

3434
### Source entries
3535

@@ -38,12 +38,16 @@ Each array entry names one source:
3838
| Entry | Kind | Meaning |
3939
| ----- | ---- | ------- |
4040
| `@scope/pkg` or `pkg` | npm | An npm package reachable through the dependency tree, direct or transitive. |
41+
| `@scope/pkg#skill` | npm | One exact skill in an npm package. |
4142
| `workspace:@scope/pkg` | workspace | A package in the current workspace. |
43+
| `workspace:@scope/pkg#skill` | workspace | One exact skill in a workspace package. |
4244
| `@scope/*` | npm | Every discovered npm package whose name matches the pattern. |
4345
| `workspace:@scope/*` | workspace | Every discovered workspace package whose name matches the pattern. |
4446
| `git:<host>/<repo>#<ref>` | git | Reserved. Not yet supported, and rejected until a future version adds it. |
4547

46-
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.
48+
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.
49+
50+
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.
4751

4852
### Special forms
4953

packages/intent/src/commands/stale.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
import { resolve } from 'node:path'
2+
import {
3+
compileExcludePatterns,
4+
getEffectiveExcludePatterns,
5+
isSkillExcluded,
6+
} from '../core/excludes.js'
7+
import { resolveProjectContext } from '../core/project-context.js'
8+
import {
9+
isSourcePermitted,
10+
readSkillSourcesConfig,
11+
} from '../core/source-policy.js'
112
import { isCliFailure } from '../shared/cli-error.js'
213
import type { StalenessReport } from '../shared/types.js'
314

@@ -20,8 +31,9 @@ export async function runStaleCommand(
2031
return
2132
}
2233

23-
const { reports, workflowAdvisories = [] } =
34+
const { reports: unfilteredReports, workflowAdvisories = [] } =
2435
await resolveStaleTargets(targetDir)
36+
const reports = filterStaleReportSkills(unfilteredReports, targetDir)
2537

2638
if (options.json) {
2739
console.log(JSON.stringify(reports, null, 2))
@@ -91,8 +103,9 @@ async function runGithubReview(
91103
const packageLabel = options.packageLabel ?? 'workspace'
92104

93105
try {
94-
const { reports, workflowAdvisories = [] } =
106+
const { reports: unfilteredReports, workflowAdvisories = [] } =
95107
await resolveStaleTargets(targetDir)
108+
const reports = filterStaleReportSkills(unfilteredReports, targetDir)
96109
const items = [
97110
...collectStaleReviewItems(reports),
98111
...createWorkflowAdvisoryReviewItems(packageLabel, workflowAdvisories),
@@ -115,3 +128,24 @@ async function runGithubReview(
115128
console.log('Wrote a review PR body so maintainers can inspect the logs.')
116129
}
117130
}
131+
132+
function filterStaleReportSkills(
133+
reports: Array<StalenessReport>,
134+
targetDir: string | undefined,
135+
): Array<StalenessReport> {
136+
const cwd = resolve(process.cwd(), targetDir ?? process.cwd())
137+
const context = resolveProjectContext({ cwd })
138+
const config = readSkillSourcesConfig(cwd, context)
139+
const excludeMatchers = compileExcludePatterns(
140+
getEffectiveExcludePatterns({}, context),
141+
)
142+
143+
return reports.map((report) => ({
144+
...report,
145+
skills: report.skills.filter(
146+
(skill) =>
147+
isSourcePermitted(config, report.library, undefined, skill.name) &&
148+
!isSkillExcluded(report.library, skill.name, excludeMatchers),
149+
),
150+
}))
151+
}

packages/intent/src/core/intent-core.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,12 @@ function resolveIntentSkillInCwd(
303303
)
304304
if (fastPathResolved) {
305305
if (
306-
!isSourcePermitted(config, parsedUse.packageName, fastPathResolved.kind)
306+
!isSourcePermitted(
307+
config,
308+
parsedUse.packageName,
309+
fastPathResolved.kind,
310+
parsedUse.skillName,
311+
)
307312
) {
308313
const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName)
309314
throw new IntentCoreError(lateRefusal.code, lateRefusal.message)

packages/intent/src/core/skill-sources.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
type SkillSource =
55
| ({ raw: string; kind: 'npm' | 'workspace' } & (
6-
{ id: string } | { pattern: string }
6+
{ id: string; skill?: string } | { pattern: string }
77
))
88
| { raw: string; id: string; kind: 'git'; ref: string }
99

@@ -112,7 +112,8 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig {
112112
}
113113

114114
const selector = 'pattern' in parsed ? parsed.pattern : parsed.id
115-
const identity = `${parsed.kind}\u0000${selector}`
115+
const skill = 'skill' in parsed ? parsed.skill : undefined
116+
const identity = `${parsed.kind}\u0000${selector}\u0000${skill ?? ''}`
116117
if (seenIdentity.has(identity)) continue
117118
seenIdentity.add(identity)
118119
sources.push(parsed)
@@ -178,16 +179,35 @@ function parseEntry(
178179

179180
function packageSource(
180181
raw: string,
181-
id: string,
182+
selector: string,
182183
kind: 'npm' | 'workspace',
183-
): SkillSource {
184-
return id.includes('*') ? { raw, pattern: id, kind } : { raw, id, kind }
184+
): SkillSource | SkillSourceIssue {
185+
const hashIndex = selector.indexOf('#')
186+
if (hashIndex === -1) {
187+
const invalid = validateId(selector)
188+
if (invalid) return { raw, message: invalid }
189+
return selector.includes('*')
190+
? { raw, pattern: selector, kind }
191+
: { raw, id: selector, kind }
192+
}
193+
194+
const id = selector.slice(0, hashIndex)
195+
const skill = selector.slice(hashIndex + 1)
196+
const invalidId = validateId(id)
197+
if (invalidId) return { raw, message: invalidId }
198+
if (id.includes('*')) {
199+
return {
200+
raw,
201+
message: 'exact package selectors cannot contain "*".',
202+
}
203+
}
204+
const invalidSkill = validateSkill(skill)
205+
if (invalidSkill) return { raw, message: invalidSkill }
206+
return { raw, id, kind, skill }
185207
}
186208

187209
function validateId(id: string): string | null {
188-
if (id.includes('#')) {
189-
return 'skill-level granularity (#) is not supported in intent.skills (it is package-level); use intent.exclude for skill-level control.'
190-
}
210+
if (id === '') return 'package name is empty.'
191211
if (/\s/.test(id)) {
192212
return 'package names cannot contain whitespace.'
193213
}
@@ -197,6 +217,14 @@ function validateId(id: string): string | null {
197217
return null
198218
}
199219

220+
function validateSkill(skill: string): string | null {
221+
if (skill === '') return 'skill name is empty.'
222+
if (/\s/.test(skill)) return 'skill names cannot contain whitespace.'
223+
if (skill.includes('#')) return 'skill names cannot contain "#".'
224+
if (skill.includes('*')) return 'exact skill selectors cannot contain "*".'
225+
return null
226+
}
227+
200228
function describeType(value: unknown): string {
201229
if (value === null) return 'null'
202230
return Array.isArray(value) ? 'array' : typeof value

packages/intent/src/core/source-policy.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ function compileSkillSourceMatcher(
8181

8282
function compileSkillSourcePolicy(config: SkillSourcesConfig): {
8383
matchers: Array<SkillSourceMatcher>
84-
permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean
84+
permits: (
85+
packageName: string,
86+
packageKind?: 'npm' | 'workspace',
87+
skillName?: string,
88+
) => boolean
8589
} {
8690
switch (config.mode) {
8791
case 'absent':
@@ -93,9 +97,14 @@ function compileSkillSourcePolicy(config: SkillSourcesConfig): {
9397
const matchers = config.sources.map(compileSkillSourceMatcher)
9498
return {
9599
matchers,
96-
permits: (packageName, packageKind) =>
97-
matchers.some((matcher) =>
98-
matcher.matchesPackage(packageName, packageKind),
100+
permits: (packageName, packageKind, skillName) =>
101+
matchers.some(
102+
(matcher) =>
103+
matcher.matchesPackage(packageName, packageKind) &&
104+
(!('skill' in matcher.source) ||
105+
matcher.source.skill === undefined ||
106+
skillName === undefined ||
107+
matcher.source.skill === skillName),
99108
),
100109
}
101110
}
@@ -106,8 +115,13 @@ export function isSourcePermitted(
106115
config: SkillSourcesConfig,
107116
packageName: string,
108117
packageKind?: 'npm' | 'workspace',
118+
skillName?: string,
109119
): boolean {
110-
return compileSkillSourcePolicy(config).permits(packageName, packageKind)
120+
return compileSkillSourcePolicy(config).permits(
121+
packageName,
122+
packageKind,
123+
skillName,
124+
)
111125
}
112126

113127
export function packageNotListedRefusal(
@@ -141,7 +155,7 @@ export function checkLoadAllowed(
141155
// Name-only pre-check: kind isn't known yet at this point in the load path.
142156
// A late, kind-aware isSourcePermitted call happens once resolution reveals
143157
// the actual kind (see intent-core.ts).
144-
if (!isSourcePermitted(config, packageName)) {
158+
if (!isSourcePermitted(config, packageName, undefined, skillName)) {
145159
return packageNotListedRefusal(use, packageName)
146160
}
147161

@@ -212,7 +226,9 @@ export function applySourcePolicy(
212226
}
213227

214228
const skills = pkg.skills.filter(
215-
(skill) => !isSkillExcluded(pkg.name, skill.name, excludeMatchers),
229+
(skill) =>
230+
sourcePolicy.permits(pkg.name, pkg.kind, skill.name) &&
231+
!isSkillExcluded(pkg.name, skill.name, excludeMatchers),
216232
)
217233
packages.push(
218234
skills.length === pkg.skills.length ? pkg : { ...pkg, skills },

packages/intent/tests/integration/source-policy-surfaces.test.ts

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import {
55
rmSync,
66
writeFileSync,
77
} from 'node:fs'
8+
import { spawnSync } from 'node:child_process'
89
import { tmpdir } from 'node:os'
910
import { dirname, join } from 'node:path'
1011
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1112
import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js'
1213
import { main } from '../../src/cli.js'
14+
import { buildHookRunnerScript } from '../../src/hooks/install.js'
1315

1416
const realTmpdir = realpathSync(tmpdir())
1517

@@ -21,19 +23,23 @@ function writeJson(filePath: string, data: unknown): void {
2123
function writeIntentPackage(
2224
baseDir: string,
2325
name: string,
24-
skillName: string,
26+
skillNames: string | Array<string>,
2527
): void {
2628
const pkgDir = join(baseDir, 'node_modules', ...name.split('/'))
2729
writeJson(join(pkgDir, 'package.json'), {
2830
name,
2931
version: '1.0.0',
3032
intent: { version: 1, repo: 'owner/repo', docs: 'docs/' },
3133
})
32-
mkdirSync(join(pkgDir, 'skills', skillName), { recursive: true })
33-
writeFileSync(
34-
join(pkgDir, 'skills', skillName, 'SKILL.md'),
35-
`---\nname: "${skillName}"\ndescription: "${name} ${skillName}"\n---\n\nContent.\n`,
36-
)
34+
for (const skillName of Array.isArray(skillNames)
35+
? skillNames
36+
: [skillNames]) {
37+
mkdirSync(join(pkgDir, 'skills', skillName), { recursive: true })
38+
writeFileSync(
39+
join(pkgDir, 'skills', skillName, 'SKILL.md'),
40+
`---\nname: "${skillName}"\ndescription: "${name} ${skillName}"\nlibrary_version: "1.0.0"\n---\n\nContent.\n`,
41+
)
42+
}
3743
}
3844

3945
const LISTED = '@scope/listed'
@@ -167,4 +173,77 @@ describe('source policy — all four surfaces filter excluded and unlisted', ()
167173

168174
fetchSpy.mockRestore()
169175
})
176+
177+
it('applies an exact selector across list, load, install map, stale, and hook catalogs', async () => {
178+
writeJson(join(root, 'package.json'), {
179+
name: 'monorepo',
180+
private: true,
181+
workspaces: ['packages/*'],
182+
intent: { skills: [`${LISTED}#allowed`] },
183+
})
184+
writeJson(join(root, 'packages', 'app', 'package.json'), {
185+
name: '@scope/app',
186+
})
187+
writeIntentPackage(root, LISTED, ['allowed', 'hidden'])
188+
const isolatedGlobalRoot = mkdtempSync(
189+
join(realTmpdir, 'intent-g4-global-'),
190+
)
191+
process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot
192+
193+
const listed = listIntentSkills({ cwd: root })
194+
expect(listed.skills.map((entry) => entry.use)).toEqual([
195+
`${LISTED}#allowed`,
196+
])
197+
expect(loadIntentSkill(`${LISTED}#allowed`, { cwd: root }).skillName).toBe(
198+
'allowed',
199+
)
200+
expect(() => loadIntentSkill(`${LISTED}#hidden`, { cwd: root })).toThrow(
201+
'is not listed in intent.skills',
202+
)
203+
204+
process.chdir(root)
205+
expect(await main(['install', '--map', '--dry-run'])).toBe(0)
206+
const installOutput = logSpy.mock.calls.flat().join('\n')
207+
expect(installOutput).toContain(`id: "${LISTED}#allowed"`)
208+
expect(installOutput).not.toContain(`id: "${LISTED}#hidden"`)
209+
210+
const catalogPath = join(root, 'catalog.mjs')
211+
const hookPath = join(root, 'intent-hook.mjs')
212+
writeFileSync(
213+
catalogPath,
214+
`console.log(${JSON.stringify(JSON.stringify(listed))})\n`,
215+
)
216+
writeFileSync(
217+
hookPath,
218+
buildHookRunnerScript(
219+
'claude',
220+
`${JSON.stringify(process.execPath)} ${JSON.stringify(catalogPath)}`,
221+
),
222+
)
223+
const hookResult = spawnSync(process.execPath, [hookPath], {
224+
encoding: 'utf8',
225+
input: JSON.stringify({
226+
cwd: root,
227+
hook_event_name: 'SessionStart',
228+
session_id: 'exact-selector',
229+
}),
230+
})
231+
const hookContext = JSON.parse(hookResult.stdout).hookSpecificOutput
232+
.additionalContext as string
233+
expect(hookContext).toContain(`${LISTED}#allowed`)
234+
expect(hookContext).not.toContain(`${LISTED}#hidden`)
235+
236+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
237+
ok: true,
238+
json: () => Promise.resolve({ version: '2.0.0' }),
239+
} as Response)
240+
expect(await main(['stale', '--json'])).toBe(0)
241+
const staleOutput = String(logSpy.mock.calls.at(-1)?.[0])
242+
const reports = JSON.parse(staleOutput) as Array<{ library: string }>
243+
expect(reports.map((report) => report.library)).toEqual([LISTED])
244+
expect(staleOutput).not.toContain('hidden')
245+
246+
fetchSpy.mockRestore()
247+
rmSync(isolatedGlobalRoot, { recursive: true, force: true })
248+
})
170249
})

0 commit comments

Comments
 (0)