From da04db479bdd6462c8c644cf8cbf95824dd37e19 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 13 Jul 2026 19:59:50 -0700 Subject: [PATCH 1/2] feat(intent): support wildcard package patterns in allowlists and update documentation --- .changeset/calm-dingos-match.md | 5 ++ docs/cli/intent-list.md | 5 +- docs/concepts/configuration.md | 5 +- docs/concepts/trust-model.md | 4 +- docs/getting-started/quick-start-consumers.md | 4 +- packages/intent/src/core/excludes.ts | 15 ++++-- packages/intent/src/core/skill-sources.ts | 48 +++++++++++-------- packages/intent/src/core/source-policy.ts | 42 +++++++++------- .../source-policy-surfaces.test.ts | 18 +++++++ packages/intent/tests/skill-sources.test.ts | 21 ++++++-- packages/intent/tests/source-policy.test.ts | 47 ++++++++++++++++++ 11 files changed, 161 insertions(+), 53 deletions(-) create mode 100644 .changeset/calm-dingos-match.md diff --git a/.changeset/calm-dingos-match.md b/.changeset/calm-dingos-match.md new file mode 100644 index 00000000..359d8dad --- /dev/null +++ b/.changeset/calm-dingos-match.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Support `*` package patterns such as `@tanstack/*` in `intent.skills` allowlists. diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index ada58a1a..c1fe1e9a 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -109,15 +109,16 @@ Each entry is one source: - `@scope/pkg` or `pkg`: an npm package reachable through the dependency tree. - `workspace:@scope/pkg`: a package in the current workspace. +- `@scope/*` or `workspace:@scope/*`: every discovered package of that kind whose name matches the pattern. - `git:/#`: reserved, and not yet supported. The list as a whole has three special forms: - **Absent** (no `intent.skills` key): every discovered package is surfaced, with a deprecation notice printed to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist. - **Empty** (`"skills": []`): no package is surfaced, with an info notice printed to stderr. -- **Wildcard** (`"skills": ["*"]`): every discovered package is surfaced, with an acknowledged-risk notice printed to stderr. +- **Wildcard** (`"skills": ["*"]`): every discovered package is surfaced, with an acknowledged-risk notice printed to stderr. This exact trust-all entry is distinct from a scoped package pattern such as `@tanstack/*`. -A package that ships skills but is not listed is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. In agent sessions, hidden sources are reported by count only; run `intent list --show-hidden` outside the agent session to review candidates. A listed package that was not discovered is reported as well. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). +A package that ships skills but is not listed or matched by a pattern is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. In agent sessions, hidden sources are reported by count only; run `intent list --show-hidden` outside the agent session to review candidates. An exact entry or pattern that matches no discovered package is reported as well. Package patterns support `*` wildcards. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). ## Excludes diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 60fbeaf0..8b9d27ad 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -28,9 +28,10 @@ Each array entry names one source: | ----- | ---- | ------- | | `@scope/pkg` or `pkg` | npm | A package reachable through the dependency tree, direct or transitive. | | `workspace:@scope/pkg` | workspace | A package in the current workspace. | +| `@scope/*` or `workspace:@scope/*` | npm or workspace | Every discovered package of that kind 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. Intent currently matches an allowlist entry against a discovered package by name. This matching will tighten in a future version. +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 allowlist entries against discovered package names. This matching will tighten in a future version. ### Special forms @@ -38,7 +39,7 @@ The list as a whole has three special forms: - **Absent.** No `intent.skills` key. Every discovered package is surfaced, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist. - **Empty.** `"skills": []`. No package is surfaced. Intent prints an info notice to stderr. -- **Wildcard.** `"skills": ["*"]`. Every discovered package is surfaced. Intent prints an acknowledged-risk notice to stderr, since unvetted skills may reach your agent. +- **Wildcard.** `"skills": ["*"]`. Every discovered package is surfaced. Unlike a package pattern such as `@tanstack/*`, this exact entry crosses package scopes and source kinds. Intent prints an acknowledged-risk notice to stderr, since unvetted skills may reach your agent. A package that ships skills but is not listed is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. A listed package that was not discovered is reported as well. diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index b8fba139..65ea0b4b 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -9,11 +9,11 @@ Intent surfaces skills from your dependencies into your coding agent's guidance. A package ships skills in a `skills/` directory. Discovery finds every installed package that has one, including transitive dependencies. Discovery does not grant trust. -`package.json#intent.skills` is the gate. A discovered package contributes skills only when it appears in the allowlist. An unlisted package is dropped, and Intent reports it so you can opt in or ignore it. +`package.json#intent.skills` is the gate. A discovered package contributes skills only when an exact entry or `*` pattern in the allowlist matches it. An unlisted package is dropped, and Intent reports it so you can opt in or ignore it. The gate is opt-in today. A project with no `intent.skills` key still surfaces every discovered package, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. A future version will require an explicit allowlist. See the [special forms](./configuration#special-forms) in Configuration. -Trust does not propagate. A listed package may depend on another package that ships skills, but that dependency stays unlisted until you add it to `intent.skills` yourself. You allow each source on its own. +Trust does not propagate. A listed package may depend on another package that ships skills, but that dependency stays unlisted unless another entry matches it. Exact entries allow one source; patterns such as `@tanstack/*` explicitly allow every matching source. ## Static discovery diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index c801f77e..374742a8 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -65,12 +65,12 @@ Hooks add the available Intent skill catalog to supported agent sessions and kee ```json { "intent": { - "skills": ["@tanstack/query", "@tanstack/router"] + "skills": ["@tanstack/*"] } } ``` -List the packages you trust. Intent then surfaces skills from those packages and leaves the rest out. See the [source entries](../concepts/configuration#source-entries) in Configuration for the forms an entry can take, and [Trust model](../concepts/trust-model) for why the allowlist exists. +List the packages or `*` package patterns you trust. Intent then surfaces skills from matching packages and leaves the rest out. See the [source entries](../concepts/configuration#source-entries) in Configuration for the forms an entry can take, and [Trust model](../concepts/trust-model) for why the allowlist exists. ## 3. Use skills in your workflow diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 05ddfba8..81b3e3e7 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -91,7 +91,9 @@ function globToRegExp(pattern: string): RegExp { return new RegExp(`^${source}$`) } -function compileSegment(segment: string): (value: string) => boolean { +export function compileWildcardPattern( + segment: string, +): (value: string) => boolean { if (!segment.includes('*')) { return (value) => value === segment } @@ -108,20 +110,23 @@ export function compileExcludePatterns( const hashIndex = pattern.indexOf('#') if (hashIndex === -1) { - return { pattern, matchesPackage: compileSegment(pattern) } + return { pattern, matchesPackage: compileWildcardPattern(pattern) } } const packageSegment = pattern.slice(0, hashIndex) const skillSegment = pattern.slice(hashIndex + 1) if (skillSegment.replace(/\*+/g, '*') === '*') { - return { pattern, matchesPackage: compileSegment(packageSegment) } + return { + pattern, + matchesPackage: compileWildcardPattern(packageSegment), + } } return { pattern, - matchesPackage: compileSegment(packageSegment), - matchesSkill: compileSegment(skillSegment), + matchesPackage: compileWildcardPattern(packageSegment), + matchesSkill: compileWildcardPattern(skillSegment), } }) } diff --git a/packages/intent/src/core/skill-sources.ts b/packages/intent/src/core/skill-sources.ts index b370c250..303fc4b4 100644 --- a/packages/intent/src/core/skill-sources.ts +++ b/packages/intent/src/core/skill-sources.ts @@ -2,13 +2,16 @@ // resolves, requires, or executes any discovered package. /** - * `kind` + `id` is the identity M2's lockfile reuses; `ref` exists only on - * `git`. The `git` variant is never constructed in M1 (git entries are rejected - * at parse time) but is defined here so M2 builds on this shape. + * Exact entries keep the `kind` + `id` identity M2's lockfile reuses. Patterns + * select multiple discovered identities and remain distinct from exact entries. + * The `git` variant is never constructed in M1 (git entries are rejected at + * parse time) but is defined here so M2 builds on this shape. */ type SkillSource = - | { raw: string; id: string; kind: 'npm' } - | { raw: string; id: string; kind: 'workspace' } + | ({ raw: string; kind: 'npm' | 'workspace' } & ( + | { id: string } + | { pattern: string } + )) | { raw: string; id: string; kind: 'git'; ref: string } /** @@ -94,19 +97,17 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig { continue } - // The wildcard is a trust-all switch, so it must be the exact string `"*"`. - // Any other entry containing `*` (whitespace-wrapped, or a glob like - // `@scope/*`) is rejected rather than silently flipping to allow-all or - // becoming a bogus source — `intent.skills` is not glob-matched. - if (entry.includes('*')) { - if (entry === '*') { - allowAll = true - continue - } + if (entry === '*') { + allowAll = true + continue + } + + // Only the exact raw entry is the trust-all switch. Whitespace must not + // turn a package pattern into allow-all after normalization. + if (trimmed === '*') { issues.push({ raw: entry, - message: - 'The "*" wildcard must be the exact entry "*"; globs are not supported in intent.skills.', + message: 'The "*" wildcard must be the exact entry "*".', }) continue } @@ -117,7 +118,8 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig { continue } - const identity = `${parsed.kind}\u0000${parsed.id}` + const selector = 'pattern' in parsed ? parsed.pattern : parsed.id + const identity = `${parsed.kind}\u0000${selector}` if (seenIdentity.has(identity)) continue seenIdentity.add(identity) sources.push(parsed) @@ -145,7 +147,7 @@ function parseEntry( const invalid = validateId(trimmed) if (invalid) return { raw, message: `Invalid npm source "${trimmed}": ${invalid}` } - return { raw, id: trimmed, kind: 'npm' } + return packageSource(raw, trimmed, 'npm') } const prefix = trimmed.slice(0, colon) @@ -166,7 +168,7 @@ function parseEntry( message: `Invalid workspace source "${trimmed}": ${invalid}`, } } - return { raw, id: rest, kind: 'workspace' } + return packageSource(raw, rest, 'workspace') } case 'git': return { @@ -181,6 +183,14 @@ function parseEntry( } } +function packageSource( + raw: string, + id: string, + kind: 'npm' | 'workspace', +): SkillSource { + return id.includes('*') ? { raw, pattern: id, kind } : { raw, id, kind } +} + 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.' diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index e9525f86..ca7a366b 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -3,6 +3,7 @@ import { detectIntentAudience } from '../shared/environment.js' import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js' import { compileExcludePatterns, + compileWildcardPattern, getConfigDirs, getEffectiveExcludePatterns, isPackageExcluded, @@ -12,7 +13,6 @@ import { import { readPackageJson } from './package-json.js' import { parseSkillSources } from './skill-sources.js' import { resolveProjectContext } from './project-context.js' -import { sourceIdentityKey } from './types.js' import type { SkillUse } from '../skills/use.js' import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' import type { ExcludeMatcher } from './excludes.js' @@ -48,6 +48,23 @@ export interface LoadRefusal { message: string } +type ExplicitSkillSource = Extract< + SkillSourcesConfig, + { mode: 'explicit' } +>['sources'][number] + +function sourceMatchesPackage( + source: ExplicitSkillSource, + packageName: string, + packageKind?: 'npm' | 'workspace', +): boolean { + if (source.kind === 'git') return false + if (packageKind !== undefined && source.kind !== packageKind) return false + return 'pattern' in source + ? compileWildcardPattern(source.pattern)(packageName) + : source.id === packageName +} + export function isSourcePermitted( config: SkillSourcesConfig, packageName: string, @@ -60,10 +77,9 @@ export function isSourcePermitted( case 'empty': return false case 'explicit': - return config.sources.some((source) => { - if (source.id !== packageName) return false - return packageKind === undefined || source.kind === packageKind - }) + return config.sources.some((source) => + sourceMatchesPackage(source, packageName, packageKind), + ) } } @@ -180,20 +196,10 @@ export function applySourcePolicy( } if (config.mode === 'explicit') { - const discoveredKeys = new Set( - scanResult.packages.map((pkg) => - sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), - ), - ) for (const source of config.sources) { - // git sources can't appear in config yet (parseSkillSources rejects them), - // and IntentPackage.kind excludes 'git', so treat as always-not-discovered - // until git discovery lands — revisit this line then. - const notDiscovered = - source.kind === 'git' || - !discoveredKeys.has( - sourceIdentityKey({ kind: source.kind, id: source.id }), - ) + const notDiscovered = !scanResult.packages.some((pkg) => + sourceMatchesPackage(source, pkg.name, pkg.kind), + ) if (notDiscovered) { emit( `"${source.raw}" is declared in intent.skills but was not discovered.`, diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 62417361..5bbede6f 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -87,6 +87,24 @@ describe('source policy — all four surfaces filter excluded and unlisted', () ) }) + it('list and load accept packages matched by an allowlist glob', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@scope/*'] }, + }) + writeIntentPackage(root, LISTED, 'core') + writeIntentPackage(root, UNLISTED, 'core') + writeIntentPackage(root, '@other/hidden', 'core') + + const listed = listIntentSkills({ cwd: root }) + + expect(listed.packages.map((pkg) => pkg.name)).toEqual([LISTED, UNLISTED]) + expect(loadIntentSkill(`${UNLISTED}#core`, { cwd: root }).packageName).toBe( + UNLISTED, + ) + }) + it('load refuses the unlisted and excluded packages but allows the listed one', () => { writeStandaloneFixture() diff --git a/packages/intent/tests/skill-sources.test.ts b/packages/intent/tests/skill-sources.test.ts index 88169597..d80ec2cf 100644 --- a/packages/intent/tests/skill-sources.test.ts +++ b/packages/intent/tests/skill-sources.test.ts @@ -229,9 +229,24 @@ describe('parseSkillSources — wildcard composition', () => { expect(error.issues[0]?.message).toContain('must be the exact entry "*"') }) - it('rejects a glob in a package segment', () => { - const error = expectParseError(['@scope/*']) - expect(error.issues[0]?.message).toContain('globs are not supported') + it('parses a glob in an npm package name', () => { + expect(parseSkillSources(['@scope/*'])).toEqual({ + mode: 'explicit', + sources: [{ raw: '@scope/*', pattern: '@scope/*', kind: 'npm' }], + }) + }) + + it('parses a glob in a workspace package name', () => { + expect(parseSkillSources(['workspace:@scope/*'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: 'workspace:@scope/*', + pattern: '@scope/*', + kind: 'workspace', + }, + ], + }) }) it('still throws a duplicate error even when "*" subsumes the duplicated entry', () => { diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 122aef97..b3af57f4 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -59,6 +59,53 @@ describe('applySourcePolicy — allowlist matrix', () => { expect(result.notices).toEqual([]) }) + it('includes every package matched by an npm allowlist glob', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@tanstack/query', ['query']), + pkg('@tanstack/router', ['router']), + pkg('@other/package', ['other']), + ], + }, + { config: config(['@tanstack/*']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual([ + '@tanstack/query', + '@tanstack/router', + ]) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @other/package. Add to opt in.', + ]) + }) + + it('keeps workspace allowlist globs kind-specific', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/workspace', ['workspace'], 'workspace'), + pkg('@scope/npm', ['npm']), + ], + }, + { config: config(['workspace:@scope/*']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/workspace']) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @scope/npm. Add to opt in.', + ]) + }) + + it('warns when an allowlist glob matches no discovered package', () => { + const result = applySourcePolicy( + { packages: [pkg('@other/package', ['other'])] }, + { config: config(['@tanstack/*']), excludeMatchers: [] }, + ) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @other/package. Add to opt in.', + '"@tanstack/*" is declared in intent.skills but was not discovered.', + ]) + }) + it('drops an unlisted discovered package and warns', () => { const result = applySourcePolicy( { packages: [pkg('@scope/a', ['x']), pkg('@scope/b', ['y'])] }, From 171dd060b7a15ec593f41ec960a7e6c75a06c676 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 13 Jul 2026 20:33:49 -0700 Subject: [PATCH 2/2] feat(intent): enhance source policy matching with wildcard support and add tests --- packages/intent/src/core/source-policy.ts | 78 ++++++++++++++------- packages/intent/tests/source-policy.test.ts | 37 +++++++++- 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index ca7a366b..929fd034 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -53,36 +53,65 @@ type ExplicitSkillSource = Extract< { mode: 'explicit' } >['sources'][number] -function sourceMatchesPackage( +interface SkillSourceMatcher { + source: ExplicitSkillSource + matchesPackage: ( + packageName: string, + packageKind?: 'npm' | 'workspace', + ) => boolean +} + +function compileSkillSourceMatcher( source: ExplicitSkillSource, - packageName: string, - packageKind?: 'npm' | 'workspace', -): boolean { - if (source.kind === 'git') return false - if (packageKind !== undefined && source.kind !== packageKind) return false - return 'pattern' in source - ? compileWildcardPattern(source.pattern)(packageName) - : source.id === packageName +): SkillSourceMatcher { + if (source.kind === 'git') { + return { source, matchesPackage: () => false } + } + + const matchesName = + 'pattern' in source + ? compileWildcardPattern(source.pattern) + : (packageName: string) => source.id === packageName + + return { + source, + matchesPackage: (packageName, packageKind) => + (packageKind === undefined || source.kind === packageKind) && + matchesName(packageName), + } } -export function isSourcePermitted( - config: SkillSourcesConfig, - packageName: string, - packageKind?: 'npm' | 'workspace', -): boolean { +function compileSkillSourcePolicy(config: SkillSourcesConfig): { + matchers: Array + permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean +} { switch (config.mode) { case 'absent': case 'allow-all': - return true + return { matchers: [], permits: () => true } case 'empty': - return false - case 'explicit': - return config.sources.some((source) => - sourceMatchesPackage(source, packageName, packageKind), - ) + return { matchers: [], permits: () => false } + case 'explicit': { + const matchers = config.sources.map(compileSkillSourceMatcher) + return { + matchers, + permits: (packageName, packageKind) => + matchers.some((matcher) => + matcher.matchesPackage(packageName, packageKind), + ), + } + } } } +export function isSourcePermitted( + config: SkillSourcesConfig, + packageName: string, + packageKind?: 'npm' | 'workspace', +): boolean { + return compileSkillSourcePolicy(config).permits(packageName, packageKind) +} + export function packageNotListedRefusal( use: string, packageName: string, @@ -161,6 +190,7 @@ export function applySourcePolicy( ): SourcePolicyResult { const { config, excludeMatchers } = options const audience = options.audience ?? 'human' + const sourcePolicy = compileSkillSourcePolicy(config) const seen = new Set() const notices: Array = [] @@ -176,7 +206,7 @@ export function applySourcePolicy( for (const pkg of scanResult.packages) { if (isPackageExcluded(pkg.name, excludeMatchers)) continue - if (!isSourcePermitted(config, pkg.name, pkg.kind)) { + if (!sourcePolicy.permits(pkg.name, pkg.kind)) { if (config.mode === 'explicit') { hiddenSources.push({ name: pkg.name, skillCount: pkg.skills.length }) } @@ -196,13 +226,13 @@ export function applySourcePolicy( } if (config.mode === 'explicit') { - for (const source of config.sources) { + for (const matcher of sourcePolicy.matchers) { const notDiscovered = !scanResult.packages.some((pkg) => - sourceMatchesPackage(source, pkg.name, pkg.kind), + matcher.matchesPackage(pkg.name, pkg.kind), ) if (notDiscovered) { emit( - `"${source.raw}" is declared in intent.skills but was not discovered.`, + `"${matcher.source.raw}" is declared in intent.skills but was not discovered.`, ) } } diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index b3af57f4..1f6e3554 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -7,8 +7,11 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { compileExcludePatterns } from '../src/core/excludes.js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + compileExcludePatterns, + compileWildcardPattern, +} from '../src/core/excludes.js' import { ALLOW_ALL_NOTICE, EMPTY_NOTE, @@ -17,8 +20,17 @@ import { readSkillSourcesConfig, } from '../src/core/source-policy.js' import { parseSkillSources } from '../src/core/skill-sources.js' +import type * as Excludes from '../src/core/excludes.js' import type { IntentPackage, SkillEntry } from '../src/shared/types.js' +vi.mock('../src/core/excludes.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + compileWildcardPattern: vi.fn(actual.compileWildcardPattern), + } +}) + const realTmpdir = realpathSync(tmpdir()) function skill(name: string): SkillEntry { @@ -106,6 +118,27 @@ describe('applySourcePolicy — allowlist matrix', () => { ]) }) + it('compiles each allowlist glob once per policy application', () => { + const compile = vi.mocked(compileWildcardPattern) + compile.mockClear() + + applySourcePolicy( + { + packages: [ + pkg('@scope/one', ['one']), + pkg('@scope/two', ['two']), + pkg('@other/three', ['three']), + ], + }, + { + config: config(['@scope/*', '@other/*']), + excludeMatchers: [], + }, + ) + + expect(compile).toHaveBeenCalledTimes(2) + }) + it('drops an unlisted discovered package and warns', () => { const result = applySourcePolicy( { packages: [pkg('@scope/a', ['x']), pkg('@scope/b', ['y'])] },