From 9828e110df712acc59b33207fbd14dfc5839cc24 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 18:42:57 -0700 Subject: [PATCH 1/5] feat: add SourceIdentity interface and related functions for distinct source handling --- packages/intent/src/core/types.ts | 18 ++++++++++ packages/intent/tests/core-types.test.ts | 45 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 packages/intent/tests/core-types.test.ts diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 7212a3c..cf89703 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -96,6 +96,24 @@ export interface LoadedIntentSkillDebug { scan: ScanStats } +// `npm:foo` and `workspace:foo` are distinct sources; name-only comparison +// would collapse them onto one lockfile entry/approval. +export interface SourceIdentity { + kind: 'npm' | 'workspace' + id: string +} + +export function sourceIdentityKey(s: SourceIdentity): string { + return `${s.kind}\u0000${s.id}` +} + +export function sourceIdentityEquals( + a: SourceIdentity, + b: SourceIdentity, +): boolean { + return a.kind === b.kind && a.id === b.id +} + export type IntentCoreErrorCode = | 'invalid-options' | 'invalid-skill-use' diff --git a/packages/intent/tests/core-types.test.ts b/packages/intent/tests/core-types.test.ts new file mode 100644 index 0000000..d8a238e --- /dev/null +++ b/packages/intent/tests/core-types.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { sourceIdentityEquals, sourceIdentityKey } from '../src/core/types.js' + +describe('sourceIdentityKey', () => { + it('distinguishes npm:foo from workspace:foo', () => { + expect(sourceIdentityKey({ kind: 'npm', id: 'foo' })).not.toBe( + sourceIdentityKey({ kind: 'workspace', id: 'foo' }), + ) + }) + + it('is stable for the same kind and id', () => { + expect(sourceIdentityKey({ kind: 'npm', id: 'foo' })).toBe( + sourceIdentityKey({ kind: 'npm', id: 'foo' }), + ) + }) +}) + +describe('sourceIdentityEquals', () => { + it('returns false when kind differs but id matches', () => { + expect( + sourceIdentityEquals( + { kind: 'npm', id: 'foo' }, + { kind: 'workspace', id: 'foo' }, + ), + ).toBe(false) + }) + + it('returns false when id differs but kind matches', () => { + expect( + sourceIdentityEquals( + { kind: 'npm', id: 'foo' }, + { kind: 'npm', id: 'bar' }, + ), + ).toBe(false) + }) + + it('returns true when kind and id both match', () => { + expect( + sourceIdentityEquals( + { kind: 'npm', id: 'foo' }, + { kind: 'npm', id: 'foo' }, + ), + ).toBe(true) + }) +}) From e0c6a0ddedb28644941068e7db8bd2f75fbeb798 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 18:48:21 -0700 Subject: [PATCH 2/5] feat: Enhance source policy checks with package kind support - Updated `isSourcePermitted` to accept an optional `packageKind` parameter, allowing for more granular source validation based on package type. - Modified the logic in `applySourcePolicy` to pass the package kind when checking source permissions. - Improved the discovery check for sources in explicit mode by utilizing a `sourceIdentityKey` function to create unique identifiers based on both package kind and name. - Added a comment in `excludes.ts` to clarify the intentional design choice regarding package exclusion handling. --- packages/intent/src/core/excludes.ts | 1 + packages/intent/src/core/source-policy.ts | 20 +++++++++--- packages/intent/tests/source-policy.test.ts | 35 +++++++++++++++++++-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 2eeb073..05ddfba 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -126,6 +126,7 @@ export function compileExcludePatterns( }) } +// Deliberately kind-agnostic, unlike the allowlist/lockfile — not a gap to close later. export function isPackageExcluded( packageName: string, matchers: Array, diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 2c1bf6a..c1498e6 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -12,6 +12,7 @@ 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' @@ -50,6 +51,7 @@ export interface LoadRefusal { function isSourcePermitted( config: SkillSourcesConfig, packageName: string, + packageKind?: 'npm' | 'workspace', ): boolean { switch (config.mode) { case 'absent': @@ -58,7 +60,10 @@ function isSourcePermitted( case 'empty': return false case 'explicit': - return config.sources.some((source) => source.id === packageName) + return config.sources.some((source) => { + if (source.id !== packageName) return false + return packageKind === undefined || source.kind === packageKind + }) } } @@ -145,7 +150,7 @@ export function applySourcePolicy( for (const pkg of scanResult.packages) { if (isPackageExcluded(pkg.name, excludeMatchers)) continue - if (!isSourcePermitted(config, pkg.name)) { + if (!isSourcePermitted(config, pkg.name, pkg.kind)) { if (config.mode === 'explicit') { hiddenSources.push({ name: pkg.name, skillCount: pkg.skills.length }) } @@ -165,9 +170,16 @@ export function applySourcePolicy( } if (config.mode === 'explicit') { - const discoveredNames = new Set(scanResult.packages.map((pkg) => pkg.name)) + const discoveredKeys = new Set( + scanResult.packages.map((pkg) => + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ), + ) for (const source of config.sources) { - if (!discoveredNames.has(source.id)) { + const notDiscovered = + source.kind === 'git' || + !discoveredKeys.has(sourceIdentityKey({ kind: source.kind, id: source.id })) + if (notDiscovered) { emit( `"${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 9fdef84..122aef9 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -25,14 +25,18 @@ function skill(name: string): SkillEntry { return { name, path: `/pkg/skills/${name}/SKILL.md`, description: name } } -function pkg(name: string, skillNames: Array): IntentPackage { +function pkg( + name: string, + skillNames: Array, + kind: IntentPackage['kind'] = 'npm', +): IntentPackage { return { name, version: '1.0.0', intent: { version: 1, repo: 'owner/repo', docs: '' }, skills: skillNames.map(skill), packageRoot: `/root/node_modules/${name}`, - kind: 'npm', + kind, source: 'local', } } @@ -93,11 +97,23 @@ describe('applySourcePolicy — allowlist matrix', () => { ]) }) - it('matches by name only, so workspace:foo authorizes an npm-discovered foo (M1 baseline)', () => { + it('does not authorize an npm-discovered foo via workspace:foo', () => { const result = applySourcePolicy( { packages: [pkg('foo', ['x'])] }, { config: config(['workspace:foo']), excludeMatchers: [] }, ) + expect(names(result.packages)).toEqual([]) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: foo. Add to opt in.', + '"workspace:foo" is declared in intent.skills but was not discovered.', + ]) + }) + + it('authorizes a workspace-discovered foo via workspace:foo', () => { + const result = applySourcePolicy( + { packages: [pkg('foo', ['x'], 'workspace')] }, + { config: config(['workspace:foo']), excludeMatchers: [] }, + ) expect(names(result.packages)).toEqual(['foo']) expect(result.notices).toEqual([]) }) @@ -141,6 +157,19 @@ describe('applySourcePolicy — allowlist matrix', () => { }) describe('applySourcePolicy — permit-all and empty modes', () => { + it('unqualified exclude hides both an npm and a workspace package of the same name (kind-agnostic, deliberate)', () => { + const result = applySourcePolicy( + { + packages: [pkg('foo', ['x'], 'npm'), pkg('foo', ['y'], 'workspace')], + }, + { + config: config(['*']), + excludeMatchers: compileExcludePatterns(['foo']), + }, + ) + expect(names(result.packages)).toEqual([]) + }) + it('permits every discovered source under allow-all with a loud notice', () => { const result = applySourcePolicy( { packages: [pkg('@scope/a', ['x']), pkg('@scope/b', ['y'])] }, From a22b7b7e871aeb8c6245fb93e55bfbd295a08a87 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 18:55:19 -0700 Subject: [PATCH 3/5] feat: Implement late gate for kind-mismatch package handling in intent skills --- packages/intent/src/core/intent-core.ts | 14 ++- packages/intent/src/core/load-resolution.ts | 11 +- packages/intent/src/core/source-policy.ts | 19 +++- packages/intent/tests/core.test.ts | 107 ++++++++++++++++++++ 4 files changed, 142 insertions(+), 9 deletions(-) diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index ac0e5e5..d24f7a9 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -11,6 +11,8 @@ import { resolveSkillUseFastPath } from './load-resolution.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, + isSourcePermitted, + packageNotListedRefusal, readSkillSourcesConfig, scanForPolicedIntents, } from './source-policy.js' @@ -300,6 +302,12 @@ function resolveIntentSkillInCwd( fsCache, ) if (fastPathResolved) { + if ( + !isSourcePermitted(config, parsedUse.packageName, fastPathResolved.kind) + ) { + const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) + throw new IntentCoreError(lateRefusal.code, lateRefusal.message) + } return toResolvedIntentSkill( cwd, use, @@ -318,12 +326,16 @@ function resolveIntentSkillInCwd( ) } - const { scan: scanResult } = scanForPolicedIntents({ + const { scan: scanResult, droppedNames } = scanForPolicedIntents({ cwd, scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, context: projectContext, }) + if (droppedNames.includes(parsedUse.packageName)) { + const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) + throw new IntentCoreError(lateRefusal.code, lateRefusal.message) + } let resolved: ReturnType try { resolved = resolveSkillUse(use, scanResult) diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index a2a47bf..b69dfc6 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -181,10 +181,14 @@ function getWorkspaceLoadFastPathCandidateDirs( return candidates } +export interface FastPathResolveResult extends ResolveSkillResult { + kind: 'npm' | 'workspace' +} + function resolveScannedPackageSkill( scanned: ReturnType, parsedUse: SkillUse, -): ResolveSkillResult | null { +): FastPathResolveResult | null { const pkg = scanned.package if (!pkg || pkg.name !== parsedUse.packageName) return null @@ -202,6 +206,7 @@ function resolveScannedPackageSkill( source: pkg.source, version: pkg.version, packageRoot: pkg.packageRoot, + kind: pkg.kind, warnings: scanned.warnings.filter((warning) => warningMentionsPackage(warning, pkg.name), ), @@ -214,7 +219,7 @@ function resolveFromPackageRoots( parsedUse: SkillUse, cwd: string, fsCache: IntentFsCache, -): ResolveSkillResult | null { +): FastPathResolveResult | null { for (const packageRoot of packageRoots) { const scanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, @@ -248,7 +253,7 @@ export function resolveSkillUseFastPath( context = resolveProjectContext({ cwd: process.cwd() }), cwd = context.cwd, fsCache = createIntentFsCache(), -): ResolveSkillResult | null { +): FastPathResolveResult | null { if (options.globalOnly) return null if (shouldSkipFastPathForYarnPnp(context, cwd)) return null diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index c1498e6..d8af810 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -48,7 +48,7 @@ export interface LoadRefusal { message: string } -function isSourcePermitted( +export function isSourcePermitted( config: SkillSourcesConfig, packageName: string, packageKind?: 'npm' | 'workspace', @@ -67,6 +67,16 @@ function isSourcePermitted( } } +export function packageNotListedRefusal( + use: string, + packageName: string, +): LoadRefusal { + return { + code: 'package-not-listed', + message: `Cannot load skill use "${use}": package "${packageName}" is not listed in intent.skills.`, + } +} + export function checkLoadAllowed( use: string, parsed: SkillUse, @@ -86,10 +96,7 @@ export function checkLoadAllowed( } if (!isSourcePermitted(config, packageName)) { - return { - code: 'package-not-listed', - message: `Cannot load skill use "${use}": package "${packageName}" is not listed in intent.skills.`, - } + return packageNotListedRefusal(use, packageName) } if (isSkillExcluded(packageName, skillName, excludeMatchers)) { @@ -224,6 +231,7 @@ export interface PolicedScan { hiddenSources: Array scan: ScanResult excludePatterns: Array + droppedNames: Array } export function scanForPolicedIntents(params: { @@ -268,5 +276,6 @@ export function scanForPolicedIntents(params: { ), }, excludePatterns, + droppedNames, } } diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 4b8800c..0989b1f 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -901,3 +901,110 @@ describe('loadIntentSkill', () => { expect(loaded.skillName).toBe('fetching') }) }) + +describe('loadIntentSkill — kind-mismatch late gate', () => { + it('refuses an npm-installed package listed only as workspace:, via the fast path', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['workspace:@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + let thrown: unknown + try { + loadIntentSkill('@tanstack/query#fetching', { cwd: root, debug: true }) + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(IntentCoreError) + expect((thrown as IntentCoreError).code).toBe('package-not-listed') + expect((thrown as Error).message).toBe( + 'Cannot load skill use "@tanstack/query#fetching": package "@tanstack/query" is not listed in intent.skills.', + ) + }) + + it('refuses an npm-installed package listed only as workspace:, via the full-scan fallback', () => { + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['workspace:@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + let thrown: unknown + try { + const result = loadIntentSkill('@tanstack/query#fetching', { + cwd: root, + debug: true, + }) + thrown = result + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(IntentCoreError) + expect((thrown as IntentCoreError).code).toBe('package-not-listed') + expect((thrown as Error).message).toBe( + 'Cannot load skill use "@tanstack/query#fetching": package "@tanstack/query" is not listed in intent.skills.', + ) + }) + + it('allows a workspace member listed as workspace:', () => { + const appDir = join(root, 'packages', 'app') + const routerDir = join(root, 'packages', 'router-core') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + intent: { skills: ['workspace:@tanstack/router-core'] }, + }) + writeJson(join(appDir, 'package.json'), { + name: '@test/app', + }) + writeJson(join(routerDir, 'package.json'), { + name: '@tanstack/router-core', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd({ + dir: join(routerDir, 'skills', 'core'), + frontmatter: { name: 'core', description: 'Router core' }, + }) + + const result = loadIntentSkill('@tanstack/router-core#core', { + cwd: appDir, + }) + + expect(result.packageName).toBe('@tanstack/router-core') + }) + + it('refuses an excluded, kind-mismatched package before any resolution attempt', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['workspace:@tanstack/query'], + exclude: ['@tanstack/query'], + }, + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": package "@tanstack/query" is excluded by Intent configuration.', + ) + }) +}) From 40877037926b58f74682517196c7fbd06faa5287 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 18:56:27 -0700 Subject: [PATCH 4/5] changeset --- .changeset/tidy-buses-hunt.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-buses-hunt.md diff --git a/.changeset/tidy-buses-hunt.md b/.changeset/tidy-buses-hunt.md new file mode 100644 index 0000000..581d8d4 --- /dev/null +++ b/.changeset/tidy-buses-hunt.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Tighten skill-source identity to `(kind, id)` instead of name alone: `workspace:foo` no longer authorizes an npm-installed `foo` (and vice versa) in `intent.skills`, the "declared but not discovered" notice, and skill loading via `intent load`. From 9dd06a728f7f6a475a4d5390fb2c648a14128eb7 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 19:58:31 -0700 Subject: [PATCH 5/5] feat: Add late checks for package kind in source policy functions --- packages/intent/src/core/source-policy.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index d8af810..e9525f8 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -95,6 +95,9 @@ 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)) { return packageNotListedRefusal(use, packageName) } @@ -183,9 +186,14 @@ export function applySourcePolicy( ), ) 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 })) + !discoveredKeys.has( + sourceIdentityKey({ kind: source.kind, id: source.id }), + ) if (notDiscovered) { emit( `"${source.raw}" is declared in intent.skills but was not discovered.`, @@ -255,6 +263,8 @@ export function scanForPolicedIntents(params: { excludeMatchers, }) + // Name-only Sets, correct because the scanner guarantees at most one + // package per name (createPackageRegistrar dedups before this runs). const survivingNames = new Set(policy.packages.map((pkg) => pkg.name)) const droppedNames = scanResult.packages .map((pkg) => pkg.name)