diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index f789a81..a36e432 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -24,6 +24,7 @@ npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--glob - Updates an existing managed block in a supported config file. - Preserves all content outside the managed block. - Scans packages and writes compact `when` and `use` mappings only when `--map` is passed. +- Surfaces packages permitted by `package.json#intent.skills` in `--map` mode. See [Configuration](../concepts/configuration). - Skips reference, meta, maintainer, and maintainer-only skills in `--map` mode. - Writes compact `when` and `use` entries instead of load paths in `--map` mode. - Verifies the managed block before reporting success. diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index 3fee71c..132d06f 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -20,9 +20,10 @@ npx @tanstack/intent@latest list [--json] [--debug] [--exclude ] [--glo ## What you get - Scans project and workspace dependencies for intent-enabled packages and skills +- Surfaces packages permitted by `package.json#intent.skills` (see [Allowlist](#allowlist)) - Includes global packages only when `--global` or `--global-only` is passed - Includes warnings from discovery -- Excludes packages matched by package.json `intent.exclude` or `--exclude` +- Excludes packages and skills matched by package.json `intent.exclude` or `--exclude` - Prints debug details to stderr when `--debug` is passed - If no packages are discovered, prints `No intent-enabled packages found.` - Summary line with package count and skill count @@ -83,20 +84,48 @@ When both local and global packages are scanned, local packages take precedence. When the same package exists both locally and globally and global scanning is enabled, `intent list` prefers the local package. When project `node_modules` exists, `intent list` scans it. In Yarn PnP projects without usable `node_modules`, `intent list` uses Yarn's PnP API. +## Allowlist + +`package.json#intent.skills` is the allowlist that decides which discovered packages are surfaced. Only listed packages contribute skills. + +```json +{ + "intent": { + "skills": ["@tanstack/query", "workspace:@scope/internal"] + } +} +``` + +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. +- `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. + +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. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). + ## Excludes -Package excludes are hard filters for packages that should not be used in a repo. +Package excludes are hard filters for packages that should not be used in a repo, applied after the allowlist. Intent reads `intent.exclude` arrays from package.json files while walking from the workspace or project root to the current working directory, then appends any `--exclude` flags. ```json { "intent": { - "exclude": ["@tanstack/*devtools*"] + "exclude": ["@tanstack/*devtools*", "@tanstack/router#experimental-*"] } } ``` -Exclude patterns match full package names. In v1, only exact names and `*` wildcards are supported. +A pattern without `#` excludes a whole package. A pattern with `#` excludes a single skill (`@scope/pkg#search-params`), and the skill segment may itself be a glob (`@scope/pkg#experimental-*`). A pattern may cross package boundaries at skill granularity (`*#experimental-*`). The `#*` shortcut (`@scope/pkg#*`) excludes the whole package. Only exact names and `*` wildcards are supported on each segment. Bare package-name patterns keep working unchanged. + +An excluded package never triggers the unlisted-source warning, because an exclude is an explicit decision rather than an oversight. ## Common errors diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 8f515cf..520d0b9 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -14,7 +14,7 @@ npx @tanstack/intent@latest load # [--path] [--json] [--debug] [ - `--path`: print the resolved skill path instead of the file content - `--json`: print structured JSON with metadata and content - `--debug`: print resolution debug details to stderr -- `--exclude `: exclude package names matching a simple glob; can be passed more than once +- `--exclude `: exclude a package or skill matching a simple glob; can be passed more than once - `--global`: load from project packages first, then global packages - `--global-only`: load from global packages only @@ -23,7 +23,8 @@ npx @tanstack/intent@latest load # [--path] [--json] [--debug] [ - Validates `#` before scanning - Scans project-local packages by default - Includes global packages only when `--global` or `--global-only` is passed -- Fails before scanning when the target package matches package.json `intent.exclude` or `--exclude` +- Refuses before scanning when the target package is not permitted by `package.json#intent.skills` +- Refuses before scanning when the target package or skill matches `intent.exclude` or `--exclude` - Prefers local packages when `--global` is used and the same package exists locally and globally - Accepts an unambiguous short skill name when a package-prefixed skill exists - Prints raw `SKILL.md` content by default @@ -66,9 +67,13 @@ npx @tanstack/intent@latest load some-lib#core --path - Missing package: `Cannot resolve skill use "...": package "..." was not found.` - Missing skill: `Cannot resolve skill use "...": skill "..." was not found in package "...".` - Skill suggestion: `Did you mean @tanstack/router-core#router-core/auth-and-guards?` +- Unlisted package: `Cannot load skill use "...": package "..." is not listed in intent.skills.` - Excluded package: `Cannot load skill use "...": package "..." is excluded by Intent configuration.` +- Excluded skill: `Cannot load skill use "...": skill "..." is excluded by Intent configuration.` ## Related - [intent list](./intent-list) - [intent install](./intent-install) +- [Trust model](../concepts/trust-model) +- [Configuration](../concepts/configuration) diff --git a/docs/cli/intent-stale.md b/docs/cli/intent-stale.md index b7ebb05..b027f8b 100644 --- a/docs/cli/intent-stale.md +++ b/docs/cli/intent-stale.md @@ -15,28 +15,29 @@ npx @tanstack/intent@latest stale [--json] ## Behavior -- Checks the current package by default -- From a monorepo root, checks workspace packages that ship skills and also reports public workspace packages with no skill or artifact coverage -- When `dir` is provided, scopes the check to the targeted package or skills directory -- Computes one staleness report per package -- Reads repo-root `_artifacts/*domain_map.yaml` and `_artifacts/*skill_tree.yaml` when present -- Flags public workspace packages that are not represented by generated skills or artifact coverage -- Skips workspace packages with `"private": true` -- Prints text output by default or JSON with `--json` -- Prints a non-failing workflow update reminder when `.github/workflows/check-skills.yml` is missing the current `intent-workflow-version` stamp -- If no packages are found, prints `No intent-enabled packages found.` - -Artifact coverage ignores can be recorded in `_artifacts/*skill_tree.yaml` or `_artifacts/*domain_map.yaml`: - -```yaml -coverage: - ignored_packages: - - '@tanstack/internal-tooling' - - name: packages/devtools-fixture - reason: test fixture only -``` - -Ignored packages are excluded from missing coverage signals. Private workspace packages are excluded automatically. +- Checks the current package by default +- From a monorepo root, checks workspace packages that ship skills and also reports public workspace packages with no skill or artifact coverage +- Applies the `package.json#intent.skills` allowlist when falling back to installed dependencies; workspace packages are first-party and checked regardless. See [Configuration](../concepts/configuration). +- When `dir` is provided, scopes the check to the targeted package or skills directory +- Computes one staleness report per package +- Reads repo-root `_artifacts/*domain_map.yaml` and `_artifacts/*skill_tree.yaml` when present +- Flags public workspace packages that are not represented by generated skills or artifact coverage +- Skips workspace packages with `"private": true` +- Prints text output by default or JSON with `--json` +- Prints a non-failing workflow update reminder when `.github/workflows/check-skills.yml` is missing the current `intent-workflow-version` stamp +- If no packages are found, prints `No intent-enabled packages found.` + +Artifact coverage ignores can be recorded in `_artifacts/*skill_tree.yaml` or `_artifacts/*domain_map.yaml`: + +```yaml +coverage: + ignored_packages: + - '@tanstack/internal-tooling' + - name: packages/devtools-fixture + reason: test fixture only +``` + +Ignored packages are excluded from missing coverage signals. Private workspace packages are excluded automatically. ## JSON report schema @@ -49,26 +50,26 @@ Ignored packages are excluded from missing coverage signals. Private workspace p "currentVersion": "string | null", "skillVersion": "string | null", "versionDrift": "major | minor | patch | null", - "skills": [ - { - "name": "string", - "reasons": ["string"], - "needsReview": true - } - ], - "signals": [ - { - "type": "missing-package-coverage", - "library": "string", - "subject": "string", - "reasons": ["string"], - "needsReview": true, - "packageName": "string", - "packageRoot": "string" - } - ] - } -] + "skills": [ + { + "name": "string", + "reasons": ["string"], + "needsReview": true + } + ], + "signals": [ + { + "type": "missing-package-coverage", + "library": "string", + "subject": "string", + "reasons": ["string"], + "needsReview": true, + "packageName": "string", + "packageRoot": "string" + } + ] + } +] ``` Report fields: @@ -76,9 +77,9 @@ Report fields: - `library`: package name - `currentVersion`: latest version from npm registry (or `null` if unavailable) - `skillVersion`: `library_version` from skills (or `null`) -- `versionDrift`: `major | minor | patch | null` -- `skills`: array of per-skill checks -- `signals`: array of artifact and workspace coverage checks +- `versionDrift`: `major | minor | patch | null` +- `skills`: array of per-skill checks +- `signals`: array of artifact and workspace coverage checks Skill fields: @@ -86,17 +87,17 @@ Skill fields: - `reasons`: one or more staleness reasons - `needsReview`: boolean (`true` when reasons exist) -Reason generation: - -- `version drift ()` -- `new source ()` when a declared source has no stored sync SHA -- artifact parse warnings, unresolved artifact skill paths, source drift, artifact library version drift, and missing workspace package coverage +Reason generation: + +- `version drift ()` +- `new source ()` when a declared source has no stored sync SHA +- artifact parse warnings, unresolved artifact skill paths, source drift, artifact library version drift, and missing workspace package coverage ## Text output -- Report header format: ` () [ drift]` -- When no skill reasons exist: `All skills up-to-date` -- Otherwise: one warning line per stale skill or review signal (`⚠ : , , ...`) +- Report header format: ` () [ drift]` +- When no skill reasons exist: `All skills up-to-date` +- Otherwise: one warning line per stale skill or review signal (`⚠ : , , ...`) ## Common errors diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md new file mode 100644 index 0000000..6f8edd1 --- /dev/null +++ b/docs/concepts/configuration.md @@ -0,0 +1,69 @@ +--- +title: Configuration +id: configuration +--- + +Intent reads consumer configuration from the `intent` object in `package.json`. Two keys control which skills reach your agent: `skills` (the allowlist) and `exclude` (the blocklist). + +```json +{ + "intent": { + "skills": ["@tanstack/query", "workspace:@scope/internal"], + "exclude": ["@tanstack/router#experimental-*"] + } +} +``` + +Intent merges these keys from every `package.json` between the current working directory and the workspace or project root. A monorepo package inherits the root configuration and adds its own. + +## `intent.skills` + +`intent.skills` is the allowlist. Only packages it permits contribute skills to `list`, `load`, `install`, and `stale`. See [Trust model](./trust-model) for the reasoning. + +### Source entries + +Each array entry names one source: + +| Entry | Kind | Meaning | +| ----- | ---- | ------- | +| `@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. | +| `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. + +### Special forms + +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. + +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. + +### Existing projects + +A project that has not set `intent.skills` keeps working. Intent surfaces every discovered package and prints the deprecation notice described under the absent form. Nothing breaks. Add an allowlist when you are ready, before a future version requires one. Run `intent list` to confirm which packages are surfaced. + +## `intent.exclude` + +`intent.exclude` removes packages or individual skills after the allowlist resolves. It also accepts the `--exclude ` flag on `list` and `load` for one-off runs. + +```json +{ + "intent": { + "exclude": ["@tanstack/*devtools*", "@tanstack/router#experimental-*"] + } +} +``` + +Pattern grammar: + +- A pattern without `#` excludes a whole package: `@scope/pkg`. +- A pattern with `#` excludes a single skill: `@scope/pkg#search-params`. +- The skill segment may be a glob: `@scope/pkg#experimental-*`. +- A pattern may cross package boundaries at skill granularity: `*#experimental-*`. +- The `#*` shortcut excludes the whole package: `@scope/pkg#*`. + +Only exact names and `*` wildcards are supported on each segment. Bare package-name patterns keep working unchanged. An excluded package does not trigger the unlisted-source warning, because an exclude is an explicit decision. diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md new file mode 100644 index 0000000..b8fba13 --- /dev/null +++ b/docs/concepts/trust-model.md @@ -0,0 +1,28 @@ +--- +title: Trust model +id: trust-model +--- + +Intent surfaces skills from your dependencies into your coding agent's guidance. A skill is instructions an agent follows, so the set of packages allowed to contribute skills is a trust decision. Intent makes that decision explicit through the `intent.skills` allowlist. + +## Explicit sources + +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. + +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. + +## Static discovery + +Intent reads package data as files. It never imports, requires, or executes the code of a discovered package to find or load a skill. Adding a package to your dependency tree cannot run that package's code through Intent. + +One exception is sanctioned: in Yarn Plug'n'Play projects, Intent loads Yarn's PnP runtime (`.pnp.cjs`) to map package identities to readable locations. It loads no package entry points, bins, lifecycle scripts, or other package-provided JavaScript. An ESLint rule enforces this invariant in the discovery code. + +## What the allowlist does not cover yet + +Matching is currently by package name. A `workspace:foo` entry and a bare `foo` entry both authorize a discovered package named `foo`, because the scanner does not yet distinguish a workspace member from a published package of the same name. This errs toward permitting a same-named package, never toward denying one you listed. A future version tightens matching once the scanner carries that signal. + +The `git:` source kind is reserved. Intent parses and validates the shape, then rejects it until a future version can pin the resolved ref and content hash. A git entry never loads silently. diff --git a/docs/config.json b/docs/config.json index 7fee17d..ac979b5 100644 --- a/docs/config.json +++ b/docs/config.json @@ -27,6 +27,19 @@ } ] }, + { + "label": "Concepts", + "children": [ + { + "label": "Trust Model", + "to": "concepts/trust-model" + }, + { + "label": "Configuration", + "to": "concepts/configuration" + } + ] + }, { "label": "CLI Reference", "children": [ diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 021a5ba..9a265a6 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -41,7 +41,21 @@ Before substantial work: Intent detects the package manager when generating this block, so the runner may be `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`. -## 2. Use skills in your workflow +## 2. Choose which packages' skills to use + +`package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced. + +```json +{ + "intent": { + "skills": ["@tanstack/query", "@tanstack/router"] + } +} +``` + +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. + +## 3. Use skills in your workflow When your agent works on a task that matches an available skill, it loads the matching `SKILL.md` into context. @@ -59,7 +73,7 @@ If you want explicit task-to-skill mappings in your agent config, opt in: npx @tanstack/intent@latest install --map ``` -## 3. Keep skills up-to-date +## 4. Keep skills up-to-date Skills version with library releases. When you update a library: @@ -93,7 +107,7 @@ You can also check if any skills reference outdated source documentation: npx @tanstack/intent@latest stale ``` -## 4. Submit feedback (optional) +## 5. Submit feedback (optional) After using a skill, you can submit feedback to help maintainers improve it: diff --git a/docs/overview.md b/docs/overview.md index e3a2cb0..1d62c3a 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -5,16 +5,17 @@ id: overview `@tanstack/intent` is a CLI for shipping and consuming Agent Skills as package artifacts. -Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases, ships them inside npm packages, discovers them from your project and workspace by default, and helps agents load them automatically when working on matching tasks. +Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases and ships them inside npm packages. It discovers skills from your project and workspace dependencies, then helps agents load them when working on matching tasks. ## What Intent does Intent provides tooling for two workflows: -**For consumers:** -- Discover skills from installed dependencies -- Add lightweight skill loading guidance to your agent config -- Keep skills synchronized with library versions +**For consumers:** +- Discover skills from your project and workspace dependencies +- Control which packages' skills are surfaced with an allowlist +- Add lightweight skill loading guidance to your agent config +- Keep skills synchronized with library versions **For maintainers (library teams):** - Scaffold skills through AI-assisted domain discovery @@ -24,36 +25,36 @@ Intent provides tooling for two workflows: ## How it works -### Discovery and installation - -Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: - -| Tool | Pattern | -| ---- | -------------------------------------------- | -| npm | `npx @tanstack/intent@latest ` | -| pnpm | `pnpm dlx @tanstack/intent@latest ` | -| Yarn | `yarn dlx @tanstack/intent@latest ` | -| Bun | `bunx @tanstack/intent@latest ` | - -```bash -npx @tanstack/intent@latest list -``` - -Scans the current project's installed dependencies for intent-enabled packages, including `node_modules`, workspace dependencies, and Yarn PnP projects without `node_modules`. -Global package scanning is explicit; pass `--global` to include global packages or `--global-only` to ignore local packages. -When both local and global packages are scanned, local packages take precedence. - -```bash -npx @tanstack/intent@latest install -``` - -Creates or updates lightweight `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing guidance is updated in place; otherwise `AGENTS.md` is the default target. Pass `--map` to opt in to explicit task-to-skill mappings. - -```bash -npx @tanstack/intent@latest load @tanstack/query#fetching -``` - -Loads the matching `SKILL.md` content for the installed package version. Pass `--path` when you need the resolved skill file path for debugging. +### Discovery and installation + +Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: + +| Tool | Pattern | +| ---- | -------------------------------------------- | +| npm | `npx @tanstack/intent@latest ` | +| pnpm | `pnpm dlx @tanstack/intent@latest ` | +| Yarn | `yarn dlx @tanstack/intent@latest ` | +| Bun | `bunx @tanstack/intent@latest ` | + +```bash +npx @tanstack/intent@latest list +``` + +Scans the current project's installed dependencies for intent-enabled packages, including `node_modules`, workspace dependencies, and Yarn PnP projects without `node_modules`. You can narrow which packages are surfaced with `package.json#intent.skills`. See the [Trust model](./concepts/trust-model) and [Configuration](./concepts/configuration) for how the allowlist works. +Global package scanning is explicit; pass `--global` to include global packages or `--global-only` to ignore local packages. +When both local and global packages are scanned, local packages take precedence. + +```bash +npx @tanstack/intent@latest install +``` + +Creates or updates lightweight `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing guidance is updated in place; otherwise `AGENTS.md` is the default target. Pass `--map` to opt in to explicit task-to-skill mappings. + +```bash +npx @tanstack/intent@latest load @tanstack/query#fetching +``` + +Loads the matching `SKILL.md` content for the installed package version. Pass `--path` when you need the resolved skill file path for debugging. ### Scaffolding and validation diff --git a/eslint.config.js b/eslint.config.js index a0dc142..bc64866 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,6 +19,60 @@ const config = [ 'pnpm/json-enforce-catalog': 'off', }, }, + { + name: 'intent/policed-scanner-import', + files: ['packages/intent/src/**/*.ts'], + ignores: [ + 'packages/intent/src/index.ts', + 'packages/intent/src/core/source-policy.ts', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/scanner.js', './scanner.js', '../scanner.js'], + importNames: ['scanForIntents'], + message: + 'Import scanForPolicedIntents from core/source-policy.js; the raw scanForIntents must not be used by internal consumers.', + }, + ], + }, + ], + }, + }, + { + name: 'intent/static-discovery', + files: [ + 'packages/intent/src/scanner.ts', + 'packages/intent/src/lockfile.ts', + 'packages/intent/src/manifest.ts', + 'packages/intent/src/mcp/**/*.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "ImportExpression[source.type!='Literal']", + message: + 'Static-discovery invariant: no dynamic import() of a computed path in discovery code.', + }, + { + selector: + "CallExpression[callee.name=/[rR]equire/][callee.name!='createRequire'][arguments.0.type!='Literal']", + message: + 'Static-discovery invariant: no require() of a computed path in discovery code.', + }, + { + selector: + "CallExpression[callee.property.name='resolve'][callee.object.callee.name='createRequire']", + message: + 'Static-discovery invariant: createRequire().resolve is limited to package.json targets (disable inline for that case).', + }, + ], + }, + }, ] export default config diff --git a/packages/intent/src/cli-output.ts b/packages/intent/src/cli-output.ts index 629c6bb..25623e8 100644 --- a/packages/intent/src/cli-output.ts +++ b/packages/intent/src/cli-output.ts @@ -6,3 +6,12 @@ export function printWarnings(warnings: Array): void { console.log(` ⚠ ${warning}`) } } + +export function printNotices(notices: Array): void { + if (notices.length === 0) return + + console.error('Notices:') + for (const notice of notices) { + console.error(` ℹ ${notice}`) + } +} diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 21128b7..0305d41 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -6,7 +6,7 @@ import { resolveProjectContext } from './core/project-context.js' import type { IntentCoreOptions } from './core.js' import type { ScanOptions, ScanResult, StalenessReport } from './types.js' -export { printWarnings } from './cli-output.js' +export { printNotices, printWarnings } from './cli-output.js' export interface GlobalScanFlags { debug?: boolean @@ -48,20 +48,23 @@ export function getCheckSkillsWorkflowAdvisories(root: string): Array { } export async function scanIntentsOrFail( - options?: ScanOptions, + coreOptions: IntentCoreOptions = {}, ): Promise { - const { scanForIntents } = await import('./scanner.js') + const { scanForPolicedIntents } = await import('./core/source-policy.js') try { - return scanForIntents(undefined, options) + const { scan } = scanForPolicedIntents({ + cwd: process.cwd(), + scanOptions: scanOptionsFromGlobalFlags(coreOptions), + coreOptions, + }) + return scan } catch (err) { fail(err instanceof Error ? err.message : String(err)) } } -export function scanOptionsFromGlobalFlags( - options: GlobalScanFlags, -): ScanOptions { +function scanOptionsFromGlobalFlags(options: GlobalScanFlags): ScanOptions { if (options.global && options.globalOnly) { fail('Use either --global or --global-only, not both.') } diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index 03662c0..e9953c5 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -1,7 +1,11 @@ import { relative } from 'node:path' import { fail } from '../cli-error.js' import { detectIntentCommandPackageManager } from '../command-runner.js' -import { printWarnings, scanOptionsFromGlobalFlags } from '../cli-support.js' +import { + coreOptionsFromGlobalFlags, + printNotices, + printWarnings, +} from '../cli-support.js' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, @@ -10,7 +14,8 @@ import { writeIntentSkillsBlock, } from './install-writer.js' import type { GlobalScanFlags } from '../cli-support.js' -import type { ScanOptions, ScanResult } from '../types.js' +import type { IntentCoreOptions } from '../core.js' +import type { ScanResult } from '../types.js' export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. @@ -128,9 +133,13 @@ function formatMappingCount(mappingCount: number): string { return `${mappingCount} ${mappingCount === 1 ? 'mapping' : 'mappings'}` } -function printNoActionableSkills(warnings: Array): void { +function printNoActionableSkills( + warnings: Array, + notices: Array, +): void { console.log('No intent-enabled skills found.') printWarnings(warnings) + printNotices(notices) } function printPlacementTip(targetPath: string): void { @@ -184,14 +193,14 @@ function printWriteResult({ export async function runInstallCommand( options: InstallCommandOptions, - scanIntentsOrFail: (options?: ScanOptions) => Promise, + scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, ): Promise { if (options.printPrompt) { console.log(INSTALL_PROMPT) return } - const scanOptions = scanOptionsFromGlobalFlags(options) + const coreOptions = coreOptionsFromGlobalFlags(options) if (!options.map) { const generated = buildIntentSkillGuidanceBlock( @@ -237,7 +246,7 @@ export async function runInstallCommand( return } - const scanResult = await scanIntentsOrFail(scanOptions) + const scanResult = await scanIntentsOrFail(coreOptions) const generated = buildIntentSkillsBlock(scanResult) if (options.dryRun) { @@ -247,7 +256,7 @@ export async function runInstallCommand( ) if (!targetPath) { - printNoActionableSkills(scanResult.warnings) + printNoActionableSkills(scanResult.warnings, scanResult.notices) return } @@ -256,6 +265,7 @@ export async function runInstallCommand( ) console.log(generated.block) printWarnings(scanResult.warnings) + printNotices(scanResult.notices) return } @@ -265,7 +275,7 @@ export async function runInstallCommand( }) if (!result.targetPath) { - printNoActionableSkills(scanResult.warnings) + printNoActionableSkills(scanResult.warnings, scanResult.notices) return } @@ -289,4 +299,5 @@ export async function runInstallCommand( printPlacementTip(result.targetPath) printWarnings(scanResult.warnings) + printNotices(scanResult.notices) } diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index f724187..0749021 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,6 +1,7 @@ import { coreOptionsFromGlobalFlags, printDebugInfo, + printNotices, printWarnings, } from '../cli-support.js' import { formatIntentCommand } from '../command-runner.js' @@ -11,7 +12,7 @@ import type { IntentSkillList, IntentSkillSummary, } from '../core.js' -import type { ScanOptions, ScanResult } from '../types.js' +import type { ScanResult } from '../types.js' export interface ListCommandOptions extends GlobalScanFlags { json?: boolean @@ -27,6 +28,7 @@ function printListDebug(result: IntentSkillList): void { ['packages', result.debug.packageCount], ['skills', result.debug.skillCount], ['warnings', result.debug.warningCount], + ['notices', result.debug.noticeCount], ['conflicts', result.debug.conflictCount], ['packageJsonReadCount', result.debug.scan.packageJsonReadCount], ['packageJsonCacheHits', result.debug.scan.packageJsonCacheHits], @@ -86,7 +88,6 @@ function formatLoadCommand( export async function runListCommand( options: ListCommandOptions, - _scanIntentsOrFail?: (options?: ScanOptions) => Promise, ): Promise { const result = listIntentSkills(coreOptionsFromGlobalFlags(options)) printListDebug(result) @@ -110,6 +111,7 @@ export async function runListCommand( console.log() printWarnings(result.warnings) } + printNotices(result.notices) return } @@ -168,4 +170,5 @@ export async function runListCommand( console.log() printWarnings(result.warnings) + printNotices(result.notices) } diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 7a4ed35..88db754 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -7,7 +7,6 @@ import { } from '../core.js' import type { GlobalScanFlags } from '../cli-support.js' import type { LoadedIntentSkill, ResolvedIntentSkill } from '../core.js' -import type { ScanOptions, ScanResult } from '../types.js' export interface LoadCommandOptions extends GlobalScanFlags { json?: boolean @@ -36,7 +35,6 @@ function printLoadDebug(loaded: LoadedIntentSkill | ResolvedIntentSkill): void { export async function runLoadCommand( use: string | undefined, options: LoadCommandOptions, - _scanIntentsOrFail?: (options?: ScanOptions) => Promise, ): Promise { if (!use) { fail('Missing skill use. Expected: intent load #') diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index f2dc5a5..bb65ef6 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -1,3 +1,4 @@ +import { isCliFailure } from '../cli-error.js' import type { StalenessReport } from '../types.js' export interface StaleCommandOptions { @@ -105,7 +106,11 @@ async function runGithubReview( } catch (err) { const item = createFailedStaleReviewItem(packageLabel) writeStaleReviewWorkflowFiles([item]) - const message = err instanceof Error ? err.message : String(err) + const message = isCliFailure(err) + ? err.message + : err instanceof Error + ? err.message + : String(err) console.log(`Intent stale check failed: ${message}`) console.log('Wrote a review PR body so maintainers can inspect the logs.') } diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 6b7f67d..1490696 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -2,16 +2,18 @@ import { isAbsolute, relative, resolve } from 'node:path' import { compileExcludePatterns, getEffectiveExcludePatterns, - isPackageExcluded, - warningMentionsPackage, } from './core/excludes.js' import { createIntentFsCache } from './fs-cache.js' import { rewriteLoadedSkillMarkdownDestinations } from './core/markdown.js' import { resolveSkillUseFastPath } from './core/load-resolution.js' import { resolveProjectContext } from './core/project-context.js' +import { + checkLoadAllowed, + readSkillSourcesConfig, + scanForPolicedIntents, +} from './core/source-policy.js' import { ResolveSkillUseError, resolveSkillUse } from './resolver.js' import { formatSkillUse, parseSkillUse } from './skill-use.js' -import { scanForIntents } from './scanner.js' import type { ResolveSkillResult } from './resolver.js' import type { IntentFsCache } from './fs-cache.js' import type { ReadFs } from './utils.js' @@ -97,15 +99,13 @@ export function listIntentSkills( const scanOptions = toScanOptions(options) const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) - const scanResult = scanForIntents(cwd, withFsCache(scanOptions, fsCache)) - const excludePatterns = getEffectiveExcludePatterns(options, projectContext) - const excludeMatchers = compileExcludePatterns(excludePatterns) - const excludedPackages = scanResult.packages - .filter((pkg) => isPackageExcluded(pkg.name, excludeMatchers)) - .map((pkg) => pkg.name) - const packages = scanResult.packages.filter( - (pkg) => !isPackageExcluded(pkg.name, excludeMatchers), - ) + const { scan, excludePatterns } = scanForPolicedIntents({ + cwd, + scanOptions: withFsCache(scanOptions, fsCache), + coreOptions: options, + context: projectContext, + }) + const packages = scan.packages const skills = packages.flatMap((pkg) => pkg.skills.map((skill): IntentSkillSummary => { return { @@ -123,7 +123,7 @@ export function listIntentSkills( ) const result: IntentSkillList = { - packageManager: scanResult.packageManager, + packageManager: scan.packageManager, skills, packages: packages.map((pkg) => ({ name: pkg.name, @@ -132,15 +132,9 @@ export function listIntentSkills( packageRoot: pkg.packageRoot, skillCount: pkg.skills.length, })), - warnings: scanResult.warnings.filter( - (warning) => - !excludedPackages.some((packageName) => - warningMentionsPackage(warning, packageName), - ), - ), - conflicts: scanResult.conflicts.filter( - (conflict) => !isPackageExcluded(conflict.packageName, excludeMatchers), - ), + warnings: scan.warnings, + notices: scan.notices, + conflicts: scan.conflicts, } if (options.debug) { @@ -151,8 +145,9 @@ export function listIntentSkills( packageCount: result.packages.length, skillCount: result.skills.length, warningCount: result.warnings.length, + noticeCount: result.notices.length, conflictCount: result.conflicts.length, - scan: scanResult.stats ?? fsCache.getStats(), + scan: scan.stats ?? fsCache.getStats(), } } @@ -285,12 +280,11 @@ function resolveIntentSkillInCwd( const projectContext = resolveProjectContext({ cwd }) const excludePatterns = getEffectiveExcludePatterns(options, projectContext) const excludeMatchers = compileExcludePatterns(excludePatterns) + const config = readSkillSourcesConfig(cwd, projectContext) - if (isPackageExcluded(parsedUse.packageName, excludeMatchers)) { - throw new IntentCoreError( - 'package-excluded', - `Cannot load skill use "${use}": package "${parsedUse.packageName}" is excluded by Intent configuration.`, - ) + const refusal = checkLoadAllowed(use, parsedUse, { config, excludeMatchers }) + if (refusal) { + throw new IntentCoreError(refusal.code, refusal.message) } const scanOptions = toScanOptions(options) @@ -321,7 +315,12 @@ function resolveIntentSkillInCwd( ) } - const scanResult = scanForIntents(cwd, withFsCache(scanOptions, fsCache)) + const { scan: scanResult } = scanForPolicedIntents({ + cwd, + scanOptions: withFsCache(scanOptions, fsCache), + coreOptions: options, + context: projectContext, + }) let resolved: ReturnType try { resolved = resolveSkillUse(use, scanResult) diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 668f4ed..2eeb073 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -9,7 +9,8 @@ const PACKAGE_NAME_BOUNDARY = /[^a-zA-Z0-9_.-]/ export interface ExcludeMatcher { pattern: string - matches: (packageName: string) => boolean + matchesPackage: (packageName: string) => boolean + matchesSkill?: (skillName: string) => boolean } function normalizeExcludePatterns(value: unknown): Array { @@ -34,7 +35,7 @@ function readPackageExcludes(dir: string): Array { return normalizeExcludePatterns((intent as Record).exclude) } -function getConfigExcludePatterns( +export function getConfigDirs( cwd: string, context = resolveProjectContext({ cwd }), ): Array { @@ -51,7 +52,14 @@ function getConfigExcludePatterns( dir = next } - return dirs.reverse().flatMap(readPackageExcludes) + return dirs +} + +function getConfigExcludePatterns( + cwd: string, + context = resolveProjectContext({ cwd }), +): Array { + return [...getConfigDirs(cwd, context)].reverse().flatMap(readPackageExcludes) } export function getEffectiveExcludePatterns( @@ -66,40 +74,54 @@ export function getEffectiveExcludePatterns( ] } -function normalizeGlobPattern(pattern: string): string { +function assertPatternLength(pattern: string): void { if (pattern.length > MAX_EXCLUDE_PATTERN_LENGTH) { throw new Error( `Intent exclude pattern is too long: ${pattern.length} characters. Maximum is ${MAX_EXCLUDE_PATTERN_LENGTH}.`, ) } - - return pattern.replace(/\*+/g, '*') } function globToRegExp(pattern: string): RegExp { - const source = normalizeGlobPattern(pattern) + const source = pattern + .replace(/\*+/g, '*') .split('*') .map((part) => part.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')) .join('.*') return new RegExp(`^${source}$`) } +function compileSegment(segment: string): (value: string) => boolean { + if (!segment.includes('*')) { + return (value) => value === segment + } + + const regex = globToRegExp(segment) + return (value) => regex.test(value) +} + export function compileExcludePatterns( patterns: Array, ): Array { return patterns.map((pattern) => { - if (!pattern.includes('*')) { - normalizeGlobPattern(pattern) - return { - pattern, - matches: (packageName) => packageName === pattern, - } + assertPatternLength(pattern) + + const hashIndex = pattern.indexOf('#') + if (hashIndex === -1) { + return { pattern, matchesPackage: compileSegment(pattern) } + } + + const packageSegment = pattern.slice(0, hashIndex) + const skillSegment = pattern.slice(hashIndex + 1) + + if (skillSegment.replace(/\*+/g, '*') === '*') { + return { pattern, matchesPackage: compileSegment(packageSegment) } } - const regex = globToRegExp(pattern) return { pattern, - matches: (packageName) => regex.test(packageName), + matchesPackage: compileSegment(packageSegment), + matchesSkill: compileSegment(skillSegment), } }) } @@ -108,7 +130,36 @@ export function isPackageExcluded( packageName: string, matchers: Array, ): boolean { - return matchers.some((matcher) => matcher.matches(packageName)) + return matchers.some( + (matcher) => + matcher.matchesSkill === undefined && matcher.matchesPackage(packageName), + ) +} + +// A prefixed skill is loadable by its short alias too; an exclude must match either form. +function skillNameVariants( + packageName: string, + skillName: string, +): Array { + const shortName = packageName.split('/').pop() ?? packageName + const prefix = `${shortName}/` + if (skillName.startsWith(prefix)) { + return [skillName, skillName.slice(prefix.length)] + } + return [skillName, `${prefix}${skillName}`] +} + +export function isSkillExcluded( + packageName: string, + skillName: string, + matchers: Array, +): boolean { + const variants = skillNameVariants(packageName, skillName) + return matchers.some((matcher) => { + if (!matcher.matchesPackage(packageName)) return false + if (matcher.matchesSkill === undefined) return true + return variants.some((variant) => matcher.matchesSkill!(variant)) + }) } export function warningMentionsPackage( diff --git a/packages/intent/src/core/skill-sources.ts b/packages/intent/src/core/skill-sources.ts new file mode 100644 index 0000000..29e92f8 --- /dev/null +++ b/packages/intent/src/core/skill-sources.ts @@ -0,0 +1,209 @@ +// Static-discovery invariant: this module only inspects strings. It never +// 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. + */ +export type SkillSource = + | { raw: string; id: string; kind: 'npm' } + | { raw: string; id: string; kind: 'workspace' } + | { raw: string; id: string; kind: 'git'; ref: string } + +/** + * `absent` (key missing, v0 upgrade path) and `empty` (`[]`) are deliberately + * distinct: absent is show-all, empty is deny-all. + */ +export type SkillSourcesConfig = + | { mode: 'absent' } + | { mode: 'empty' } + | { mode: 'allow-all' } + | { mode: 'explicit'; sources: Array } + +export interface SkillSourceIssue { + raw: string | null + message: string +} + +export class SkillSourcesParseError extends Error { + readonly issues: Array + + constructor(issues: Array) { + super(formatIssues(issues)) + this.name = 'SkillSourcesParseError' + this.issues = issues + } +} + +export function isSkillSourcesParseError( + error: unknown, +): error is SkillSourcesParseError { + return error instanceof SkillSourcesParseError +} + +/** + * Strictness is fail-whole-list: every malformed entry is collected and + * reported together, and a single bad entry rejects the entire list rather + * than silently applying a partial allowlist. + */ +export function parseSkillSources(value: unknown): SkillSourcesConfig { + if (value === undefined || value === null) { + return { mode: 'absent' } + } + + if (!Array.isArray(value)) { + throw new SkillSourcesParseError([ + { + raw: null, + message: `intent.skills must be an array of source strings, received ${describeType( + value, + )}.`, + }, + ]) + } + + if (value.length === 0) { + return { mode: 'empty' } + } + + const issues: Array = [] + const sources: Array = [] + const seenRaw = new Set() + const seenIdentity = new Set() + let allowAll = false + + for (const entry of value) { + if (typeof entry !== 'string') { + issues.push({ + raw: null, + message: `Entry must be a string, received ${describeType(entry)}.`, + }) + continue + } + + if (seenRaw.has(entry)) { + issues.push({ raw: entry, message: 'Duplicate entry.' }) + continue + } + seenRaw.add(entry) + + const trimmed = entry.trim() + if (trimmed === '') { + issues.push({ raw: entry, message: 'Entry is empty.' }) + 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 + } + issues.push({ + raw: entry, + message: + 'The "*" wildcard must be the exact entry "*"; globs are not supported in intent.skills.', + }) + continue + } + + const parsed = parseEntry(entry, trimmed) + if ('message' in parsed) { + issues.push(parsed) + continue + } + + const identity = `${parsed.kind}\u0000${parsed.id}` + if (seenIdentity.has(identity)) continue + seenIdentity.add(identity) + sources.push(parsed) + } + + if (issues.length > 0) { + throw new SkillSourcesParseError(issues) + } + + if (allowAll) { + return { mode: 'allow-all' } + } + + return { mode: 'explicit', sources } +} + +function parseEntry( + raw: string, + trimmed: string, +): SkillSource | SkillSourceIssue { + const colon = trimmed.indexOf(':') + + // npm names cannot contain ':', so a colon-free entry is unambiguously npm. + if (colon === -1) { + const invalid = validateId(trimmed) + if (invalid) + return { raw, message: `Invalid npm source "${trimmed}": ${invalid}` } + return { raw, id: trimmed, kind: 'npm' } + } + + const prefix = trimmed.slice(0, colon) + const rest = trimmed.slice(colon + 1).trim() + + switch (prefix) { + case 'workspace': { + if (rest === '') { + return { + raw, + message: `Workspace source "${trimmed}" is missing a package name.`, + } + } + const invalid = validateId(rest) + if (invalid) { + return { + raw, + message: `Invalid workspace source "${trimmed}": ${invalid}`, + } + } + return { raw, id: rest, kind: 'workspace' } + } + case 'git': + return { + raw, + message: `Git source "${trimmed}" is not supported until the lockfile lands (M2).`, + } + default: + return { + raw, + message: `Unknown source prefix "${prefix}" in "${trimmed}".`, + } + } +} + +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 (/\s/.test(id)) { + return 'package names cannot contain whitespace.' + } + if (id.includes(':')) { + return 'package names cannot contain ":".' + } + return null +} + +function describeType(value: unknown): string { + if (value === null) return 'null' + return Array.isArray(value) ? 'array' : typeof value +} + +function formatIssues(issues: Array): string { + const lines = issues.map((issue) => + issue.raw === null + ? ` - ${issue.message}` + : ` - "${issue.raw}": ${issue.message}`, + ) + return ['Invalid intent.skills configuration:', ...lines].join('\n') +} diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts new file mode 100644 index 0000000..3c440b8 --- /dev/null +++ b/packages/intent/src/core/source-policy.ts @@ -0,0 +1,226 @@ +import { scanForIntents } from '../scanner.js' +import { + compileExcludePatterns, + getConfigDirs, + getEffectiveExcludePatterns, + isPackageExcluded, + isSkillExcluded, + warningMentionsPackage, +} from './excludes.js' +import { readPackageJson } from './package-json.js' +import { parseSkillSources } from './skill-sources.js' +import { resolveProjectContext } from './project-context.js' +import type { ExcludeMatcher } from './excludes.js' +import type { ProjectContext } from './project-context.js' +import type { SkillSourcesConfig } from './skill-sources.js' +import type { SkillUse } from '../skill-use.js' +import type { IntentCoreOptions } from './types.js' +import type { IntentPackage, ScanOptions, ScanResult } from '../types.js' + +export const ALLOW_ALL_NOTICE = + 'All skill sources allowed (intent.skills: ["*"]) — unvetted skills may be surfaced into agent guidance.' + +export const MIGRATION_NOTICE = + 'intent.skills is not set — all discovered skill sources are surfaced. A future version will require an explicit intent.skills allowlist; add one to opt in to specific sources.' + +export const EMPTY_NOTE = + 'intent.skills is empty — no skill sources are permitted.' + +export interface SourcePolicyOptions { + config: SkillSourcesConfig + excludeMatchers: Array +} + +type LoadRefusalCode = + | 'package-excluded' + | 'package-not-listed' + | 'skill-excluded' + +export interface LoadRefusal { + code: LoadRefusalCode + message: string +} + +function isSourcePermitted( + config: SkillSourcesConfig, + packageName: string, +): boolean { + switch (config.mode) { + case 'absent': + case 'allow-all': + return true + case 'empty': + return false + case 'explicit': + return config.sources.some((source) => source.id === packageName) + } +} + +export function checkLoadAllowed( + use: string, + parsed: SkillUse, + params: { + config: SkillSourcesConfig + excludeMatchers: Array + }, +): LoadRefusal | null { + const { config, excludeMatchers } = params + const { packageName, skillName } = parsed + + if (isPackageExcluded(packageName, excludeMatchers)) { + return { + code: 'package-excluded', + message: `Cannot load skill use "${use}": package "${packageName}" is excluded by Intent configuration.`, + } + } + + if (!isSourcePermitted(config, packageName)) { + return { + code: 'package-not-listed', + message: `Cannot load skill use "${use}": package "${packageName}" is not listed in intent.skills.`, + } + } + + if (isSkillExcluded(packageName, skillName, excludeMatchers)) { + return { + code: 'skill-excluded', + message: `Cannot load skill use "${use}": skill "${packageName}#${skillName}" is excluded by Intent configuration.`, + } + } + + return null +} + +function formatUnlistedNotice(names: Array): string { + const sorted = [...names].sort() + const noun = sorted.length === 1 ? 'package ships' : 'packages ship' + return `${sorted.length} discovered ${noun} skills but ${sorted.length === 1 ? 'is' : 'are'} not listed in intent.skills: ${sorted.join(', ')}. Add to opt in.` +} + +export interface SourcePolicyResult { + packages: Array + notices: Array +} + +export function applySourcePolicy( + scanResult: { packages: Array }, + options: SourcePolicyOptions, +): SourcePolicyResult { + const { config, excludeMatchers } = options + const seen = new Set() + const notices: Array = [] + + const emit = (notice: string): void => { + if (seen.has(notice)) return + seen.add(notice) + notices.push(notice) + } + + const packages: Array = [] + const unlistedNames: Array = [] + + for (const pkg of scanResult.packages) { + if (isPackageExcluded(pkg.name, excludeMatchers)) continue + + if (!isSourcePermitted(config, pkg.name)) { + if (config.mode === 'explicit') { + unlistedNames.push(pkg.name) + } + continue + } + + const skills = pkg.skills.filter( + (skill) => !isSkillExcluded(pkg.name, skill.name, excludeMatchers), + ) + packages.push( + skills.length === pkg.skills.length ? pkg : { ...pkg, skills }, + ) + } + + if (unlistedNames.length > 0) { + emit(formatUnlistedNotice(unlistedNames)) + } + + if (config.mode === 'explicit') { + const discoveredNames = new Set(scanResult.packages.map((pkg) => pkg.name)) + for (const source of config.sources) { + if (!discoveredNames.has(source.id)) { + emit( + `"${source.raw}" is declared in intent.skills but was not discovered.`, + ) + } + } + } + + if (config.mode === 'absent') emit(MIGRATION_NOTICE) + else if (config.mode === 'allow-all') emit(ALLOW_ALL_NOTICE) + else if (config.mode === 'empty') emit(EMPTY_NOTE) + + return { packages, notices } +} + +// A null/undefined intent.skills is treated as not-declared so it cannot +// shadow a stricter parent allowlist. +export function readSkillSourcesConfig( + cwd: string, + context: ProjectContext = resolveProjectContext({ cwd }), +): SkillSourcesConfig { + for (const dir of getConfigDirs(cwd, context)) { + const intent = readPackageJson(dir)?.intent + if (!intent || typeof intent !== 'object') continue + + if ('skills' in intent) { + const skills = (intent as Record).skills + if (skills === null || skills === undefined) continue + return parseSkillSources(skills) + } + } + + return { mode: 'absent' } +} + +export interface PolicedScan { + scan: ScanResult + excludePatterns: Array +} + +export function scanForPolicedIntents(params: { + cwd: string + scanOptions: ScanOptions + coreOptions: IntentCoreOptions + context?: ProjectContext +}): PolicedScan { + const { cwd, scanOptions, coreOptions } = params + const context = params.context ?? resolveProjectContext({ cwd }) + + const scanResult = scanForIntents(cwd, scanOptions) + const config = readSkillSourcesConfig(cwd, context) + const excludePatterns = getEffectiveExcludePatterns(coreOptions, context) + const excludeMatchers = compileExcludePatterns(excludePatterns) + + const policy = applySourcePolicy(scanResult, { + config, + excludeMatchers, + }) + + const survivingNames = new Set(policy.packages.map((pkg) => pkg.name)) + const droppedNames = scanResult.packages + .map((pkg) => pkg.name) + .filter((name) => !survivingNames.has(name)) + + return { + scan: { + ...scanResult, + packages: policy.packages, + warnings: scanResult.warnings.filter( + (warning) => + !droppedNames.some((name) => warningMentionsPackage(warning, name)), + ), + notices: policy.notices, + conflicts: scanResult.conflicts.filter((conflict) => + survivingNames.has(conflict.packageName), + ), + }, + excludePatterns, + } +} diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 2ed0e06..379d9c5 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -39,6 +39,7 @@ export interface IntentSkillList { skills: Array packages: Array warnings: Array + notices: Array conflicts: Array debug?: IntentSkillListDebug } @@ -66,6 +67,7 @@ export interface IntentSkillListDebug { packageCount: number skillCount: number warningCount: number + noticeCount: number conflictCount: number scan: IntentScanDebugStats } @@ -91,6 +93,8 @@ export type IntentCoreErrorCode = | 'invalid-skill-use' | 'package-not-found' | 'package-excluded' + | 'package-not-listed' + | 'skill-excluded' | 'skill-not-found' | 'skill-path-outside-package' | 'skill-file-not-found' diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 27ad110..d679e2b 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,3 +1,7 @@ +// Static-discovery invariant: discovery reads package data as files and never +// executes discovered package code. The only sanctioned dynamic load is Yarn's +// PnP runtime (.pnp.cjs / pnpapi), used solely to map identities to readable +// roots. Enforced by the `intent/static-discovery` ESLint rule. import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -111,6 +115,7 @@ function loadPnpApi(root: string): LoadedPnp | null { const readFs = requireFromHere('node:fs') as unknown as ReadFs try { + // eslint-disable-next-line no-restricted-syntax -- sanctioned PnP runtime load const pnpModule = requireFromHere(pnpPath) as PnpApi if (typeof pnpModule.setup === 'function') { pnpModule.setup() @@ -662,6 +667,7 @@ export function scanForIntents( packageManager, packages, warnings, + notices: [], conflicts, nodeModules, stats: getStats(), @@ -690,6 +696,7 @@ export function scanForIntents( packageManager, packages: sorted, warnings, + notices: [], conflicts, nodeModules, stats: getStats(), diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index 6115af2..1abc747 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -17,6 +17,7 @@ export interface ScanResult { packageManager: PackageManager packages: Array warnings: Array + notices: Array conflicts: Array nodeModules: { local: NodeModulesScanTarget diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 30483e4..3b8c85a 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -12,9 +12,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { INSTALL_PROMPT } from '../src/commands/install.js' -import { runLoadCommand } from '../src/commands/load.js' import { isMainModule, main } from '../src/cli.js' -import type { ScanOptions, ScanResult } from '../src/types.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') @@ -368,6 +366,41 @@ describe('cli commands', () => { expect(readFileSync(agentsPath, 'utf8')).toBe(content) }) + it('omits unlisted packages from the install --map block', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-allowlist-')) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-install-allowlist-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/unlisted', + version: '1.0.0', + skillName: 'panel', + description: 'Unlisted panel skill', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.chdir(root) + + const exitCode = await main(['install', '--map']) + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8') + + expect(exitCode).toBe(0) + expect(content).toContain('use: "@tanstack/query#fetching"') + expect(content).not.toContain('@tanstack/unlisted') + }) + it('ignores configured global packages during install --map by default', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-local-only-')) const globalRoot = mkdtempSync( @@ -549,6 +582,11 @@ describe('cli commands', () => { tempDirs.push(root, isolatedGlobalRoot) const pkgDir = join(root, 'node_modules', '@tanstack', 'db') + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/db'] }, + }) writeJson(join(pkgDir, 'package.json'), { name: '@tanstack/db', version: '0.5.2', @@ -664,6 +702,7 @@ describe('cli commands', () => { writeJson(join(root, 'package.json'), { name: 'app', private: true, + intent: { skills: ['@tanstack/query'] }, dependencies: { wrapper: '1.0.0', }, @@ -695,6 +734,33 @@ describe('cli commands', () => { expect(output).not.toContain('Could not read') }) + it('prints the intent.skills migration notice to stderr, not stdout', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-migration-')) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-list-migration-empty-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.chdir(root) + + const exitCode = await main(['list']) + const stdout = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(stdout).toContain('@tanstack/query') + expect(stderr).toContain('intent.skills is not set') + expect(stdout).not.toContain('intent.skills is not set') + expect(stdout).not.toContain('Notices:') + }) + it('prints list debug details to stderr without changing json stdout', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-debug-')) tempDirs.push(root) @@ -1351,21 +1417,6 @@ describe('cli commands', () => { ) }) - it('validates load use strings before scanning', async () => { - const scanSpy = vi.fn( - async (_options?: ScanOptions): Promise => { - throw new Error('should not scan') - }, - ) - - await expect( - runLoadCommand('@tanstack/query', {}, scanSpy), - ).rejects.toThrow( - 'Invalid skill use "@tanstack/query": expected #.', - ) - expect(scanSpy).not.toHaveBeenCalled() - }) - it('fails cleanly when load cannot find the package', async () => { const root = mkdtempSync( join(realTmpdir, 'intent-cli-load-missing-package-'), diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index a190a7f..9cacc37 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -93,6 +93,11 @@ afterEach(() => { describe('listIntentSkills', () => { it('returns a flat skill list and package summaries', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) writeInstalledIntentPackage(root, { name: '@tanstack/query', version: '5.0.0', @@ -129,11 +134,17 @@ describe('listIntentSkills', () => { }, ], warnings: [], + notices: [], conflicts: [], }) }) it('includes debug metadata when requested', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) writeInstalledIntentPackage(root, { name: '@tanstack/query', version: '5.0.0', @@ -154,6 +165,7 @@ describe('listIntentSkills', () => { packageCount: 1, skillCount: 1, warningCount: 0, + noticeCount: 0, conflictCount: 0, scan: expect.objectContaining({ packageJsonReadCount: expect.any(Number), @@ -229,6 +241,129 @@ describe('listIntentSkills', () => { ) } }) + + it('surfaces only allowlisted packages and warns about an unlisted one', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/unlisted', + version: '1.0.0', + skillName: 'panel', + description: 'Unlisted skill', + }) + + const result = listIntentSkills({ cwd: root }) + + expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @tanstack/unlisted. Add to opt in.', + ]) + }) + + it('drops a skill-level excluded skill from an allowlisted package', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: ['@tanstack/query#legacy'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeSkillMd({ + dir: join(root, 'node_modules', '@tanstack', 'query', 'skills', 'legacy'), + frontmatter: { name: 'legacy', description: 'Legacy skill' }, + }) + + const result = listIntentSkills({ cwd: root }) + + expect(result.skills.map((skill) => skill.use)).toEqual([ + '@tanstack/query#fetching', + ]) + expect(result.packages[0]?.skillCount).toBe(1) + }) + + it('warns about migration when intent.skills is absent', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = listIntentSkills({ cwd: root }) + + expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) + expect(result.notices).toEqual([ + 'intent.skills is not set — all discovered skill sources are surfaced. A future version will require an explicit intent.skills allowlist; add one to opt in to specific sources.', + ]) + }) + + it('permits nothing and notes an empty allowlist', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: [] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = listIntentSkills({ cwd: root }) + + expect(result.packages).toEqual([]) + expect(result.skills).toEqual([]) + expect(result.notices).toEqual([ + 'intent.skills is empty — no skill sources are permitted.', + ]) + }) + + it('keeps an allowlisted package whose only skill is skill-excluded as a skillCount-0 entry', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: ['@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const result = listIntentSkills({ cwd: root }) + + expect(result.skills).toEqual([]) + expect(result.packages).toEqual([ + { + name: '@tanstack/query', + version: '5.0.0', + source: 'local', + packageRoot: join(root, 'node_modules', '@tanstack', 'query'), + skillCount: 0, + }, + ]) + }) }) describe('loadIntentSkill', () => { @@ -472,6 +607,41 @@ describe('loadIntentSkill', () => { ) }) + it('refuses a prefixed skill excluded by canonical name when loaded by short alias', () => { + 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: ['@tanstack/router-core'], + exclude: ['@tanstack/router-core#router-core/auth-and-guards'], + }, + }) + 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', 'router-core', 'auth-and-guards'), + frontmatter: { + name: 'router-core/auth-and-guards', + description: 'Router auth and guards', + }, + }) + + expect(() => + loadIntentSkill('@tanstack/router-core#auth-and-guards', { cwd: appDir }), + ).toThrow( + 'Cannot load skill use "@tanstack/router-core#auth-and-guards": skill "@tanstack/router-core#auth-and-guards" is excluded by Intent configuration.', + ) + }) + it('loads a dependency declared by a workspace package without a root link', () => { const appDir = join(root, 'packages', 'app') const storeDir = join(root, '.store', '@tanstack', 'query') @@ -631,4 +801,66 @@ describe('loadIntentSkill', () => { 'Cannot load skill use "@tanstack/devtools#panel": package "@tanstack/devtools" is excluded by Intent configuration.', ) }) + + it('refuses to load a package not listed in intent.skills', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@tanstack/router'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": package "@tanstack/query" is not listed in intent.skills.', + ) + }) + + it('refuses to load a skill-level excluded skill before the fast path resolves it', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: ['@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": skill "@tanstack/query#fetching" is excluded by Intent configuration.', + ) + }) + + it('loads a listed skill that is not excluded', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const loaded = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + + expect(loaded.packageName).toBe('@tanstack/query') + expect(loaded.skillName).toBe('fetching') + }) }) diff --git a/packages/intent/tests/excludes.test.ts b/packages/intent/tests/excludes.test.ts new file mode 100644 index 0000000..8ff007b --- /dev/null +++ b/packages/intent/tests/excludes.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + compileExcludePatterns, + isPackageExcluded, + isSkillExcluded, +} from '../src/core/excludes.js' + +describe('exclude matching — package level (backward compatible)', () => { + it('excludes a whole package by exact name', () => { + const matchers = compileExcludePatterns(['@scope/pkg']) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(true) + expect(isPackageExcluded('@scope/other', matchers)).toBe(false) + }) + + it('excludes a whole package via a package-segment glob', () => { + const matchers = compileExcludePatterns(['@scope/*']) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(true) + expect(isPackageExcluded('@other/pkg', matchers)).toBe(false) + }) + + it('treats a whole-package exclusion as excluding all of its skills', () => { + const matchers = compileExcludePatterns(['@scope/pkg']) + expect(isSkillExcluded('@scope/pkg', 'anything', matchers)).toBe(true) + }) + + it('rejects an overly long pattern', () => { + expect(() => compileExcludePatterns(['@scope/'.padEnd(201, 'x')])).toThrow( + 'Intent exclude pattern is too long', + ) + }) +}) + +describe('exclude matching — skill level', () => { + it('excludes a single named skill without removing the package', () => { + const matchers = compileExcludePatterns(['@scope/pkg#search-params']) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(false) + expect(isSkillExcluded('@scope/pkg', 'search-params', matchers)).toBe(true) + expect(isSkillExcluded('@scope/pkg', 'routing', matchers)).toBe(false) + }) + + it('excludes skills matching a skill-segment glob', () => { + const matchers = compileExcludePatterns(['@scope/pkg#experimental-*']) + expect(isSkillExcluded('@scope/pkg', 'experimental-router', matchers)).toBe( + true, + ) + expect(isSkillExcluded('@scope/pkg', 'stable-router', matchers)).toBe(false) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(false) + }) + + it('does not match a skill-level pattern against a different package', () => { + const matchers = compileExcludePatterns(['@scope/pkg#search-params']) + expect(isSkillExcluded('@scope/other', 'search-params', matchers)).toBe( + false, + ) + }) + + it('matches a prefixed skill excluded by its canonical name when queried by short alias', () => { + const matchers = compileExcludePatterns(['@tanstack/router#router/guards']) + expect(isSkillExcluded('@tanstack/router', 'guards', matchers)).toBe(true) + expect(isSkillExcluded('@tanstack/router', 'router/guards', matchers)).toBe( + true, + ) + }) + + it('matches a prefixed skill excluded by short alias when queried by canonical name', () => { + const matchers = compileExcludePatterns(['@tanstack/router#guards']) + expect(isSkillExcluded('@tanstack/router', 'router/guards', matchers)).toBe( + true, + ) + }) +}) + +describe('exclude matching — #* whole-package shortcut', () => { + it('treats pkg#* as a whole-package exclusion', () => { + const matchers = compileExcludePatterns(['@scope/pkg#*']) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(true) + expect(isSkillExcluded('@scope/pkg', 'anything', matchers)).toBe(true) + }) + + it('treats *#* as excluding every package', () => { + const matchers = compileExcludePatterns(['*#*']) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(true) + expect(isPackageExcluded('@other/thing', matchers)).toBe(true) + }) + + it('treats a multi-star skill segment (pkg#**) as the whole-package shortcut', () => { + const matchers = compileExcludePatterns(['@scope/pkg#**']) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(true) + expect(isSkillExcluded('@scope/pkg', 'anything', matchers)).toBe(true) + }) +}) + +describe('exclude matching — degenerate patterns are safe no-ops', () => { + it('treats an empty skill segment (pkg#) as a no-op, not whole-package', () => { + const matchers = compileExcludePatterns(['@scope/pkg#']) + expect(isPackageExcluded('@scope/pkg', matchers)).toBe(false) + expect(isSkillExcluded('@scope/pkg', 'routing', matchers)).toBe(false) + }) + + it('treats an empty package segment (#skill) as matching no real package', () => { + const matchers = compileExcludePatterns(['#search-params']) + expect(isSkillExcluded('@scope/pkg', 'search-params', matchers)).toBe(false) + }) + + it('keeps the skill after the first # when the name has multiple #', () => { + const matchers = compileExcludePatterns(['@scope/pkg#a#b']) + expect(isSkillExcluded('@scope/pkg', 'a#b', matchers)).toBe(true) + expect(isSkillExcluded('@scope/pkg', 'a', matchers)).toBe(false) + }) +}) + +describe('exclude matching — cross-package skill patterns', () => { + it('excludes a skill name across every package with *#experimental-*', () => { + const matchers = compileExcludePatterns(['*#experimental-*']) + expect(isSkillExcluded('@scope/a', 'experimental-x', matchers)).toBe(true) + expect(isSkillExcluded('@other/b', 'experimental-y', matchers)).toBe(true) + expect(isSkillExcluded('@scope/a', 'stable', matchers)).toBe(false) + expect(isPackageExcluded('@scope/a', matchers)).toBe(false) + }) +}) + +describe('exclude matching — combined patterns', () => { + it('applies the union of package-level and skill-level patterns', () => { + const matchers = compileExcludePatterns([ + '@scope/gone', + '@scope/kept#experimental-*', + ]) + expect(isPackageExcluded('@scope/gone', matchers)).toBe(true) + expect(isPackageExcluded('@scope/kept', matchers)).toBe(false) + expect(isSkillExcluded('@scope/kept', 'experimental-x', matchers)).toBe( + true, + ) + expect(isSkillExcluded('@scope/kept', 'stable', matchers)).toBe(false) + }) +}) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index a28316a..0a76adb 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -57,6 +57,7 @@ function scanResult(packages: Array): ScanResult { packageManager: 'pnpm', packages, warnings: [], + notices: [], conflicts: [], nodeModules: { local: { diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts new file mode 100644 index 0000000..220c867 --- /dev/null +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -0,0 +1,152 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +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.js' +import { main } from '../../src/cli.js' + +const realTmpdir = realpathSync(tmpdir()) + +function writeJson(filePath: string, data: unknown): void { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + +function writeIntentPackage( + baseDir: string, + name: string, + skillName: string, +): void { + const pkgDir = join(baseDir, 'node_modules', ...name.split('/')) + writeJson(join(pkgDir, 'package.json'), { + name, + 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`, + ) +} + +const LISTED = '@scope/listed' +const UNLISTED = '@scope/unlisted' +const EXCLUDED = '@scope/excluded' + +describe('source policy — all four surfaces filter excluded and unlisted', () => { + let root: string + let originalCwd: string + let logSpy: ReturnType + + beforeEach(() => { + originalCwd = process.cwd() + root = mkdtempSync(join(realTmpdir, 'intent-g4-')) + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + process.chdir(originalCwd) + vi.restoreAllMocks() + delete process.env.INTENT_GLOBAL_NODE_MODULES + rmSync(root, { recursive: true, force: true }) + }) + + function writeStandaloneFixture(): void { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [LISTED], exclude: [EXCLUDED] }, + }) + writeIntentPackage(root, LISTED, 'core') + writeIntentPackage(root, UNLISTED, 'core') + writeIntentPackage(root, EXCLUDED, 'core') + } + + it('list surfaces only the listed package', () => { + writeStandaloneFixture() + + const result = listIntentSkills({ cwd: root }) + + expect(result.packages.map((pkg) => pkg.name)).toEqual([LISTED]) + expect(result.notices.some((notice) => notice.includes(UNLISTED))).toBe( + true, + ) + expect(result.notices.some((notice) => notice.includes(EXCLUDED))).toBe( + false, + ) + expect(result.warnings.some((warning) => warning.includes(UNLISTED))).toBe( + false, + ) + }) + + it('load refuses the unlisted and excluded packages but allows the listed one', () => { + writeStandaloneFixture() + + expect(() => loadIntentSkill(`${UNLISTED}#core`, { cwd: root })).toThrow( + `package "${UNLISTED}" is not listed in intent.skills`, + ) + expect(() => loadIntentSkill(`${EXCLUDED}#core`, { cwd: root })).toThrow( + `package "${EXCLUDED}" is excluded by Intent configuration`, + ) + expect(loadIntentSkill(`${LISTED}#core`, { cwd: root }).packageName).toBe( + LISTED, + ) + }) + + it('install --map writes only the listed package into the block', async () => { + writeStandaloneFixture() + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-g4-global-'), + ) + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.chdir(root) + + const exitCode = await main(['install', '--map', '--dry-run']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain(`use: "${LISTED}#core"`) + expect(output).not.toContain(`use: "${UNLISTED}#core"`) + expect(output).not.toContain(`use: "${EXCLUDED}#core"`) + + rmSync(isolatedGlobalRoot, { recursive: true, force: true }) + }) + + it('stale (discovered-dependency fallback) reports only the listed package', async () => { + writeJson(join(root, 'package.json'), { + name: 'monorepo', + private: true, + workspaces: ['packages/*'], + intent: { skills: [LISTED], exclude: [EXCLUDED] }, + }) + writeJson(join(root, 'packages', 'app', 'package.json'), { + name: '@scope/app', + }) + writeIntentPackage(root, LISTED, 'core') + writeIntentPackage(root, UNLISTED, 'core') + writeIntentPackage(root, EXCLUDED, 'core') + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ library: string }> + + expect(exitCode).toBe(0) + expect(reports.map((report) => report.library)).toEqual([LISTED]) + + fetchSpy.mockRestore() + }) +}) diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 605dadd..6a6e502 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -47,6 +47,7 @@ function scanResult( ): ScanResult { return { conflicts, + notices: [], nodeModules: { global: { detected: false, diff --git a/packages/intent/tests/skill-sources.test.ts b/packages/intent/tests/skill-sources.test.ts new file mode 100644 index 0000000..8816959 --- /dev/null +++ b/packages/intent/tests/skill-sources.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest' +import { + SkillSourcesParseError, + isSkillSourcesParseError, + parseSkillSources, +} from '../src/core/skill-sources.js' + +function expectParseError(value: unknown): SkillSourcesParseError { + try { + parseSkillSources(value) + } catch (err) { + if (isSkillSourcesParseError(err)) return err + throw err + } + throw new Error('Expected parseSkillSources to throw') +} + +describe('parseSkillSources — list-level modes', () => { + it('treats an absent key (undefined) as the migration show-all mode', () => { + expect(parseSkillSources(undefined)).toEqual({ mode: 'absent' }) + }) + + it('treats null as absent', () => { + expect(parseSkillSources(null)).toEqual({ mode: 'absent' }) + }) + + it('treats an empty array as deny-all (distinct from absent)', () => { + expect(parseSkillSources([])).toEqual({ mode: 'empty' }) + }) + + it('treats the "*" sentinel as allow-all', () => { + expect(parseSkillSources(['*'])).toEqual({ mode: 'allow-all' }) + }) + + it('rejects a non-array value', () => { + const error = expectParseError('@scope/pkg') + expect(error.issues).toEqual([ + { + raw: null, + message: + 'intent.skills must be an array of source strings, received string.', + }, + ]) + }) +}) + +describe('parseSkillSources — grammar', () => { + it('parses a bare name as an npm source', () => { + expect(parseSkillSources(['pkg'])).toEqual({ + mode: 'explicit', + sources: [{ raw: 'pkg', id: 'pkg', kind: 'npm' }], + }) + }) + + it('parses a scoped name as an npm source', () => { + expect(parseSkillSources(['@scope/pkg'])).toEqual({ + mode: 'explicit', + sources: [{ raw: '@scope/pkg', id: '@scope/pkg', kind: 'npm' }], + }) + }) + + it('parses a workspace: entry as a workspace source', () => { + expect(parseSkillSources(['workspace:@scope/pkg'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: 'workspace:@scope/pkg', id: '@scope/pkg', kind: 'workspace' }, + ], + }) + }) + + it('treats a colon-free entry that looks like a kind as an npm name', () => { + expect(parseSkillSources(['workspace'])).toEqual({ + mode: 'explicit', + sources: [{ raw: 'workspace', id: 'workspace', kind: 'npm' }], + }) + }) + + it('trims leading/trailing whitespace from an entry id', () => { + expect(parseSkillSources([' @scope/pkg '])).toEqual({ + mode: 'explicit', + sources: [{ raw: ' @scope/pkg ', id: '@scope/pkg', kind: 'npm' }], + }) + }) + + it('preserves the original raw string while normalizing the id', () => { + const result = parseSkillSources(['workspace: @scope/pkg ']) + expect(result).toEqual({ + mode: 'explicit', + sources: [ + { + raw: 'workspace: @scope/pkg ', + id: '@scope/pkg', + kind: 'workspace', + }, + ], + }) + }) +}) + +describe('parseSkillSources — malformed entries (fail-whole-list)', () => { + it('rejects a git: entry', () => { + const error = expectParseError(['git:github.com/me/skills#main']) + expect(error.issues).toHaveLength(1) + expect(error.issues[0]).toMatchObject({ + raw: 'git:github.com/me/skills#main', + }) + expect(error.issues[0]?.message).toContain('not supported until') + }) + + it('rejects an unknown prefix', () => { + const error = expectParseError(['file:./local']) + expect(error.issues).toEqual([ + { + raw: 'file:./local', + message: 'Unknown source prefix "file" in "file:./local".', + }, + ]) + }) + + it('rejects an empty workspace name', () => { + const error = expectParseError(['workspace:']) + expect(error.issues[0]?.message).toContain('missing a package name') + }) + + it('rejects an empty / whitespace-only entry', () => { + const error = expectParseError([' ']) + expect(error.issues).toEqual([{ raw: ' ', message: 'Entry is empty.' }]) + }) + + it('rejects a non-string entry', () => { + const error = expectParseError([42]) + expect(error.issues).toEqual([ + { raw: null, message: 'Entry must be a string, received number.' }, + ]) + }) + + it('rejects exact duplicate raw entries', () => { + const error = expectParseError(['@scope/pkg', '@scope/pkg']) + expect(error.issues).toEqual([ + { raw: '@scope/pkg', message: 'Duplicate entry.' }, + ]) + }) + + it('collects every error across the list and reports them together', () => { + const error = expectParseError([ + 'git:github.com/me/skills#main', + 'file:./local', + 'workspace:', + '', + ]) + expect(error.issues).toHaveLength(4) + expect(error.issues.map((issue) => issue.raw)).toEqual([ + 'git:github.com/me/skills#main', + 'file:./local', + 'workspace:', + '', + ]) + }) + + it('does not apply a partial allowlist when any entry is malformed', () => { + expect(() => + parseSkillSources(['@scope/good', 'git:github.com/me/skills#main']), + ).toThrow(SkillSourcesParseError) + }) +}) + +describe('parseSkillSources — normalization and dedup', () => { + it('dedups the same id+kind from differently-formatted raw entries', () => { + const result = parseSkillSources(['@scope/pkg', ' @scope/pkg ']) + expect(result).toEqual({ + mode: 'explicit', + sources: [{ raw: '@scope/pkg', id: '@scope/pkg', kind: 'npm' }], + }) + }) + + it('keeps the same name under different kinds as distinct sources', () => { + expect(parseSkillSources(['foo', 'workspace:foo'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: 'foo', id: 'foo', kind: 'npm' }, + { raw: 'workspace:foo', id: 'foo', kind: 'workspace' }, + ], + }) + }) + + it('treats id case variance as distinct (case-sensitive)', () => { + expect(parseSkillSources(['@scope/foo', '@scope/FOO'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: '@scope/foo', id: '@scope/foo', kind: 'npm' }, + { raw: '@scope/FOO', id: '@scope/FOO', kind: 'npm' }, + ], + }) + }) + + it('treats prefix case variance as an unknown prefix', () => { + const error = expectParseError(['Workspace:foo']) + expect(error.issues[0]?.message).toContain( + 'Unknown source prefix "Workspace"', + ) + }) +}) + +describe('parseSkillSources — wildcard composition', () => { + it('subsumes redundant npm/workspace entries listed alongside "*"', () => { + expect(parseSkillSources(['*', '@scope/pkg', 'workspace:foo'])).toEqual({ + mode: 'allow-all', + }) + }) + + it('fails the whole list when "*" is combined with a git entry', () => { + const error = expectParseError(['*', 'git:github.com/me/skills#main']) + expect(error.issues).toHaveLength(1) + expect(error.issues[0]?.raw).toBe('git:github.com/me/skills#main') + }) + + it('rejects a duplicate "*" entry', () => { + const error = expectParseError(['*', '*']) + expect(error.issues).toEqual([{ raw: '*', message: 'Duplicate entry.' }]) + }) + + it('requires the wildcard to be the exact string "*" (whitespace-wrapped is rejected)', () => { + const error = expectParseError([' * ']) + expect(error.issues[0]?.message).toContain('must be the exact entry "*"') + }) + + it('does not flip to allow-all from a non-breaking-space-wrapped star', () => { + const error = expectParseError(['\u00A0*\u00A0']) + 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('still throws a duplicate error even when "*" subsumes the duplicated entry', () => { + const error = expectParseError(['*', 'x', 'x']) + expect(error.issues).toEqual([{ raw: 'x', message: 'Duplicate entry.' }]) + }) +}) + +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('rejects internal whitespace in a package name', () => { + const error = expectParseError(['a b']) + expect(error.issues[0]?.message).toContain('cannot contain whitespace') + }) + + it('rejects a stray separator in a workspace name', () => { + const error = expectParseError(['workspace:a:b']) + expect(error.issues[0]?.message).toContain('cannot contain ":"') + }) +}) + +describe('parseSkillSources — error reporting', () => { + it('reports both the parse error and the duplicate for a repeated invalid entry', () => { + const error = expectParseError([ + 'git:github.com/me/skills#main', + 'git:github.com/me/skills#main', + ]) + expect(error.issues).toHaveLength(2) + expect(error.issues[0]?.message).toContain('not supported until') + expect(error.issues[1]?.message).toBe('Duplicate entry.') + }) + + it('renders a human-readable message listing every issue', () => { + const error = expectParseError(['file:./local', ' ']) + expect(error.message).toContain('Invalid intent.skills configuration:') + expect(error.message).toContain( + '"file:./local": Unknown source prefix "file"', + ) + expect(error.message).toContain('Entry is empty.') + }) + + it('describes an array entry by type', () => { + const error = expectParseError([['nested']]) + expect(error.issues).toEqual([ + { raw: null, message: 'Entry must be a string, received array.' }, + ]) + }) +}) diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts new file mode 100644 index 0000000..4948e7c --- /dev/null +++ b/packages/intent/tests/source-policy.test.ts @@ -0,0 +1,353 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} 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 { + ALLOW_ALL_NOTICE, + EMPTY_NOTE, + MIGRATION_NOTICE, + applySourcePolicy, + readSkillSourcesConfig, +} from '../src/core/source-policy.js' +import { parseSkillSources } from '../src/core/skill-sources.js' +import type { IntentPackage, SkillEntry } from '../src/types.js' + +const realTmpdir = realpathSync(tmpdir()) + +function skill(name: string): SkillEntry { + return { name, path: `/pkg/skills/${name}/SKILL.md`, description: name } +} + +function pkg(name: string, skillNames: Array): IntentPackage { + return { + name, + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: '' }, + skills: skillNames.map(skill), + packageRoot: `/root/node_modules/${name}`, + source: 'local', + } +} + +function config(value: unknown) { + return parseSkillSources(value) +} + +function names(packages: Array): Array { + return packages.map((p) => p.name) +} + +describe('applySourcePolicy — allowlist matrix', () => { + it('includes a listed and discovered package', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x'])] }, + { config: config(['@scope/a']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(result.notices).toEqual([]) + }) + + it('drops an unlisted discovered package and warns', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x']), pkg('@scope/b', ['y'])] }, + { config: config(['@scope/a']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @scope/b. Add to opt in.', + ]) + }) + + it('collapses several unlisted packages into one sorted summary warning', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/a', ['x']), + pkg('@scope/c', ['y']), + pkg('@scope/b', ['z']), + ], + }, + { config: config(['@scope/a']), excludeMatchers: [] }, + ) + expect(result.notices).toEqual([ + '2 discovered packages ship skills but are not listed in intent.skills: @scope/b, @scope/c. Add to opt in.', + ]) + }) + + it('warns when a listed source was not discovered', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x'])] }, + { config: config(['@scope/a', '@scope/missing']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(result.notices).toEqual([ + '"@scope/missing" is declared in intent.skills but was not discovered.', + ]) + }) + + it('matches by name only, so workspace:foo authorizes an npm-discovered foo (M1 baseline)', () => { + const result = applySourcePolicy( + { packages: [pkg('foo', ['x'])] }, + { config: config(['workspace:foo']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['foo']) + expect(result.notices).toEqual([]) + }) + + it('does not trust a discovered dependency just because its dependent is listed', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/listed', ['x']), pkg('@scope/dep', ['y'])] }, + { config: config(['@scope/listed']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/listed']) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @scope/dep. Add to opt in.', + ]) + }) + + it('emits unlisted warnings before not-discovered warnings deterministically', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/unlisted', ['x'])] }, + { + config: config(['@scope/missing']), + excludeMatchers: [], + }, + ) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @scope/unlisted. Add to opt in.', + '"@scope/missing" is declared in intent.skills but was not discovered.', + ]) + }) + + it('does not mutate the input scan packages', () => { + const input = pkg('@scope/a', ['keep', 'drop']) + applySourcePolicy( + { packages: [input] }, + { + config: config(['@scope/a']), + excludeMatchers: compileExcludePatterns(['@scope/a#drop']), + }, + ) + expect(input.skills.map((s) => s.name)).toEqual(['keep', 'drop']) + }) +}) + +describe('applySourcePolicy — permit-all and empty modes', () => { + it('permits every discovered source under allow-all with a loud notice', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x']), pkg('@scope/b', ['y'])] }, + { config: config(['*']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/a', '@scope/b']) + expect(result.notices).toEqual([ALLOW_ALL_NOTICE]) + }) + + it('permits every discovered source under absent config with a migration warning', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x'])] }, + { config: config(undefined), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(result.notices).toEqual([MIGRATION_NOTICE]) + }) + + it('permits nothing under empty config with a quiet info note', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x'])] }, + { config: config([]), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) + }) + + it('stays quiet under empty config even with several discovered packages', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x']), pkg('@scope/b', ['y'])] }, + { config: config([]), excludeMatchers: [] }, + ) + expect(result.notices).toEqual([EMPTY_NOTE]) + }) +}) + +describe('applySourcePolicy — exclude interaction', () => { + it('subtracts an excluded package on top of allow-all', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x']), pkg('@scope/bad', ['y'])] }, + { + config: config(['*']), + excludeMatchers: compileExcludePatterns(['@scope/bad']), + }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + }) + + it('subtracts an excluded package on top of absent (migration) mode', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x']), pkg('@scope/bad', ['y'])] }, + { + config: config(undefined), + excludeMatchers: compileExcludePatterns(['@scope/bad']), + }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(result.notices).toEqual([MIGRATION_NOTICE]) + }) + + it('treats an unlisted+excluded package as excluded with no unlisted warning', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x']), pkg('@scope/bad', ['y'])] }, + { + config: config(['@scope/a']), + excludeMatchers: compileExcludePatterns(['@scope/bad']), + }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(result.notices).toEqual([]) + }) + + it('does not report a listed+excluded package as undiscovered', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/bad', ['y'])] }, + { + config: config(['@scope/bad']), + excludeMatchers: compileExcludePatterns(['@scope/bad']), + }, + ) + expect(names(result.packages)).toEqual([]) + expect(result.notices).toEqual([]) + }) + + it('removes skill-level excluded skills while keeping the package', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['keep', 'drop'])] }, + { + config: config(['@scope/a']), + excludeMatchers: compileExcludePatterns(['@scope/a#drop']), + }, + ) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]?.skills.map((s) => s.name)).toEqual(['keep']) + }) +}) + +describe('applySourcePolicy — warning dedup', () => { + it('emits each warning only once within a single call', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/a', ['x']), + pkg('@scope/b', ['y']), + pkg('@scope/c', ['z']), + ], + }, + { config: config(['@scope/a']), excludeMatchers: [] }, + ) + const counts = result.notices.reduce>( + (acc, notice) => { + acc[notice] = (acc[notice] ?? 0) + 1 + return acc + }, + {}, + ) + expect(Object.values(counts).every((count) => count === 1)).toBe(true) + }) +}) + +describe('readSkillSourcesConfig', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(join(realTmpdir, 'intent-policy-config-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + function writeJson(filePath: string, data: unknown): void { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(data, null, 2)) + } + + it('returns absent when no package.json declares intent.skills', () => { + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + expect(readSkillSourcesConfig(root)).toEqual({ mode: 'absent' }) + }) + + it('returns empty when intent.skills is an empty array', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [] }, + }) + expect(readSkillSourcesConfig(root)).toEqual({ mode: 'empty' }) + }) + + it('parses an explicit allowlist', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@scope/a', 'workspace:b'] }, + }) + expect(readSkillSourcesConfig(root)).toEqual({ + mode: 'explicit', + sources: [ + { raw: '@scope/a', id: '@scope/a', kind: 'npm' }, + { raw: 'workspace:b', id: 'b', kind: 'workspace' }, + ], + }) + }) + + it('prefers the nearest package.json that declares the key over the workspace root', () => { + const appDir = join(root, 'packages', 'app') + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'package.json'), { + name: 'monorepo', + private: true, + intent: { skills: ['@scope/root'] }, + }) + writeJson(join(appDir, 'package.json'), { + name: '@scope/app', + intent: { skills: ['@scope/app-local'] }, + }) + + expect(readSkillSourcesConfig(appDir)).toEqual({ + mode: 'explicit', + sources: [ + { raw: '@scope/app-local', id: '@scope/app-local', kind: 'npm' }, + ], + }) + }) + + it('ignores a null intent.skills so it cannot shadow a stricter parent', () => { + const appDir = join(root, 'packages', 'app') + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'package.json'), { + name: 'monorepo', + private: true, + intent: { skills: ['@scope/root'] }, + }) + writeJson(join(appDir, 'package.json'), { + name: '@scope/app', + intent: { skills: null }, + }) + + expect(readSkillSourcesConfig(appDir)).toEqual({ + mode: 'explicit', + sources: [{ raw: '@scope/root', id: '@scope/root', kind: 'npm' }], + }) + }) +})