Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-buses-hunt.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions packages/intent/src/core/excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExcludeMatcher>,
Expand Down
14 changes: 13 additions & 1 deletion packages/intent/src/core/intent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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<typeof resolveSkillUse>
try {
resolved = resolveSkillUse(use, scanResult)
Expand Down
11 changes: 8 additions & 3 deletions packages/intent/src/core/load-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,14 @@ function getWorkspaceLoadFastPathCandidateDirs(
return candidates
}

export interface FastPathResolveResult extends ResolveSkillResult {
kind: 'npm' | 'workspace'
}

function resolveScannedPackageSkill(
scanned: ReturnType<typeof scanIntentPackageAtRoot>,
parsedUse: SkillUse,
): ResolveSkillResult | null {
): FastPathResolveResult | null {
const pkg = scanned.package
if (!pkg || pkg.name !== parsedUse.packageName) return null

Expand All @@ -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),
),
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down
49 changes: 40 additions & 9 deletions packages/intent/src/core/source-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -47,9 +48,10 @@ export interface LoadRefusal {
message: string
}

function isSourcePermitted(
export function isSourcePermitted(
config: SkillSourcesConfig,
packageName: string,
packageKind?: 'npm' | 'workspace',
): boolean {
switch (config.mode) {
case 'absent':
Expand All @@ -58,7 +60,20 @@ 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
})
}
}

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.`,
}
}

Expand All @@ -80,11 +95,11 @@ 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 {
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)) {
Expand Down Expand Up @@ -145,7 +160,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 })
}
Expand All @@ -165,9 +180,21 @@ 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)) {
// 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 }),
)
if (notDiscovered) {
emit(
`"${source.raw}" is declared in intent.skills but was not discovered.`,
)
Expand Down Expand Up @@ -212,6 +239,7 @@ export interface PolicedScan {
hiddenSources: Array<IntentHiddenSourceSummary>
scan: ScanResult
excludePatterns: Array<string>
droppedNames: Array<string>
}

export function scanForPolicedIntents(params: {
Expand All @@ -235,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)
Expand All @@ -256,5 +286,6 @@ export function scanForPolicedIntents(params: {
),
},
excludePatterns,
droppedNames,
}
}
18 changes: 18 additions & 0 deletions packages/intent/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
45 changes: 45 additions & 0 deletions packages/intent/tests/core-types.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
107 changes: 107 additions & 0 deletions packages/intent/tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>, 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:<name>, 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:<name>', () => {
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.',
)
})
})
Loading
Loading