diff --git a/.changeset/many-forks-cough.md b/.changeset/many-forks-cough.md new file mode 100644 index 00000000..2a6b106a --- /dev/null +++ b/.changeset/many-forks-cough.md @@ -0,0 +1,13 @@ +--- +'@tanstack/intent': minor +--- + +Add consumer-managed `intent.lock` files for reviewing and pinning the exact skill content approved for a project. + +- Add `intent skills scan`, `diff`, `approve`, and `update` for inspecting drift, reviewing exact current text and binary summaries, and maintaining approved sources. +- Track sources by `(kind, id)` so same-named workspace and npm packages remain distinct approvals. +- Hash each skill’s `SKILL.md` plus supported `references/`, `assets/`, and `scripts/` files with deterministic SHA-256 content hashes. +- Add frozen-mode enforcement for CI through `--frozen`, `INTENT_FROZEN`, and non-interactive `CI` detection. Frozen mode rejects missing or malformed lockfiles, unapproved source changes, hidden skill-bearing sources, and lockfile mutations. +- When an `intent.lock` exists, reject drift during ordinary skill loading and agent catalog generation so interactive agents cannot silently consume content that differs from the approved state. +- Validate manifests against the installed package identity, skill paths, and content hashes. Manifest metadata changes appear in lockfile diffs and require approval before frozen checks pass. +- Export lockfile metadata types from `@tanstack/intent`. diff --git a/benchmarks/intent/README.md b/benchmarks/intent/README.md new file mode 100644 index 00000000..cadc1a73 --- /dev/null +++ b/benchmarks/intent/README.md @@ -0,0 +1,19 @@ +# Intent Benchmarks + +## Lockfile Scan Baseline + +Run from the repository root: + +```sh +pnpm --dir benchmarks/intent exec vitest bench --config ./vitest.config.ts ./lockfile-scan.bench.ts +``` + +Local baseline recorded on 2026-07-09: + +| Case | Fixture | Mean | +| ----------------- | --------------------------------------------------------------------- | ---------: | +| Clean lockfile | 8 packages, 3 skills per package, 3 support files per skill at 1 KiB | 9.2180 ms | +| Changed skill | Same fixture, one `SKILL.md` changed after approval | 9.1127 ms | +| Large support set | 24 packages, 4 skills per package, 6 support files per skill at 8 KiB | 50.7923 ms | + +The fixture creates `intent.lock` with `intent skills approve --all --yes` before each scan. Re-run these cases after changing lockfile discovery or hashing and compare the same fixture means. diff --git a/benchmarks/intent/helpers.ts b/benchmarks/intent/helpers.ts index 011fd52b..84754bb1 100644 --- a/benchmarks/intent/helpers.ts +++ b/benchmarks/intent/helpers.ts @@ -153,7 +153,7 @@ export function createTempDir(name: string): string { return mkdtempSync(join(tmpdir(), `intent-bench-${name}-`)) } -export function writeFile(filePath: string, content: string): void { +export function writeFile(filePath: string, content: string | Buffer): void { mkdirSync(dirname(filePath), { recursive: true }) writeFileSync(filePath, content) } diff --git a/benchmarks/intent/lockfile-scan.bench.ts b/benchmarks/intent/lockfile-scan.bench.ts new file mode 100644 index 00000000..777f5338 --- /dev/null +++ b/benchmarks/intent/lockfile-scan.bench.ts @@ -0,0 +1,137 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { + createBenchOptions, + createCliRunner, + createConsoleSilencer, + createTempDir, + writeFile, + writeJson, + writePackage, +} from './helpers.js' + +type LockfileFixture = { + root: string + runner: ReturnType +} + +type FixtureOptions = { + changedSkill?: boolean + packageCount: number + skillCount: number + supportFileCount: number + supportFileSize: number +} + +function createFixture(name: string, options: FixtureOptions): LockfileFixture { + const root = createTempDir(name) + const packageNames = Array.from( + { length: options.packageCount }, + (_, index) => `@bench/lock-${index}`, + ) + writeJson(join(root, 'package.json'), { + name: `intent-lockfile-${name}-benchmark`, + private: true, + intent: { skills: packageNames }, + }) + + for (const packageName of packageNames) { + const skills = Array.from( + { length: options.skillCount }, + (_, index) => `skill-${index}`, + ) + writePackage(join(root, 'node_modules'), packageName, '1.0.0', { skills }) + const packageRoot = join(root, 'node_modules', ...packageName.split('/')) + for (const skillName of skills) { + const skillDir = join(packageRoot, 'skills', skillName) + for (let index = 0; index < options.supportFileCount; index++) { + const directory = ['references', 'assets', 'scripts'][index % 3]! + const extension = directory === 'scripts' ? 'mjs' : 'dat' + const content = + directory === 'assets' + ? Buffer.alloc(options.supportFileSize, index) + : 'x'.repeat(options.supportFileSize) + writeFile( + join(skillDir, directory, `support-${index}.${extension}`), + content, + ) + } + } + } + + return { root, runner: createCliRunner({ cwd: root }) } +} + +function defineScenario(name: string, options: FixtureOptions): void { + const consoleSilencer = createConsoleSilencer() + let fixture: LockfileFixture | null = null + + async function setup(): Promise { + if (fixture) return + + consoleSilencer.silence() + fixture = createFixture(name, options) + await fixture.runner.setup() + await fixture.runner.run(['skills', 'approve', '--all', '--yes']) + if (options.changedSkill) { + writeFile( + join( + fixture.root, + 'node_modules', + '@bench', + 'lock-0', + 'skills', + 'skill-0', + 'SKILL.md', + ), + '---\nname: skill-0\ndescription: changed benchmark skill\n---\n\nChanged.\n', + ) + } + } + + function teardown(): void { + if (fixture) { + fixture.runner.teardown() + rmSync(fixture.root, { recursive: true, force: true }) + fixture = null + } + consoleSilencer.restore() + } + + describe(`intent skills scan --json (${name})`, () => { + beforeAll(setup) + afterAll(teardown) + + bench( + 'scans lockfile state', + async () => { + if (!fixture) throw new Error('Lockfile fixture was not initialized') + await fixture.runner.run(['skills', 'scan', '--json']) + }, + createBenchOptions(setup, teardown), + ) + }) +} + +defineScenario('clean-eight-packages', { + packageCount: 8, + skillCount: 3, + supportFileCount: 3, + supportFileSize: 1024, +}) + +defineScenario('changed-skill', { + changedSkill: true, + packageCount: 8, + skillCount: 3, + supportFileCount: 3, + supportFileSize: 1024, +}) + +defineScenario('large-support-set', { + packageCount: 24, + skillCount: 4, + supportFileCount: 6, + supportFileSize: 8 * 1024, +}) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index ada58a1a..f619b495 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -117,7 +117,9 @@ The list as a whole has three special forms: - **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. In agent sessions, hidden sources are reported by count only; run `intent list --show-hidden` outside the agent session to review candidates. A listed package that was not discovered is reported as well. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). +A package that ships skills but is not listed is dropped. Human-facing output includes bounded dependency provenance when available, otherwise `provenance unknown`. In agent sessions, hidden sources are reported by count only; run `intent list --show-hidden` outside the agent session to review candidates. A listed package that was not discovered is reported as well. Matching uses `(kind, id)`. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). + +When an npm and workspace source share a name and the requested skill, `intent load` rejects the unqualified use as ambiguous. ## Excludes @@ -133,7 +135,7 @@ Manage persistent excludes with `intent exclude add|remove|list`. } ``` -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. +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. Prefix a package segment with `npm:` or `workspace:` to target one source kind. 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. diff --git a/docs/cli/intent-skills.md b/docs/cli/intent-skills.md new file mode 100644 index 00000000..ef1046d2 --- /dev/null +++ b/docs/cli/intent-skills.md @@ -0,0 +1,95 @@ +--- +title: intent skills +id: intent-skills +--- + +`intent skills` manages `intent.lock`, the committed record of which skill-bearing sources you've approved and what their content looked like when you approved it. Four subcommands: `scan`, `diff` (read-only), `approve`, and `update` (mutating). + +```bash +npx intent skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen] +``` + +See [Lockfile and frozen mode](../security/lockfile) for what `intent.lock` is and what frozen mode guarantees. + +## `intent skills scan` + +```bash +npx intent skills scan [--json] [--frozen] [--no-frozen] +``` + +Read-only. Discovers current skill-bearing sources, computes each source's `contentHash`, and reports drift against `intent.lock`. + +- No lock found: prints `No intent.lock found. Run \`intent skills approve --all\` to create one.` +- Lock is clean: prints `intent.lock is up to date.` +- Lock is stale: prints `intent.lock is out of date: N added, N removed, N changed.` +- Discovered sources not in `intent.skills`: names each source with bounded dependency provenance when available, falls back to `provenance unknown`, and points at `intent.skills`/`intent.exclude`. Agent-mode output remains count-only. +- `--json` prints `{ frozen, hiddenSourceCount, hasLockfile, added, removed, changed, isClean }` + +## `intent skills diff` + +```bash +npx intent skills diff [--json] [--frozen] [--no-frozen] +``` + +Read-only. Same underlying computation as `scan`, but change-focused: prints `Added:`/`Removed:`/`Changed:` sections with per-field diffs (`version`, `resolution`, `skills`, `contentHash`, `manifestHash`, `capabilities`). It then displays the complete current canonical text for every added or changed source. Binary files are summarized by path, byte length, and hash. Control and bidirectional characters are escaped in the line-numbered text display so package content cannot manipulate terminal output. Unchanged sources are omitted. + +``` +Changed: + ~ npm:@acme/query + version: "1.0.0" -> "1.1.0" + resolution: "npm:@acme/query@1.0.0" -> "npm:@acme/query@1.1.0" + contentHash: "sha256-492ac4..." -> "sha256-2631b3..." +``` + +## `intent skills approve [source]` + +```bash +npx intent skills approve [source] [--all] [--yes] +``` + +Writes `intent.lock`. This is the trust decision. Before any prompt or non-interactive write, Intent displays the current canonical text and binary summaries for the affected sources. Removed sources display their locked skill paths and aggregate hash because the old file bytes are not stored in `intent.lock`. + +- **No arg, no `--all`/`--yes`:** interactive per-pending-change prompt (approve/skip each). Fails if stdin isn't a TTY. +- **`--all` or `--yes`:** displays every pending change, then accepts them without prompting. This is the first-run path that creates the initial lock. +- **A single source:** `approve npm:@tanstack/query`, `approve workspace:my-package`, or a bare name (`approve foo`) if it resolves unambiguously against currently-discovered sources. Two sources sharing a bare name across kinds (`npm:foo` and `workspace:foo`) error instead of guessing — pass `kind:id` explicitly. +- Re-serializes the whole file deterministically: identical inputs produce a byte-identical `intent.lock`. +- Only touches the targeted entry (single-source form) or all pending changes (`--all`/`--yes`) — never silently drops an entry you didn't act on. +- Refuses in frozen mode (exit `5`). + +## `intent skills update [source]` + +```bash +npx intent skills update [source] [--all] [--yes] +``` + +Writes `intent.lock`. It mechanically re-syncs version and resolution for matching **already-locked** entries. Changes to skills, content hashes, manifests, capabilities, declared secrets, or MCP metadata require `--yes`; before writing them, `update` displays the same current content review as `diff` and `approve`. + +- Only touches sources present in **both** the lock and the current scan. It never adds a newly-discovered source (that's `approve`'s job) and never drops a source that's no longer discovered (also `approve`'s job — removing a source from the trust boundary is itself a trust decision). +- Reports pending added/removed drift it didn't touch: `N added, M removed source(s) still pending. Run \`intent skills approve\` to review.` +- Makes zero network calls and zero subprocess calls — it only reads what's already on disk. +- Refuses in frozen mode (exit `5`). + +## Options + +- `--json`: with `scan`/`diff`, print the structured diff instead of text +- `--all`: with `approve`/`update`, act on all pending changes without prompting +- `--yes`: with `approve`, accept all pending changes non-interactively; with `update`, accept reviewed trust-bearing changes +- `--frozen`: force frozen mode, regardless of `INTENT_FROZEN`/`CI` auto-detection +- `--no-frozen`: force interactive mode — overrides `INTENT_FROZEN` and the `CI` auto-detect (highest-precedence explicit override) + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | ok | +| `1` | generic CLI usage or parse error | +| `2` | drift found under frozen mode | +| `3` | unapproved/unlisted skill-bearing source found under frozen mode | +| `4` | no `intent.lock` found under frozen mode | +| `5` | `approve`/`update` refused because frozen mode disallows mutation | +| `6` | `intent.lock` is malformed or from an unsupported (newer) `lockfileVersion` | + +## Related + +- [Lockfile and frozen mode](../security/lockfile) +- [Trust model](../concepts/trust-model) diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 60fbeaf0..83574d84 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -30,7 +30,9 @@ Each array entry names one source: | `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. +A malformed entry fails the whole command, and every bad entry is reported at once. Intent matches a source by `(kind, id)`: `workspace:foo` never authorizes an npm-installed `foo`. + +If both `npm:foo` and `workspace:foo` provide the same requested skill, `intent load foo#skill` fails as ambiguous instead of selecting one source by discovery order. Narrow the allowlist to one source before loading it. ### Special forms @@ -40,7 +42,7 @@ The list as a whole has three special forms: - **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. +A package that ships skills but is not listed is dropped. Human-facing output includes bounded dependency provenance when available, otherwise `provenance unknown`. A listed package that was not discovered is reported as well. ### Existing projects @@ -85,4 +87,6 @@ Pattern grammar: - A pattern may cross package boundaries at skill granularity: `*#experimental-*`. - The `#*` shortcut excludes the whole package: `@scope/pkg#*`. +Prefix a package segment with `npm:` or `workspace:` to target one source kind, for example `workspace:foo` or `npm:foo#experimental-*`. Bare package patterns remain broad and match either kind. + 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 index b8fba139..af71139f 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -21,8 +21,10 @@ Intent reads package data as files. It never imports, requires, or executes the 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 +## Source identity and remaining limits -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. +Intent matches allowlist entries by `(kind, id)`. `workspace:foo` authorizes only the workspace member named `foo`; it does not authorize an installed `npm:foo`. A bare `foo` entry is normalized to the npm source kind. Same-named workspace and npm packages remain distinct sources throughout discovery, locking, and approval. + +Source trust and content approval remain separate decisions. When `intent.lock` exists, ordinary skill loading and agent catalog generation reject installed content that differs from the approved hashes. A missing lockfile does not silently approve anything; outside frozen mode it preserves the bootstrap path until the user reviews content with `intent skills diff` and writes the first lock with `intent skills approve`. 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 11e09090..b424bfa2 100644 --- a/docs/config.json +++ b/docs/config.json @@ -37,6 +37,10 @@ { "label": "Configuration", "to": "concepts/configuration" + }, + { + "label": "Lockfile and frozen mode", + "to": "security/lockfile" } ] }, @@ -82,6 +86,10 @@ { "label": "intent stale", "to": "cli/intent-stale" + }, + { + "label": "intent skills", + "to": "cli/intent-skills" } ] } diff --git a/docs/security/lockfile.md b/docs/security/lockfile.md new file mode 100644 index 00000000..9f53711f --- /dev/null +++ b/docs/security/lockfile.md @@ -0,0 +1,95 @@ +--- +title: Lockfile and frozen mode +id: lockfile +--- + +`intent.lock` is a committed, per-project record of which skill-bearing sources you've approved and what their content looked like when you approved it. It closes the gap [source trust](../concepts/trust-model) leaves open: `package.json#intent.skills` controls which packages *may* contribute skills, but nothing records what those sources *contained* when you allowed them — so an allowlisted package could silently change its skill content and nothing would notice. `intent.lock` is that record. + +This is tamper-evidence, not semantic validation. Before approval, Intent displays the complete current canonical text for every added or changed skill file. Binary files are identified by path, byte length, and hash. Approving a source means **a human reviewed the displayed content** — never "Intent verified this skill is safe." + +The lockfile stores hashes and paths, not historical file contents. Intent therefore cannot reconstruct a unified old/new text diff after a dependency update. For changed sources it displays the complete current content being approved; for removed sources it displays the locked paths and aggregate hash. This keeps the lockfile compact and preserves offline operation without pretending old bytes are available. + +## What's in the file + +`intent.lock` lives at the project root, alongside `package.json`. It's strict JSON (not JSONC), canonically serialized (sorted keys, two-space indent, trailing newline) so identical inputs always produce a byte-identical file. + +```json +{ + "lockfileVersion": 1, + "intentVersion": "0.3.5", + "sources": [ + { + "id": "@acme/query", + "kind": "npm", + "version": "1.0.0", + "resolution": "npm:@acme/query@1.0.0", + "skills": ["skills/fetching/SKILL.md"], + "contentHash": "sha256-492ac46894f5f36ebbf314b8312e320b5e3c7836b824b0a74f1a639728a877d7", + "manifestHash": null, + "capabilities": null + } + ], + "policy": { "ignores": [] } +} +``` + +- **`sources[]`** is keyed by `(kind, id)`, never `id` alone — `workspace:foo` and `npm:foo` are distinct entries and distinct approvals. +- **`skills[]`** is the sorted list of package-relative `SKILL.md` paths in the aggregate. Supporting-file changes still change `contentHash`. +- **`contentHash`** is a `sha256-` digest over each listed `SKILL.md` plus files under that skill's `references/`, `assets/`, and `scripts/` directories. Text line endings normalize to LF; binary bytes remain exact. A file rename with identical bytes changes the hash. +- **`manifestHash`** and **`capabilities`** are `null` until the package ships a `skills/intent.manifest.json`. An existing manifest must parse and match the installed package identity, skill paths, and per-skill hashes. Once it does, `manifestHash` is populated and `capabilities` is always an array: `[]` means the manifest declares none; a non-empty array is the union of declared capabilities. Manifest authoring remains M3 work. `declaredSecrets`, `mcpTools`, and `mcpPolicy` remain reserved fields for the current lockfile version. +- **`policy.ignores`** is a reserved block; nothing writes to it yet, but it's round-tripped verbatim if present. +- **`staleness.baseline`** (`{ kind: "tag", ref, commit }`) is reserved for the M7 staleness workflow. M2 validates and preserves it when rewriting an existing lockfile, but no M2 command derives behavior from it. +- A `lockfileVersion` newer than this Intent version supports, an undeclared field, a duplicate `(kind, id)` entry, a non-canonical skill path, or any other structural problem is a **malformed lockfile**. Intent fails closed and never silently treats it as an empty lock. + +## Commands + +`intent.lock` is managed entirely by the [`intent skills`](../cli/intent-skills) command group: `scan`/`diff` (read-only) and `approve`/`update` (mutating). + +## Activation enforcement + +When `intent.lock` exists, ordinary `intent load` verifies the installed allowlisted sources against it before returning skill content. Agent-audience `intent list` does the same before producing the catalog used by Intent hooks. Any added, removed, or changed approved source blocks activation and points the user to `intent skills diff` and `intent skills approve`. + +A project without `intent.lock` retains bootstrap behavior outside frozen mode: listed skills can load and appear in agent catalogs until the project creates its first lock. Human `intent list` remains diagnostic so a user can inspect the project while resolving drift. + +## Frozen mode + +Frozen mode is the CI gate: it turns "an allowlisted source's content silently drifted" from a warning into a hard failure, and guarantees the check itself makes no outbound network calls or subprocess calls. + +**Detection, highest precedence first:** + +1. `--no-frozen` flag — forces interactive, overriding everything below +2. `--frozen` flag — forces frozen +3. `INTENT_FROZEN` truthy (`1`/`true`/`yes`/`on`) +4. `CI` truthy **and** stdin is not a TTY — auto-detect +5. otherwise interactive + +**What frozen mode does:** + +- Refuses `approve`/`update` outright — no mutation, exit `5`. +- Still allows `scan`, `diff`, `list`, `load` (read-only). +- Fails on any pending drift — added, removed, or changed source (exit `2`). +- Fails on a discovered skill-bearing source that isn't in `intent.lock` (exit `3`). +- Fails if there's no `intent.lock` at all (exit `4`) — run `approve --all` interactively first. +- Fails closed on a malformed or unsupported `intent.lock` (exit `6`). +- `scan` and `diff` make zero network calls and skip subprocess-based global package-manager detection. + +See [`intent skills`](../cli/intent-skills#exit-codes) for the full exit-code table. + +## Consumer CI + +Run the frozen scan in the consumer repository after dependencies and `intent.lock` are present: + +```yaml +- name: Verify approved skill sources + run: npx intent skills scan --frozen +``` + +The generated `Check Skills` workflow is for library-maintainer validation and review; it does not add this consumer lockfile gate automatically. + +## What this does and doesn't solve + +- **Solves:** an allowlisted package's approved skill content changing silently during ordinary agent loading or in CI. +- **Solves:** distinguishing a `workspace:foo` package from a same-named `npm:foo` package — they're separate approvals. +- **Does not solve:** deciding whether a package should be trusted in the first place — that's still `package.json#intent.skills`, a human decision. +- **Does not solve:** validating that skill content is semantically safe or correct — approving is "a human reviewed this," not "Intent verified this." +- **Does not solve:** anything about a `git:` source kind — that kind is still parsed and rejected, same as before this feature. diff --git a/packages/intent/README.md b/packages/intent/README.md index 8a4aabdd..974375a7 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -98,6 +98,8 @@ npx @tanstack/intent@latest setup ## Compatibility +Intent requires Node.js 20 or newer. + | Environment | Status | Notes | | -------------- | ----------- | -------------------------------------------------- | | Node.js + npm | Supported | Use `npx @tanstack/intent@latest ` | @@ -130,6 +132,12 @@ The real risk with any derived artifact is staleness. `npx @tanstack/intent@late | `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files | | `npx @tanstack/intent@latest setup` | Copy CI templates into your repo | | `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift | +| `npx @tanstack/intent@latest skills scan` | Discover sources and diff against `intent.lock` | +| `npx @tanstack/intent@latest skills diff` | Review pending metadata and current skill content | +| `npx @tanstack/intent@latest skills approve` | Review and approve changes into `intent.lock` | +| `npx @tanstack/intent@latest skills update` | Re-sync already-locked sources to installed state | + +See [Lockfile and frozen mode](https://tanstack.com/intent/latest/docs/security/lockfile) and [`intent skills`](https://tanstack.com/intent/latest/docs/cli/intent-skills) for what `intent.lock` is and full command details. ## License diff --git a/packages/intent/package.json b/packages/intent/package.json index 3aad3487..c3fafef7 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -4,6 +4,9 @@ "description": "Ship compositional knowledge for AI coding agents alongside your npm packages", "license": "MIT", "type": "module", + "engines": { + "node": ">=20" + }, "repository": { "type": "git", "url": "https://github.com/TanStack/intent" diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index d317b9d7..18645061 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -10,6 +10,9 @@ import type { HooksInstallCommandOptions } from './commands/hooks/command.js' import type { InstallCommandOptions } from './commands/install/command.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' +import type { SkillsApproveCommandOptions } from './commands/skills/approve.js' +import type { SkillsScanCommandOptions } from './commands/skills/scan.js' +import type { SkillsUpdateCommandOptions } from './commands/skills/update.js' import type { StaleCommandOptions } from './commands/stale.js' import type { ValidateCommandOptions } from './commands/validate.js' @@ -208,6 +211,96 @@ function createCli(): CAC { }, ) + cli + .command( + 'skills [action] [source]', + 'Scan, diff, approve, or update approved skills', + ) + .usage( + 'skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen]', + ) + .option('--json', 'Output JSON') + .option( + '--all', + 'With `approve`/`update`, act on all pending changes without prompting', + ) + .option( + '--yes', + 'With `approve`/`update`, accept trust-bearing changes non-interactively', + ) + .option( + '--frozen', + 'Force frozen mode (fail if intent.lock is missing or stale)', + ) + .option( + '--no-frozen', + 'Force interactive mode, overriding INTENT_FROZEN/CI auto-detect', + ) + .example('skills scan') + .example('skills diff') + .example('skills scan --json') + .example('skills approve --all') + .example('skills approve --yes') + .example('skills approve npm:@tanstack/query') + .example('skills update --all') + .action( + async ( + action: string | undefined, + source: string | undefined, + options: SkillsScanCommandOptions & + SkillsApproveCommandOptions & + SkillsUpdateCommandOptions, + ) => { + const { scanPolicedIntentsOrFail, frozenOptionsFromGlobalFlags } = + await import('./commands/support.js') + const frozenOptions = frozenOptionsFromGlobalFlags(options, cli.rawArgs) + + if (action === 'scan') { + const { runSkillsScanCommand } = + await import('./commands/skills/scan.js') + await runSkillsScanCommand( + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + if (action === 'diff') { + const { runSkillsDiffCommand } = + await import('./commands/skills/diff.js') + await runSkillsDiffCommand( + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + if (action === 'approve') { + const { runSkillsApproveCommand } = + await import('./commands/skills/approve.js') + await runSkillsApproveCommand( + source, + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + if (action === 'update') { + const { runSkillsUpdateCommand } = + await import('./commands/skills/update.js') + await runSkillsUpdateCommand( + source, + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + fail('Unknown skills action: expected scan, diff, approve, or update.') + }, + ) + cli .command( 'edit-package-json', @@ -269,7 +362,17 @@ function createCli(): CAC { command.outputHelp() }) - cli.help() + cli.help((sections) => + sections.map((section) => ({ + ...section, + // CAC always annotates negated flags as defaulting to true. Intent reads + // raw argv for this tri-state flag, so that generated suffix is false. + body: section.body.replace( + /^(\s*--no-frozen\b[^\n]*?) \(default: true\)$/m, + '$1', + ), + })), + ) return cli } diff --git a/packages/intent/src/commands/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts index 0a7e4f19..f5591381 100644 --- a/packages/intent/src/commands/install/guidance.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -295,8 +295,8 @@ export function buildIntentSkillsBlock( ] let mappingCount = 0 - for (const pkg of [...scanResult.packages].sort(compareNames)) { - for (const skill of [...pkg.skills].sort(compareNames)) { + for (const pkg of scanResult.packages.toSorted(compareNames)) { + for (const skill of pkg.skills.toSorted(compareNames)) { if (!isGeneratedMappingSkill(skill)) continue mappingCount++ diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0fc45c5e..9fcd72e1 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,5 +1,6 @@ import { detectIntentAudience } from '../shared/environment.js' import { formatIntentCommand } from '../shared/command-runner.js' +import { escapeReviewValue } from '../shared/cli-output.js' import { listIntentSkills } from '../core/index.js' import { coreOptionsFromGlobalFlags, @@ -101,8 +102,11 @@ function printHiddenSources(result: IntentSkillList, audience: string): void { console.log('\nHidden skill sources:\n') for (const source of result.hiddenSources) { + const provenance = source.provenance + ?.map((path) => path.map(escapeReviewValue).join(' -> ')) + .join('; ') console.log( - ` ${source.name} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})`, + ` ${source.kind}:${escapeReviewValue(source.name)} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})${provenance ? ` via ${provenance}` : ' (provenance unknown)'}`, ) } } diff --git a/packages/intent/src/commands/skills/approve.ts b/packages/intent/src/commands/skills/approve.ts new file mode 100644 index 00000000..b7028b08 --- /dev/null +++ b/packages/intent/src/commands/skills/approve.ts @@ -0,0 +1,286 @@ +import { createInterface } from 'node:readline/promises' +import { getIntentPackageVersion } from '../support.js' +import { + assertSourceContentReviewsMatch, + buildSourceContentReviews, +} from '../../core/lockfile/content-review.js' +import { writeIntentLockfile } from '../../core/lockfile/lockfile.js' +import { sourceIdentityKey } from '../../core/types.js' +import { isFrozenMode } from '../../shared/mode.js' +import { fail } from '../../shared/cli-error.js' +import { escapeReviewValue, formatReviewJson } from '../../shared/cli-output.js' +import { + computeLockfileState, + printSourceContentReviews, + resolveLockfilePath, + resolveSourceArg, +} from './support.js' +import type { LockfileFieldChange } from '../../core/lockfile/lockfile-diff.js' +import type { IntentLockfileSource } from '../../core/lockfile/lockfile.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsApproveCommandOptions { + all?: boolean + yes?: boolean + frozen?: boolean + noFrozen?: boolean +} + +type PendingChange = + | { kind: 'add'; identity: string; source: IntentLockfileSource } + | { kind: 'remove'; identity: string; source: IntentLockfileSource } + | { + kind: 'update' + identity: string + source: IntentLockfileSource + fields: Array + } + +export type ConfirmFn = (question: string) => Promise + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function formatDisclosures(source: IntentLockfileSource): string { + const parts: Array = [] + if (source.capabilities && source.capabilities.length > 0) { + parts.push( + `capabilities: ${source.capabilities.map(escapeReviewValue).join(', ')}`, + ) + } + if (source.declaredSecrets && source.declaredSecrets.length > 0) { + parts.push( + `declaredSecrets: ${source.declaredSecrets.map(escapeReviewValue).join(', ')}`, + ) + } + if (source.mcpTools && source.mcpTools.length > 0) { + parts.push(`mcpTools: ${source.mcpTools.map(escapeReviewValue).join(', ')}`) + } + return parts.length > 0 ? ` [${parts.join('; ')}]` : '' +} + +function describeChange(change: PendingChange): string { + const label = `${change.source.kind}:${escapeReviewValue(change.source.id)}@${escapeReviewValue(change.source.version)}` + switch (change.kind) { + case 'add': + return `Approve new source ${label}?${formatDisclosures(change.source)}` + case 'remove': + return `Approve removal of ${label} (no longer discovered)?` + case 'update': { + const fieldSummary = change.fields + .map( + (field) => + `${field.field}: ${formatReviewJson(field.from)} -> ${formatReviewJson(field.to)}`, + ) + .join('; ') + return `Approve change to ${label}? (${fieldSummary})` + } + } +} + +function printRemovedSourceReview(source: IntentLockfileSource): void { + console.log( + `Reviewing removal ${source.kind}:${escapeReviewValue(source.id)}@${escapeReviewValue(source.version)} (no longer discovered)`, + ) + console.log( + ` Locked skills: ${source.skills.length > 0 ? source.skills.map(escapeReviewValue).join(', ') : '(none)'}`, + ) + console.log(` Locked content hash: ${source.contentHash}`) + console.log() +} + +function printPendingReviews( + changes: ReadonlyArray, + scan: PolicedScan['scan'], + current: ReadonlyArray, +): void { + const currentIdentities = new Set( + changes + .filter((change) => change.kind !== 'remove') + .map((change) => change.identity), + ) + const reviews = buildSourceContentReviews( + scan.packages, + currentIdentities, + scan.readFs, + ) + assertSourceContentReviewsMatch(reviews, current) + const reviewByIdentity = new Map( + reviews.map((review) => [sourceIdentityKey(review), review]), + ) + + for (const change of changes) { + if (change.kind === 'remove') { + printRemovedSourceReview(change.source) + continue + } + const review = reviewByIdentity.get(change.identity) + if (!review) { + throw new Error( + `Internal error: no content review found for ${change.identity}.`, + ) + } + printSourceContentReviews([review]) + } +} + +async function defaultConfirm(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + try { + const answer = await rl.question(`${question} (y/N) `) + return answer.trim().toLowerCase() === 'y' + } finally { + rl.close() + } +} + +export async function runSkillsApproveCommand( + sourceArg: string | undefined, + options: SkillsApproveCommandOptions, + scanPolicedIntents: () => Promise, + cwd: string = process.cwd(), + confirm: ConfirmFn = defaultConfirm, +): Promise { + const frozen = isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + if (frozen) { + fail('`intent skills approve` cannot run in frozen mode.', 5) + } + + if (sourceArg && options.all) { + fail('Pass either a source id or --all, not both.') + } + + const { scan, hiddenSourceCount } = await scanPolicedIntents() + if (hiddenSourceCount > 0) { + console.log( + `${hiddenSourceCount} discovered skill-bearing source(s) are not listed in intent.skills and were not considered.`, + ) + } + + const { current, lockedResult, diff } = computeLockfileState(scan, cwd) + + const finalSources = new Map( + lockedResult.status === 'found' + ? lockedResult.lockfile.sources.map((source) => [ + sourceIdentityKey(source), + source, + ]) + : [], + ) + + const currentByIdentity = new Map( + current.map((source) => [sourceIdentityKey(source), source]), + ) + + // diffLockfileSources leaves added/removed/changed empty when there's no + // lockfile (a distinct state, not diff-against-empty) — first run must + // build pending changes from `current` directly, not `diff`. + const changes: Array = + lockedResult.status === 'missing' + ? current + .map((source) => ({ + kind: 'add' as const, + identity: sourceIdentityKey(source), + source, + })) + .toSorted((a, b) => compareStrings(a.identity, b.identity)) + : [ + ...diff.added.map((source) => ({ + kind: 'add' as const, + identity: sourceIdentityKey(source), + source, + })), + ...diff.changed.map((change) => { + const identity = sourceIdentityKey({ + kind: change.kind, + id: change.id, + }) + const source = currentByIdentity.get(identity) + if (!source) { + throw new Error( + `Internal error: no current source found for changed identity ${identity}.`, + ) + } + return { + kind: 'update' as const, + identity, + source, + fields: change.fields, + } + }), + ...diff.removed.map((source) => ({ + kind: 'remove' as const, + identity: sourceIdentityKey(source), + source, + })), + ] + + let candidates: Array + + if (sourceArg) { + const identity = sourceIdentityKey(resolveSourceArg(sourceArg, current)) + const match = changes.find((change) => change.identity === identity) + if (!match) { + fail( + `No pending change for "${sourceArg}". Run \`intent skills diff\` to see pending changes.`, + ) + } + candidates = [match] + } else if (changes.length === 0) { + console.log('intent.lock is up to date. Nothing to approve.') + return + } else { + candidates = changes + } + + printPendingReviews(candidates, scan, current) + + let toApply: Array + if (sourceArg || options.all || options.yes) { + toApply = candidates + } else { + if (confirm === defaultConfirm && process.stdin.isTTY !== true) { + fail( + '`intent skills approve` needs --all or a source id when stdin is not a TTY.', + ) + } + toApply = [] + for (const change of candidates) { + if (await confirm(describeChange(change))) { + toApply.push(change) + } + } + } + + if (toApply.length === 0) { + console.log('No changes approved. intent.lock left unchanged.') + return + } + + for (const change of toApply) { + if (change.kind === 'remove') { + finalSources.delete(change.identity) + } else { + finalSources.set(change.identity, change.source) + } + } + + writeIntentLockfile(resolveLockfilePath(cwd), { + lockfileVersion: 1, + intentVersion: getIntentPackageVersion(), + ...(lockedResult.status === 'found' && lockedResult.lockfile.staleness + ? { staleness: lockedResult.lockfile.staleness } + : {}), + sources: [...finalSources.values()], + policy: + lockedResult.status === 'found' + ? lockedResult.lockfile.policy + : { ignores: [] }, + }) + + console.log(`Wrote ${toApply.length} change(s) to intent.lock.`) +} diff --git a/packages/intent/src/commands/skills/diff.ts b/packages/intent/src/commands/skills/diff.ts new file mode 100644 index 00000000..688b5c41 --- /dev/null +++ b/packages/intent/src/commands/skills/diff.ts @@ -0,0 +1,128 @@ +import { + assertSourceContentReviewsMatch, + buildSourceContentReviews, +} from '../../core/lockfile/content-review.js' +import { sourceIdentityKey } from '../../core/types.js' +import { isFrozenMode } from '../../shared/mode.js' +import { escapeReviewValue, formatReviewJson } from '../../shared/cli-output.js' +import { + computeLockfileState, + enforceFrozenMode, + formatHiddenSourceDetails, + printSourceContentReviews, +} from './support.js' +import type { + LockfileDiffResult, + LockfileSourceChange, +} from '../../core/lockfile/lockfile-diff.js' +import type { IntentLockfileSource } from '../../core/lockfile/lockfile.js' +import type { IntentHiddenSourceSummary } from '../../core/types.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsDiffCommandOptions { + json?: boolean + frozen?: boolean + noFrozen?: boolean +} + +function formatSourceLabel(source: IntentLockfileSource): string { + return `${source.kind}:${escapeReviewValue(source.id)}@${escapeReviewValue(source.version)}` +} + +function formatChangeLabel(change: LockfileSourceChange): string { + return `${change.kind}:${escapeReviewValue(change.id)}` +} + +function printDiffDetails( + diff: LockfileDiffResult, + hiddenSourceCount: number, + hiddenSources: ReadonlyArray, +): void { + if (hiddenSourceCount > 0) { + console.log( + `${hiddenSourceCount} discovered skill-bearing source(s) are not listed in intent.skills${formatHiddenSourceDetails(hiddenSources)}. Add them to intent.skills or intent.exclude.`, + ) + } + + if (!diff.hasLockfile) { + console.log( + 'No intent.lock found. Run `intent skills approve --all` to create one.', + ) + return + } + + if (diff.isClean) { + console.log('intent.lock is up to date.') + return + } + + if (diff.added.length > 0) { + console.log('Added:') + for (const source of diff.added) { + console.log(` + ${formatSourceLabel(source)}`) + } + console.log() + } + + if (diff.removed.length > 0) { + console.log('Removed:') + for (const source of diff.removed) { + console.log(` - ${formatSourceLabel(source)}`) + console.log( + ` skills: ${source.skills.length > 0 ? source.skills.map(escapeReviewValue).join(', ') : '(none)'}`, + ) + console.log(` contentHash: ${source.contentHash}`) + } + console.log() + } + + if (diff.changed.length > 0) { + console.log('Changed:') + for (const change of diff.changed) { + console.log(` ~ ${formatChangeLabel(change)}`) + for (const field of change.fields) { + console.log( + ` ${field.field}: ${formatReviewJson(field.from)} -> ${formatReviewJson(field.to)}`, + ) + } + } + } +} + +export async function runSkillsDiffCommand( + options: SkillsDiffCommandOptions, + scanPolicedIntents: () => Promise, + cwd: string = process.cwd(), +): Promise { + const frozen = isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + const { scan, hiddenSourceCount, hiddenSources } = await scanPolicedIntents() + const { current, diff } = computeLockfileState(scan, cwd) + + if (options.json) { + console.log(JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2)) + } else { + printDiffDetails(diff, hiddenSourceCount, hiddenSources) + const reviewIdentities = new Set( + diff.hasLockfile + ? [ + ...diff.added.map(sourceIdentityKey), + ...diff.changed.map(sourceIdentityKey), + ] + : scan.packages.map((pkg) => + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ), + ) + const reviews = buildSourceContentReviews( + scan.packages, + reviewIdentities, + scan.readFs, + ) + assertSourceContentReviewsMatch(reviews, current) + printSourceContentReviews(reviews) + } + + enforceFrozenMode(diff, frozen, hiddenSourceCount, hiddenSources) +} diff --git a/packages/intent/src/commands/skills/scan.ts b/packages/intent/src/commands/skills/scan.ts new file mode 100644 index 00000000..1f75b248 --- /dev/null +++ b/packages/intent/src/commands/skills/scan.ts @@ -0,0 +1,67 @@ +import { isFrozenMode } from '../../shared/mode.js' +import { + buildSkillsDiff, + enforceFrozenMode, + formatHiddenSourceDetails, +} from './support.js' +import type { LockfileDiffResult } from '../../core/lockfile/lockfile-diff.js' +import type { IntentHiddenSourceSummary } from '../../core/types.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsScanCommandOptions { + json?: boolean + frozen?: boolean + noFrozen?: boolean +} + +function printScanSummary( + diff: LockfileDiffResult, + hiddenSourceCount: number, + hiddenSources: ReadonlyArray, +): void { + if (hiddenSourceCount > 0) { + console.log( + `${hiddenSourceCount} discovered skill-bearing source(s) are not listed in intent.skills${formatHiddenSourceDetails(hiddenSources)}. Add them to intent.skills or intent.exclude.`, + ) + } + + if (!diff.hasLockfile) { + console.log( + 'No intent.lock found. Run `intent skills approve --all` to create one.', + ) + return + } + + if (diff.isClean) { + console.log('intent.lock is up to date.') + return + } + + console.log( + `intent.lock is out of date: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed.`, + ) + console.log( + 'Run `intent skills diff` for details, or `intent skills approve` to update.', + ) +} + +export async function runSkillsScanCommand( + options: SkillsScanCommandOptions, + scanPolicedIntents: () => Promise, + cwd: string = process.cwd(), +): Promise { + const frozen = isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + const { scan, hiddenSourceCount, hiddenSources } = await scanPolicedIntents() + const diff = buildSkillsDiff(scan, cwd) + + if (options.json) { + console.log(JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2)) + } else { + printScanSummary(diff, hiddenSourceCount, hiddenSources) + } + + enforceFrozenMode(diff, frozen, hiddenSourceCount, hiddenSources) +} diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts new file mode 100644 index 00000000..91c0b80c --- /dev/null +++ b/packages/intent/src/commands/skills/support.ts @@ -0,0 +1,200 @@ +import { join } from 'node:path' +import { diffLockfileSources } from '../../core/lockfile/lockfile-diff.js' +import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { readIntentLockfile } from '../../core/lockfile/lockfile.js' +import { resolveProjectContext } from '../../core/project-context.js' +import { fail } from '../../shared/cli-error.js' +import { escapeReviewValue } from '../../shared/cli-output.js' +import type { LockfileDiffResult } from '../../core/lockfile/lockfile-diff.js' +import type { SourceContentReview } from '../../core/lockfile/content-review.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../../core/lockfile/lockfile.js' +import type { + IntentHiddenSourceSummary, + SourceIdentity, +} from '../../core/types.js' +import type { ScanResult } from '../../shared/types.js' + +export function resolveLockfilePath(cwd: string): string { + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? cwd + return join(root, 'intent.lock') +} + +// Shared by `approve` and `update`'s single-source argument form. +export function resolveSourceArg( + arg: string, + discovered: ReadonlyArray, +): SourceIdentity { + const separatorIndex = arg.indexOf(':') + + if (separatorIndex !== -1) { + const kind = arg.slice(0, separatorIndex) + let id = arg.slice(separatorIndex + 1) + + // Tolerate diff.ts's displayed kind:id@version label as input, but only + // strip a trailing @version, not a scoped package's leading @scope. + const lastAt = id.lastIndexOf('@') + if (lastAt > 0 && /^\d/.test(id.slice(lastAt + 1))) { + id = id.slice(0, lastAt) + } + + if (kind !== 'npm' && kind !== 'workspace') { + fail( + `Invalid source "${arg}". Expected the form kind:id, e.g. npm:@tanstack/query or workspace:my-package.`, + ) + } + + return { kind, id } + } + + // Bare name (F1 rule): resolve against currently-discovered sources. A name + // shared across kinds (e.g. workspace:foo and npm:foo) can't be guessed. + const matches = discovered.filter((source) => source.id === arg) + const [firstMatch] = matches + + if (!firstMatch) { + fail( + `No discovered source matches "${arg}". It may not be installed, or may not be listed in intent.skills.`, + ) + } + + if (matches.length > 1) { + const labels = matches + .map((source) => `${source.kind}:${source.id}`) + .sort() + .join(' and ') + fail(`Ambiguous source "${arg}": matches ${labels} — specify kind:id.`) + } + + return firstMatch +} + +export interface LockfileState { + current: Array + lockedResult: ReadIntentLockfileResult + diff: LockfileDiffResult +} + +export function computeLockfileState( + scan: ScanResult, + cwd: string, +): LockfileState { + const current = buildCurrentLockfileSources(scan.packages, scan.readFs) + const lockedResult = readLockfileOrFail(cwd) + const diff = diffLockfileSources(current, lockedResult) + return { current, lockedResult, diff } +} + +function readLockfileOrFail(cwd: string): ReadIntentLockfileResult { + try { + return readIntentLockfile(resolveLockfilePath(cwd)) + } catch (err) { + fail( + `Malformed intent.lock: ${err instanceof Error ? err.message : String(err)}`, + 6, + ) + } +} + +export function buildSkillsDiff( + scan: ScanResult, + cwd: string, +): LockfileDiffResult { + return computeLockfileState(scan, cwd).diff +} + +export function formatHiddenSourceDetails( + hiddenSources: ReadonlyArray, +): string { + if (hiddenSources.length === 0) return '' + + const details = hiddenSources + .toSorted((a, b) => + `${a.kind}:${a.name}`.localeCompare(`${b.kind}:${b.name}`), + ) + .map((source) => { + const provenance = source.provenance + ?.map((path) => path.map(escapeReviewValue).join(' -> ')) + .join('; ') + const label = `${source.kind}:${escapeReviewValue(source.name)}` + return provenance + ? `${label} (via ${provenance})` + : `${label} (provenance unknown)` + }) + .join(', ') + + return `: ${details}` +} + +function formatCanonicalText(content: Buffer): string { + return content + .toString('utf8') + .split('\n') + .map((line, index) => { + const escaped = escapeReviewValue(line) + return ` ${String(index + 1).padStart(4)} | ${escaped}` + }) + .join('\n') +} + +export function printSourceContentReviews( + reviews: ReadonlyArray, +): void { + for (const review of reviews) { + console.log( + `Reviewing ${review.kind}:${escapeReviewValue(review.id)}@${escapeReviewValue(review.version)}`, + ) + if (review.files.length === 0) { + console.log(' No skill content files.') + console.log() + continue + } + + for (const file of review.files) { + if (file.isBinary) { + console.log( + ` Binary: ${escapeReviewValue(file.relativePath)} (${file.byteLength} bytes, ${file.contentHash})`, + ) + continue + } + + console.log( + ` Text: ${escapeReviewValue(file.relativePath)} (canonical UTF-8, ${file.content.length} bytes)`, + ) + console.log(formatCanonicalText(file.content)) + } + console.log() + } +} + +export function enforceFrozenMode( + diff: LockfileDiffResult, + frozen: boolean, + hiddenSourceCount: number, + hiddenSources: ReadonlyArray = [], +): void { + if (!frozen) return + + if (hiddenSourceCount > 0) { + fail( + `Frozen mode found ${hiddenSourceCount} unlisted skill-bearing source(s) not in intent.skills${formatHiddenSourceDetails(hiddenSources)}. Add them to intent.skills or intent.exclude, then re-run outside frozen mode.`, + 3, + ) + } + + if (!diff.hasLockfile) { + fail( + 'Frozen mode requires intent.lock. Run `intent skills approve --all` outside frozen mode first.', + 4, + ) + } + if (!diff.isClean) { + fail( + 'intent.lock is out of date. Run `intent skills diff` outside frozen mode, then `intent skills approve`.', + 2, + ) + } +} diff --git a/packages/intent/src/commands/skills/update.ts b/packages/intent/src/commands/skills/update.ts new file mode 100644 index 00000000..755226ec --- /dev/null +++ b/packages/intent/src/commands/skills/update.ts @@ -0,0 +1,196 @@ +import { getIntentPackageVersion } from '../support.js' +import { + assertSourceContentReviewsMatch, + buildSourceContentReviews, +} from '../../core/lockfile/content-review.js' +import { writeIntentLockfile } from '../../core/lockfile/lockfile.js' +import { sourceIdentityKey } from '../../core/types.js' +import { isFrozenMode } from '../../shared/mode.js' +import { fail } from '../../shared/cli-error.js' +import { escapeReviewValue, formatReviewJson } from '../../shared/cli-output.js' +import { + computeLockfileState, + printSourceContentReviews, + resolveLockfilePath, + resolveSourceArg, +} from './support.js' +import type { + LockfileDiffResult, + LockfileSourceChange, +} from '../../core/lockfile/lockfile-diff.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsUpdateCommandOptions { + all?: boolean + yes?: boolean + frozen?: boolean + noFrozen?: boolean +} + +function requiresApproval(change: LockfileSourceChange): boolean { + return change.fields.some( + ({ field }) => + field === 'skills' || + field === 'contentHash' || + field === 'manifestHash' || + field === 'capabilities' || + field === 'declaredSecrets' || + field === 'mcpTools' || + field === 'mcpPolicy', + ) +} + +function formatChangeLabel(change: LockfileSourceChange): string { + return `${change.kind}:${escapeReviewValue(change.id)}` +} + +function printUpdated(changes: ReadonlyArray): void { + console.log(`Updated ${changes.length} source(s) in intent.lock:`) + for (const change of changes) { + console.log(` ~ ${formatChangeLabel(change)}`) + for (const field of change.fields) { + console.log( + ` ${field.field}: ${formatReviewJson(field.from)} -> ${formatReviewJson(field.to)}`, + ) + } + } +} + +// update only ever touches the changed set (§7.4); added/removed sources are +// approve's trust decision, so surface them here or the operator sees a +// clean "Updated N" with no sign that other drift still needs approve. +function printPendingAddRemove(diff: LockfileDiffResult): void { + if (diff.added.length === 0 && diff.removed.length === 0) return + console.log( + `${diff.added.length} added, ${diff.removed.length} removed source(s) still pending. Run \`intent skills approve\` to review.`, + ) +} + +export async function runSkillsUpdateCommand( + sourceArg: string | undefined, + options: SkillsUpdateCommandOptions, + scanPolicedIntents: () => Promise, + cwd: string = process.cwd(), +): Promise { + const frozen = isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + if (frozen) { + fail('`intent skills update` cannot run in frozen mode.', 5) + } + + if (sourceArg && options.all) { + fail('Pass either a source id or --all, not both.') + } + + const { scan } = await scanPolicedIntents() + const { current, lockedResult, diff } = computeLockfileState(scan, cwd) + + if (lockedResult.status === 'missing') { + fail( + 'No intent.lock found. Run `intent skills approve --all` to create one.', + ) + } + + let targets: Array + + if (sourceArg) { + const identity = sourceIdentityKey(resolveSourceArg(sourceArg, current)) + const lockedByIdentity = new Set( + lockedResult.lockfile.sources.map((source) => sourceIdentityKey(source)), + ) + const currentByIdentity = new Set( + current.map((source) => sourceIdentityKey(source)), + ) + + if (!lockedByIdentity.has(identity)) { + fail( + `"${sourceArg}" is not in intent.lock. Run \`intent skills approve\` first.`, + ) + } + if (!currentByIdentity.has(identity)) { + fail( + `"${sourceArg}" is locked but no longer discovered; nothing to update.`, + ) + } + + const match = diff.changed.find( + (change) => + sourceIdentityKey({ kind: change.kind, id: change.id }) === identity, + ) + if (!match) { + console.log( + `intent.lock already matches the installed state for "${sourceArg}". Nothing to update.`, + ) + printPendingAddRemove(diff) + return + } + targets = [match] + } else { + targets = diff.changed + } + + if (targets.length === 0) { + console.log( + 'intent.lock already matches installed sources. Nothing to update.', + ) + printPendingAddRemove(diff) + return + } + + if (targets.some(requiresApproval) && !options.yes) { + fail( + 'Trust-bearing source changes require `--yes`. Run `intent skills diff` to review, then re-run with `--yes` to update intent.lock.', + ) + } + + const trustBearingIdentities = new Set( + targets + .filter(requiresApproval) + .map((change) => sourceIdentityKey({ kind: change.kind, id: change.id })), + ) + if (trustBearingIdentities.size > 0) { + const reviews = buildSourceContentReviews( + scan.packages, + trustBearingIdentities, + scan.readFs, + ) + assertSourceContentReviewsMatch(reviews, current) + printSourceContentReviews(reviews) + } + + const currentByIdentity = new Map( + current.map((source) => [sourceIdentityKey(source), source]), + ) + const finalSources = new Map( + lockedResult.lockfile.sources.map((source) => [ + sourceIdentityKey(source), + source, + ]), + ) + + for (const change of targets) { + const identity = sourceIdentityKey({ kind: change.kind, id: change.id }) + const source = currentByIdentity.get(identity) + if (!source) { + throw new Error( + `Internal error: no current source found for changed identity ${identity}.`, + ) + } + finalSources.set(identity, source) + } + + writeIntentLockfile(resolveLockfilePath(cwd), { + lockfileVersion: 1, + intentVersion: getIntentPackageVersion(), + ...(lockedResult.lockfile.staleness + ? { staleness: lockedResult.lockfile.staleness } + : {}), + sources: [...finalSources.values()], + policy: lockedResult.lockfile.policy, + }) + + printUpdated(targets) + printPendingAddRemove(diff) +} diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index d6ea3811..f6aec40a 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { fail } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import type { IntentCoreOptions } from '../core/index.js' +import type { PolicedScan } from '../core/source-policy.js' import type { ScanOptions, ScanResult, @@ -31,6 +32,14 @@ export function getMetaDir(): string { return findMetaDir(dirname(fileURLToPath(import.meta.url))) } +export function getIntentPackageVersion(): string { + const packageJsonPath = join(dirname(getMetaDir()), 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + version?: string + } + return packageJson.version ?? '0.0.0' +} + /** * Resolve the package `meta/` directory by walking up from `startDir`. * @@ -83,15 +92,24 @@ export function getCheckSkillsWorkflowAdvisories(root: string): Array { export async function scanIntentsOrFail( coreOptions: IntentCoreOptions = {}, ): Promise { + const { scan } = await scanPolicedIntentsOrFail(coreOptions) + return scan +} + +// scanIntentsOrFail discards hiddenSourceCount/hiddenSources; frozen-mode +// callers need them, hence this separate function instead of changing +// scanIntentsOrFail's return type. +export async function scanPolicedIntentsOrFail( + coreOptions: IntentCoreOptions = {}, +): Promise { const { scanForPolicedIntents } = await import('../core/source-policy.js') try { - const { scan } = scanForPolicedIntents({ + return scanForPolicedIntents({ cwd: process.cwd(), scanOptions: scanOptionsFromGlobalFlags(coreOptions), coreOptions, }) - return scan } catch (err) { fail(err instanceof Error ? err.message : String(err)) } @@ -133,6 +151,26 @@ export function noticeOptionsFromGlobalFlags(options: GlobalScanFlags): { return { noNotices: options.noNotices || options.notices === false } } +// cac's --no-x negation convention collapses --frozen/--no-frozen onto a +// single "frozen" key that defaults to true (see cac's Option constructor), +// which can't represent our third state (neither flag passed => auto-detect). +// Read the raw argv instead so "nothing passed" stays distinguishable. +export function frozenOptionsFromGlobalFlags( + options: { frozen?: boolean }, + argv: ReadonlyArray, +): { + frozen: boolean + noFrozen: boolean +} { + const hasFrozenFlag = argv.some( + (arg) => arg === '--frozen' || arg.startsWith('--frozen='), + ) + return { + frozen: hasFrozenFlag && options.frozen === true, + noFrozen: argv.includes('--no-frozen'), + } +} + function formatDebugValue(value: string | number | Array): string { if (Array.isArray(value)) { return value.length > 0 ? value.join(', ') : '(none)' diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 05ddfba8..51c25078 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -3,13 +3,17 @@ import { resolveProjectContext } from './project-context.js' import { readPackageJson } from './package-json.js' import type { ProjectContext } from './project-context.js' import type { IntentCoreOptions } from './types.js' +import type { IntentPackage } from '../shared/types.js' const MAX_EXCLUDE_PATTERN_LENGTH = 200 const PACKAGE_NAME_BOUNDARY = /[^a-zA-Z0-9_.-]/ export interface ExcludeMatcher { pattern: string - matchesPackage: (packageName: string) => boolean + matchesPackage: ( + packageName: string, + packageKind?: IntentPackage['kind'], + ) => boolean matchesSkill?: (skillName: string) => boolean } @@ -100,6 +104,33 @@ function compileSegment(segment: string): (value: string) => boolean { return (value) => regex.test(value) } +function parsePackageSegment(segment: string): { + kind?: IntentPackage['kind'] + packagePattern: string +} { + const separatorIndex = segment.indexOf(':') + if (separatorIndex === -1) { + return { packagePattern: segment } + } + + const prefix = segment.slice(0, separatorIndex) + const packagePattern = segment.slice(separatorIndex + 1) + if (prefix === 'npm' || prefix === 'workspace') { + return { kind: prefix, packagePattern } + } + + return { packagePattern: segment } +} + +function createPackageMatcher( + packageSegment: string, +): ExcludeMatcher['matchesPackage'] { + const { kind, packagePattern } = parsePackageSegment(packageSegment) + const matchesName = compileSegment(packagePattern) + return (packageName, packageKind) => + (kind === undefined || kind === packageKind) && matchesName(packageName) +} + export function compileExcludePatterns( patterns: Array, ): Array { @@ -108,32 +139,33 @@ export function compileExcludePatterns( const hashIndex = pattern.indexOf('#') if (hashIndex === -1) { - return { pattern, matchesPackage: compileSegment(pattern) } + return { pattern, matchesPackage: createPackageMatcher(pattern) } } const packageSegment = pattern.slice(0, hashIndex) const skillSegment = pattern.slice(hashIndex + 1) if (skillSegment.replace(/\*+/g, '*') === '*') { - return { pattern, matchesPackage: compileSegment(packageSegment) } + return { pattern, matchesPackage: createPackageMatcher(packageSegment) } } return { pattern, - matchesPackage: compileSegment(packageSegment), + matchesPackage: createPackageMatcher(packageSegment), matchesSkill: compileSegment(skillSegment), } }) } -// Deliberately kind-agnostic, unlike the allowlist/lockfile — not a gap to close later. export function isPackageExcluded( packageName: string, matchers: Array, + packageKind?: IntentPackage['kind'], ): boolean { return matchers.some( (matcher) => - matcher.matchesSkill === undefined && matcher.matchesPackage(packageName), + matcher.matchesSkill === undefined && + matcher.matchesPackage(packageName, packageKind), ) } @@ -154,10 +186,11 @@ export function isSkillExcluded( packageName: string, skillName: string, matchers: Array, + packageKind?: IntentPackage['kind'], ): boolean { const variants = skillNameVariants(packageName, skillName) return matchers.some((matcher) => { - if (!matcher.matchesPackage(packageName)) return false + if (!matcher.matchesPackage(packageName, packageKind)) return false if (matcher.matchesSkill === undefined) return true return variants.some((variant) => matcher.matchesSkill!(variant)) }) diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index d24f7a95..b18d8683 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -1,7 +1,11 @@ -import { isAbsolute, relative, resolve } from 'node:path' +import { isAbsolute, join, relative, resolve } from 'node:path' import { createIntentFsCache } from '../discovery/fs-cache.js' import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' import { formatSkillUse, parseSkillUse } from '../skills/use.js' +import { isFrozenMode } from '../shared/mode.js' +import { diffLockfileSources } from './lockfile/lockfile-diff.js' +import { readIntentLockfile } from './lockfile/lockfile.js' +import { buildCurrentLockfileSources } from './lockfile/lockfile-state.js' import { compileExcludePatterns, getEffectiveExcludePatterns, @@ -11,7 +15,6 @@ import { resolveSkillUseFastPath } from './load-resolution.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, - isSourcePermitted, packageNotListedRefusal, readSkillSourcesConfig, scanForPolicedIntents, @@ -19,7 +22,13 @@ import { import type { ResolveSkillResult } from '../skills/resolver.js' import type { IntentFsCache } from '../discovery/fs-cache.js' import type { ReadFs } from '../shared/utils.js' -import type { ScanOptions, ScanScope } from '../shared/types.js' +import type { ReadIntentLockfileResult } from './lockfile/lockfile.js' +import type { + IntentPackage, + ScanOptions, + ScanResult, + ScanScope, +} from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, @@ -109,6 +118,15 @@ export function listIntentSkills( context: projectContext, }) const packages = scan.packages + if (options.audience === 'agent') { + assertApprovedLockState( + cwd, + scan, + hiddenSourceCount, + readProjectLockfile(cwd), + { rejectHiddenSources: false, requireLockfile: false }, + ) + } const skills = packages.flatMap((pkg) => pkg.skills.map((skill): IntentSkillSummary => { return { @@ -261,6 +279,59 @@ function createLoadedSkillDebug({ } } +function readProjectLockfile(cwd: string): ReadIntentLockfileResult { + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? cwd + return readIntentLockfile(join(root, 'intent.lock')) +} + +function assertApprovedLockState( + cwd: string, + scan: ScanResult, + hiddenSourceCount: number, + lockedResult: ReadIntentLockfileResult, + options: { + rejectHiddenSources: boolean + requireLockfile: boolean + }, +): void { + if (options.rejectHiddenSources && hiddenSourceCount > 0) { + throw new IntentCoreError( + 'package-not-listed', + `Frozen mode found ${hiddenSourceCount} unlisted skill-bearing source(s) not in intent.skills. Add them to intent.skills or intent.exclude, then re-run outside frozen mode.`, + ) + } + + if (lockedResult.status === 'missing') { + if (options.requireLockfile) { + throw new IntentCoreError( + 'package-not-listed', + 'Frozen mode requires intent.lock. Run `intent skills approve --all` outside frozen mode first.', + ) + } + return + } + + const packages = scan.packages.map( + (pkg): IntentPackage => ({ + ...pkg, + packageRoot: resolve(cwd, pkg.packageRoot), + skills: pkg.skills.map((skill) => ({ + ...skill, + path: resolve(cwd, skill.path), + })), + }), + ) + const current = buildCurrentLockfileSources(packages, scan.readFs) + const diff = diffLockfileSources(current, lockedResult) + if (!diff.isClean) { + throw new IntentCoreError( + 'package-not-listed', + 'intent.lock is out of date. Run `intent skills diff` outside frozen mode, then `intent skills approve`.', + ) + } +} + function resolveIntentSkillInCwd( cwd: string, use: string, @@ -294,18 +365,25 @@ function resolveIntentSkillInCwd( const scanOptions = toScanOptions(options) const scope = getScanScope(scanOptions) - const fastPathResolved = resolveSkillUseFastPath( - parsedUse, - options, - projectContext, - cwd, - fsCache, - ) + const frozen = isFrozenMode() + const lockedResult = readProjectLockfile(cwd) + const fastPathResolved = + frozen || lockedResult.status === 'found' + ? null + : resolveSkillUseFastPath( + parsedUse, + options, + projectContext, + cwd, + fsCache, + ) if (fastPathResolved) { - if ( - !isSourcePermitted(config, parsedUse.packageName, fastPathResolved.kind) - ) { - const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) + const lateRefusal = checkLoadAllowed(use, parsedUse, { + config, + excludeMatchers, + sourceKind: fastPathResolved.kind, + }) + if (lateRefusal) { throw new IntentCoreError(lateRefusal.code, lateRefusal.message) } return toResolvedIntentSkill( @@ -326,7 +404,11 @@ function resolveIntentSkillInCwd( ) } - const { scan: scanResult, droppedNames } = scanForPolicedIntents({ + const { + scan: scanResult, + droppedNames, + hiddenSourceCount, + } = scanForPolicedIntents({ cwd, scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, @@ -348,6 +430,13 @@ function resolveIntentSkillInCwd( throw err } + if (frozen || lockedResult.status === 'found') { + assertApprovedLockState(cwd, scanResult, hiddenSourceCount, lockedResult, { + rejectHiddenSources: frozen, + requireLockfile: frozen, + }) + } + return toResolvedIntentSkill( cwd, use, diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index b69dfc63..8c1e0e98 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -263,13 +263,11 @@ export function resolveSkillUseFastPath( cwd, fsCache, ) - if (directResolved) return directResolved - if (!context.workspaceRoot) { - return null + return directResolved } - return resolveFromPackageRoots( + const workspaceResolved = resolveFromPackageRoots( getWorkspaceLoadFastPathCandidateDirs( parsedUse.packageName, context, @@ -279,4 +277,12 @@ export function resolveSkillUseFastPath( cwd, fsCache, ) + if ( + directResolved && + workspaceResolved && + directResolved.kind !== workspaceResolved.kind + ) { + return null + } + return directResolved ?? workspaceResolved } diff --git a/packages/intent/src/core/lockfile/content-review.ts b/packages/intent/src/core/lockfile/content-review.ts new file mode 100644 index 00000000..2e389d74 --- /dev/null +++ b/packages/intent/src/core/lockfile/content-review.ts @@ -0,0 +1,87 @@ +import { relative, sep } from 'node:path' +import { nodeReadFs } from '../../shared/utils.js' +import { sourceIdentityKey } from '../types.js' +import { + computeReviewedSourceContentHash, + readSourceContentForReview, +} from './hash.js' +import type { SourceContentReviewEntry } from './hash.js' +import type { IntentLockfileSource } from './lockfile.js' +import type { IntentPackage } from '../../shared/types.js' +import type { ReadFs } from '../../shared/utils.js' + +export interface SourceContentReview { + id: string + kind: IntentPackage['kind'] + version: string + files: Array + contentHash: string +} + +function toPosixPath(path: string): string { + return sep === '/' ? path : path.split(sep).join('/') +} + +export function buildSourceContentReviews( + packages: ReadonlyArray, + identities: ReadonlySet, + fs: ReadFs = nodeReadFs, +): Array { + const reviews = packages.flatMap((pkg) => { + if (!identities.has(sourceIdentityKey({ kind: pkg.kind, id: pkg.name }))) { + return [] + } + + const entries = pkg.skills.map((skill) => ({ + relativePath: toPosixPath(relative(pkg.packageRoot, skill.path)), + absolutePath: skill.path, + })) + const files = readSourceContentForReview(pkg.packageRoot, entries, fs) + return [ + { + id: pkg.name, + kind: pkg.kind, + version: pkg.version, + files, + contentHash: computeReviewedSourceContentHash(files), + }, + ] + }) + + const found = new Set() + for (const review of reviews) { + const identity = sourceIdentityKey(review) + if (found.has(identity)) { + throw new Error( + `Internal error: duplicate content review for ${review.kind}:${review.id}.`, + ) + } + found.add(identity) + } + for (const identity of identities) { + if (!found.has(identity)) { + throw new Error( + `Internal error: no content review found for ${JSON.stringify(identity)}.`, + ) + } + } + + return reviews +} + +export function assertSourceContentReviewsMatch( + reviews: ReadonlyArray, + current: ReadonlyArray, +): void { + const currentByIdentity = new Map( + current.map((source) => [sourceIdentityKey(source), source]), + ) + for (const review of reviews) { + const source = currentByIdentity.get(sourceIdentityKey(review)) + if (!source || source.contentHash !== review.contentHash) { + throw new Error( + `Skill content for ${review.kind}:${review.id} changed while it was being reviewed. Re-run the command before approving.`, + ) + } + } +} diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts new file mode 100644 index 00000000..52d5cdbe --- /dev/null +++ b/packages/intent/src/core/lockfile/hash.ts @@ -0,0 +1,548 @@ +import { createHash } from 'node:crypto' +import { isUtf8 } from 'node:buffer' +import { dirname, isAbsolute, join, relative } from 'node:path' +import { assertCanonicalPackageRelativePaths } from '../skill-path.js' +import { nodeReadFs } from '../../shared/utils.js' +import type { ReadFs } from '../../shared/utils.js' +import type { Dirent } from 'node:fs' + +export interface SkillContentEntry { + relativePath: string + absolutePath: string +} + +export interface SourceContentHash { + skills: Array + contentHash: string +} + +interface SkillFolderContentEntry { + relativePath: string + content: Buffer +} + +const RECORD_SEPARATOR = Buffer.from([0]) + +export const HASH_LIMITS = { + maxRecursionDepth: 32, + maxFileCount: 1000, + maxEntryCount: 1000, + maxFileBytes: 4 * 1024 * 1024, + maxTotalBytes: 16 * 1024 * 1024, +} as const + +type HashCollectionState = { + fileCount: number + entryCount: number +} + +type ReadSkillContent = { + content: Buffer + bytesRead: number + isBinary: boolean +} + +export type SourceContentReviewEntry = { + relativePath: string + content: Buffer + contentHash: string + isBinary: boolean + byteLength: number +} + +export function computeReviewedSourceContentHash( + entries: ReadonlyArray< + Pick + >, +): string { + return hashEntries( + entries.map((entry) => ({ + key: entry.relativePath, + value: entry.content, + })), + ) +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +// Only valid UTF-8 without a NUL byte is normalized as text. Other content is +// binary for hashing purposes and must remain byte-exact. +function isBinaryContent(content: Buffer): boolean { + return content.indexOf(0) !== -1 || !isUtf8(content) +} + +// 'latin1' round-trips 1 byte to 1 codepoint, so replacing on the decoded +// string is byte-identical to a manual scan — safe for non-UTF8 content. +function normalizeLineEndings(content: Buffer): Buffer { + const normalized = content.toString('latin1').replace(/\r\n|\r/g, '\n') + return Buffer.from(normalized, 'latin1') +} + +function isWithinDir(candidate: string, dir: string): boolean { + const rel = relative(dir, candidate) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +function assertNoDuplicateKeys(keys: Array, label: string): void { + const seen = new Set() + for (const key of keys) { + if (seen.has(key)) { + throw new Error(`Invalid ${label}: duplicate path "${key}".`) + } + seen.add(key) + } +} + +function assertHashFileCount(fileCount: number): void { + if (fileCount > HASH_LIMITS.maxFileCount) { + throw new Error( + `Hash file count limit (${HASH_LIMITS.maxFileCount}) exceeded.`, + ) + } +} + +function assertHashEntryCount(entryCount: number): void { + if (entryCount > HASH_LIMITS.maxEntryCount) { + throw new Error( + `Hash entry count limit (${HASH_LIMITS.maxEntryCount}) exceeded.`, + ) + } +} + +function appendHashEntry(state: HashCollectionState): void { + state.entryCount += 1 + assertHashEntryCount(state.entryCount) +} + +function appendHashFile( + files: Array, + entry: SkillContentEntry, + state: HashCollectionState, +): void { + state.fileCount += 1 + assertHashFileCount(state.fileCount) + files.push(entry) +} + +// Values are length-prefixed because content can contain NUL bytes. Keys +// (package-relative paths) never can, but a JS string could, so that +// assumption is enforced here rather than just relied on. +function hashEntries( + entries: ReadonlyArray<{ key: string; value: Buffer }>, +): string { + const hash = createHash('sha256') + const sorted = entries.toSorted((a, b) => compareStrings(a.key, b.key)) + + for (const entry of sorted) { + if (entry.key.includes('\0')) { + throw new Error( + `Invalid path "${entry.key}": must not contain a NUL byte.`, + ) + } + hash.update(Buffer.from(entry.key, 'utf8')) + hash.update(RECORD_SEPARATOR) + hash.update(Buffer.from(String(entry.value.length), 'ascii')) + hash.update(RECORD_SEPARATOR) + hash.update(entry.value) + hash.update(RECORD_SEPARATOR) + } + + return `sha256-${hash.digest('hex')}` +} + +function readRegularFile( + fs: ReadFs, + physicalPath: string, + logicalRelativePath: string, +): Buffer { + try { + if (!fs.lstatSync(physicalPath).isFile()) { + throw new Error('not a regular file.') + } + return fs.readFileSync(physicalPath) + } catch (err) { + throw new Error( + `Failed to read skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +function readSkillMdContent( + fs: ReadFs, + absolutePath: string, + realPackageRoot: string, + logicalRelativePath: string, +): ReadSkillContent { + let realPath: string + try { + realPath = fs.realpathSync(absolutePath) + } catch (err) { + throw new Error( + `Failed to resolve skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + if (!isWithinDir(realPath, realPackageRoot)) { + throw new Error( + `Refusing to hash skill file: "${logicalRelativePath}" escapes the package root via a symlink.`, + ) + } + + const raw = readRegularFile(fs, realPath, logicalRelativePath) + if (raw.byteLength > HASH_LIMITS.maxFileBytes) { + throw new Error( + `Hash file size limit (${HASH_LIMITS.maxFileBytes} bytes) exceeded by "${logicalRelativePath}".`, + ) + } + const isBinary = isBinaryContent(raw) + return { + content: isBinary ? raw : normalizeLineEndings(raw), + bytesRead: raw.byteLength, + isBinary, + } +} + +function readHashEntries( + entries: ReadonlyArray, + fs: ReadFs, + realPackageRoot: string, +): Array<{ + key: string + value: Buffer + isBinary: boolean + byteLength: number +}> { + let totalBytes = 0 + return entries.map((entry) => { + const { content, bytesRead, isBinary } = readSkillMdContent( + fs, + entry.absolutePath, + realPackageRoot, + entry.relativePath, + ) + totalBytes += bytesRead + if (totalBytes > HASH_LIMITS.maxTotalBytes) { + throw new Error( + `Hash total size limit (${HASH_LIMITS.maxTotalBytes} bytes) exceeded.`, + ) + } + return { + key: entry.relativePath, + value: content, + isBinary, + byteLength: bytesRead, + } + }) +} + +function resolveContainedDirectory( + fs: ReadFs, + absolutePath: string, + realPackageRoot: string, + logicalRelativePath: string, +): string { + let realPath: string + try { + realPath = fs.realpathSync(absolutePath) + } catch (err) { + throw new Error( + `Failed to resolve skill directory "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + if (!isWithinDir(realPath, realPackageRoot)) { + throw new Error( + `Refusing to hash skill directory: "${logicalRelativePath}" escapes the package root via a symlink.`, + ) + } + + try { + if (!fs.lstatSync(realPath).isDirectory()) { + throw new Error('not a directory.') + } + } catch (err) { + throw new Error( + `Failed to read skill directory "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + return realPath +} + +function collectSupportFiles( + fs: ReadFs, + dir: string, + baseDir: string, + realPackageRoot: string, + depth: number, + state: HashCollectionState, +): Array { + if (depth > HASH_LIMITS.maxRecursionDepth) { + throw new Error( + `Hash recursion depth limit (${HASH_LIMITS.maxRecursionDepth}) exceeded at "${toPosixRelative(baseDir, dir)}".`, + ) + } + const logicalRelativePath = toPosixRelative(baseDir, dir) + const realDir = resolveContainedDirectory( + fs, + dir, + realPackageRoot, + logicalRelativePath, + ) + + let dirents: Array> + try { + dirents = fs.readdirSync(realDir, { + withFileTypes: true, + encoding: 'utf8', + }) + } catch (err) { + throw new Error( + `Failed to read skill directory "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const files: Array = [] + for (const dirent of dirents) { + appendHashEntry(state) + const absolutePath = join(dir, dirent.name) + if (dirent.isDirectory()) { + files.push( + ...collectSupportFiles( + fs, + absolutePath, + baseDir, + realPackageRoot, + depth + 1, + state, + ), + ) + continue + } + if (dirent.isFile()) { + appendHashFile( + files, + { + relativePath: toPosixRelative(baseDir, absolutePath), + absolutePath, + }, + state, + ) + continue + } + if (dirent.isSymbolicLink()) { + let realPath: string + try { + realPath = fs.realpathSync(absolutePath) + } catch (err) { + throw new Error( + `Failed to resolve skill file "${toPosixRelative(baseDir, absolutePath)}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + const stat = fs.lstatSync(realPath) + if (stat.isDirectory()) { + files.push( + ...collectSupportFiles( + fs, + absolutePath, + baseDir, + realPackageRoot, + depth + 1, + state, + ), + ) + } else if (stat.isFile()) { + appendHashFile( + files, + { + relativePath: toPosixRelative(baseDir, absolutePath), + absolutePath, + }, + state, + ) + } + } + } + + return files +} + +function collectSkillContentEntries( + fs: ReadFs, + pathBaseDir: string, + entries: ReadonlyArray, + realPackageRoot: string, +): Array { + const contentEntries = [...entries] + const state = { fileCount: contentEntries.length, entryCount: 0 } + assertHashFileCount(state.fileCount) + for (const entry of entries) { + const skillDir = dirname(entry.absolutePath) + let dirents: Array> + try { + dirents = fs.readdirSync(skillDir, { + withFileTypes: true, + encoding: 'utf8', + }) + } catch (err) { + throw new Error( + `Failed to read skill directory "${toPosixRelative(pathBaseDir, skillDir)}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + for (const dirent of dirents) { + if ( + dirent.name !== 'references' && + dirent.name !== 'assets' && + dirent.name !== 'scripts' + ) { + continue + } + + const supportDir = join(skillDir, dirent.name) + let realPath: string + try { + realPath = fs.realpathSync(supportDir) + } catch (err) { + throw new Error( + `Failed to resolve skill directory "${toPosixRelative(pathBaseDir, supportDir)}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + const stat = fs.lstatSync(realPath) + if (stat.isDirectory()) { + contentEntries.push( + ...collectSupportFiles( + fs, + supportDir, + pathBaseDir, + realPackageRoot, + 0, + state, + ), + ) + } + } + } + + return contentEntries +} + +export function computeSourceContentHash( + packageRoot: string, + entries: ReadonlyArray, + fs: ReadFs = nodeReadFs, +): SourceContentHash { + assertCanonicalPackageRelativePaths( + entries.map((entry) => entry.relativePath), + 'skill path', + ) + + const realPackageRoot = fs.realpathSync(packageRoot) + const contentEntries = collectSkillContentEntries( + fs, + packageRoot, + entries, + realPackageRoot, + ) + assertNoDuplicateKeys( + contentEntries.map((entry) => entry.relativePath), + 'skill content path', + ) + + const hashed = readHashEntries(contentEntries, fs, realPackageRoot) + + return { + skills: entries.map((entry) => entry.relativePath).toSorted(compareStrings), + contentHash: computeReviewedSourceContentHash( + hashed.map((entry) => ({ + relativePath: entry.key, + content: entry.value, + })), + ), + } +} + +export function readSourceContentForReview( + packageRoot: string, + entries: ReadonlyArray, + fs: ReadFs = nodeReadFs, +): Array { + assertCanonicalPackageRelativePaths( + entries.map((entry) => entry.relativePath), + 'skill path', + ) + + const realPackageRoot = fs.realpathSync(packageRoot) + const contentEntries = collectSkillContentEntries( + fs, + packageRoot, + entries, + realPackageRoot, + ) + assertNoDuplicateKeys( + contentEntries.map((entry) => entry.relativePath), + 'skill content path', + ) + + return readHashEntries(contentEntries, fs, realPackageRoot) + .map((entry) => ({ + relativePath: entry.key, + content: entry.value, + contentHash: `sha256-${createHash('sha256').update(entry.value).digest('hex')}`, + isBinary: entry.isBinary, + byteLength: entry.byteLength, + })) + .toSorted((a, b) => compareStrings(a.relativePath, b.relativePath)) +} + +function toPosixRelative(baseDir: string, absolutePath: string): string { + const rel = relative(baseDir, absolutePath) + return rel.split('\\').join('/') +} + +// Manifest hashes cover one whole skill folder. Lockfile hashes aggregate the +// same content for every locked skill using package-relative paths. Both use +// the same canonical hashing rules (LF text normalization, byte-exact binary). +export function computeSkillFolderHash( + skillDir: string, + packageRoot: string, + fs: ReadFs = nodeReadFs, +): string { + return hashEntries( + readSkillFolderContents(skillDir, packageRoot, fs).map((entry) => ({ + key: entry.relativePath, + value: entry.content, + })), + ) +} + +function readSkillFolderContents( + skillDir: string, + packageRoot: string, + fs: ReadFs = nodeReadFs, +): Array { + const realPackageRoot = fs.realpathSync(packageRoot) + const entries = collectSkillContentEntries( + fs, + skillDir, + [ + { + relativePath: 'SKILL.md', + absolutePath: join(skillDir, 'SKILL.md'), + }, + ], + realPackageRoot, + ) + + assertNoDuplicateKeys( + entries.map((entry) => entry.relativePath), + 'skill folder path', + ) + + return readHashEntries(entries, fs, realPackageRoot).map((entry) => ({ + relativePath: entry.key, + content: entry.value, + })) +} diff --git a/packages/intent/src/core/lockfile/lockfile-diff.ts b/packages/intent/src/core/lockfile/lockfile-diff.ts new file mode 100644 index 00000000..a2aa32f0 --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -0,0 +1,149 @@ +import { sourceIdentityKey } from '../types.js' +import { canonicalSource } from './lockfile.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from './lockfile.js' +import type { SourceIdentity } from '../types.js' + +type LockfileChangeField = + | 'version' + | 'resolution' + | 'skills' + | 'contentHash' + | 'manifestHash' + | 'capabilities' + | 'declaredSecrets' + | 'mcpTools' + | 'mcpPolicy' + +export interface LockfileFieldChange { + field: LockfileChangeField + from: unknown + to: unknown +} + +export interface LockfileSourceChange { + id: string + kind: SourceIdentity['kind'] + fields: Array +} + +export interface LockfileDiffResult { + hasLockfile: boolean + added: Array + removed: Array + changed: Array + isClean: boolean +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function sortBySourceIdentity( + items: Array, +): Array { + return items.toSorted((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ) +} + +function diffFields( + locked: IntentLockfileSource, + current: IntentLockfileSource, +): Array { + const lockedCanonical = canonicalSource(locked) + const currentCanonical = canonicalSource(current) + const changes: Array = [] + + const comparePrimitiveField = ( + field: 'version' | 'resolution' | 'contentHash' | 'manifestHash', + ): void => { + const from = lockedCanonical[field] + const to = currentCanonical[field] + if (from !== to) { + changes.push({ field, from, to }) + } + } + + const compareStructuredField = ( + field: + | 'skills' + | 'capabilities' + | 'declaredSecrets' + | 'mcpTools' + | 'mcpPolicy', + ): void => { + const from = lockedCanonical[field] + const to = currentCanonical[field] + if (JSON.stringify(from) !== JSON.stringify(to)) { + changes.push({ field, from, to }) + } + } + + comparePrimitiveField('version') + comparePrimitiveField('resolution') + comparePrimitiveField('contentHash') + comparePrimitiveField('manifestHash') + compareStructuredField('skills') + compareStructuredField('capabilities') + compareStructuredField('declaredSecrets') + compareStructuredField('mcpTools') + compareStructuredField('mcpPolicy') + + return changes +} + +export function diffLockfileSources( + current: ReadonlyArray, + lockedResult: ReadIntentLockfileResult, +): LockfileDiffResult { + if (lockedResult.status === 'missing') { + return { + hasLockfile: false, + added: [], + removed: [], + changed: [], + isClean: false, + } + } + + const lockedSources = lockedResult.lockfile.sources + const currentByKey = new Map( + current.map((source) => [sourceIdentityKey(source), source]), + ) + const lockedByKey = new Map( + lockedSources.map((source) => [sourceIdentityKey(source), source]), + ) + + const added = sortBySourceIdentity( + current + .filter((source) => !lockedByKey.has(sourceIdentityKey(source))) + .map(canonicalSource), + ) + const removed = sortBySourceIdentity( + lockedSources.filter( + (source) => !currentByKey.has(sourceIdentityKey(source)), + ), + ) + + const changed: Array = [] + for (const [key, lockedSource] of lockedByKey) { + const currentSource = currentByKey.get(key) + if (!currentSource) continue + + const fields = diffFields(lockedSource, currentSource) + if (fields.length > 0) { + changed.push({ id: currentSource.id, kind: currentSource.kind, fields }) + } + } + + return { + hasLockfile: true, + added, + removed, + changed: sortBySourceIdentity(changed), + isClean: added.length === 0 && removed.length === 0 && changed.length === 0, + } +} diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts new file mode 100644 index 00000000..80c52fe4 --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -0,0 +1,112 @@ +import { join, relative, sep } from 'node:path' +import { sourceIdentityKey } from '../types.js' +import { nodeReadFs } from '../../shared/utils.js' +import { + assertManifestMatchesPackage, + computeManifestHash, + readIntentManifest, +} from '../manifest.js' +import { computeSourceContentHash } from './hash.js' +import type { SourceContentHash } from './hash.js' +import type { IntentLockfileSource } from './lockfile.js' +import type { IntentPackage } from '../../shared/types.js' +import type { ReadFs } from '../../shared/utils.js' + +function toPosixPath(path: string): string { + return sep === '/' ? path : path.split(sep).join('/') +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function buildSourceContent(pkg: IntentPackage, fs: ReadFs): SourceContentHash { + const entries = pkg.skills.map((skill) => ({ + relativePath: toPosixPath(relative(pkg.packageRoot, skill.path)), + absolutePath: skill.path, + })) + + return computeSourceContentHash(pkg.packageRoot, entries, fs) +} + +function buildResolution(pkg: IntentPackage): string | null { + return pkg.kind === 'npm' ? `npm:${pkg.name}@${pkg.version}` : null +} + +// manifestHash/capabilities stay null when a package ships no M3 manifest — +// reserved-nullable by design, so the lockfile works before every package +// adopts a manifest. When a manifest is present, its declared capabilities +// (unioned across skills) and hash join the lockfile source entry. +function readManifestFields( + pkg: IntentPackage, + fs: ReadFs, +): { + manifestHash: string | null + capabilities: Array | null +} { + const manifest = readIntentManifest( + join(pkg.packageRoot, 'skills', 'intent.manifest.json'), + fs, + ) + if (!manifest) { + return { manifestHash: null, capabilities: null } + } + assertManifestMatchesPackage( + manifest, + pkg.packageRoot, + pkg.name, + pkg.version, + pkg.skills, + fs, + ) + + const capabilities = [ + ...new Set(manifest.skills.flatMap((skill) => skill.capabilities)), + ].sort(compareStrings) + + return { + manifestHash: computeManifestHash(manifest), + capabilities, + } +} + +function assertUniqueIdentities( + sources: ReadonlyArray, +): void { + const seen = new Set() + for (const source of sources) { + const key = sourceIdentityKey(source) + if (seen.has(key)) { + throw new Error( + `Duplicate skill source identity: ${source.kind}:${source.id}.`, + ) + } + seen.add(key) + } +} + +export function buildCurrentLockfileSources( + packages: ReadonlyArray, + fs: ReadFs = nodeReadFs, +): Array { + const sources = packages + .map((pkg): IntentLockfileSource => { + const { skills, contentHash } = buildSourceContent(pkg, fs) + const { manifestHash, capabilities } = readManifestFields(pkg, fs) + return { + id: pkg.name, + kind: pkg.kind, + version: pkg.version, + resolution: buildResolution(pkg), + skills, + contentHash, + manifestHash, + capabilities, + } + }) + .sort((a, b) => compareStrings(sourceIdentityKey(a), sourceIdentityKey(b))) + + assertUniqueIdentities(sources) + + return sources +} diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts new file mode 100644 index 00000000..bdb4b29c --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -0,0 +1,438 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { sourceIdentityKey } from '../types.js' +import { assertCanonicalPackageRelativePaths } from '../skill-path.js' + +const INTENT_LOCKFILE_VERSION = 1 + +export interface IntentLockfile { + lockfileVersion: 1 + intentVersion: string + staleness?: IntentLockfileStaleness + sources: Array + policy: IntentLockfilePolicy +} + +export interface IntentLockfileStaleness { + baseline: IntentLockfileStalenessBaseline +} + +export interface IntentLockfileStalenessBaseline { + kind: 'tag' + ref: string + commit: string +} + +export interface IntentLockfileSource { + id: string + kind: 'npm' | 'workspace' + version: string | null + resolution: string | null + skills: Array + contentHash: string + manifestHash: string | null + capabilities: Array | null + declaredSecrets?: Array + mcpTools?: Array + mcpPolicy?: Record +} + +export interface IntentLockfilePolicy { + ignores: Array +} + +export interface IntentLockfilePolicyIgnore { + id: string + scope: { + source: string + contentHash: string + } + reason: string + createdAt: string + expiresAt: string +} + +type JsonValue = + | null + | boolean + | number + | string + | Array + | { [key: string]: JsonValue } + +export type ReadIntentLockfileResult = + | { status: 'missing' } + | { status: 'found'; lockfile: IntentLockfile } + +function assertRecord(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid intent.lock: ${label} must be an object.`) + } + return value as Record +} + +function assertNoUndeclaredFields( + value: Record, + allowedFields: ReadonlySet, + label: string, +): void { + for (const field of Object.keys(value)) { + if (!allowedFields.has(field)) { + throw new Error( + `Invalid intent.lock: ${label} contains undeclared field "${field}".`, + ) + } + } +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new Error(`Invalid intent.lock: ${label} must be a string.`) + } + return value +} + +function assertNullableString(value: unknown, label: string): string | null { + if (value === null) return null + return assertString(value, label) +} + +function assertStringArray(value: unknown, label: string): Array { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error( + `Invalid intent.lock: ${label} must be an array of strings.`, + ) + } + return value +} + +function assertNullableStringArray( + value: unknown, + label: string, +): Array | null { + if (value === null) return null + return assertStringArray(value, label) +} + +function assertOptionalStringArray( + value: unknown, + label: string, +): Array | undefined { + if (value === undefined) return undefined + return assertStringArray(value, label) +} + +function assertOptionalRecord( + value: unknown, + label: string, +): Record | undefined { + if (value === undefined) return undefined + return assertRecord(value, label) +} + +function assertNoDuplicateSourceIdentities( + sources: ReadonlyArray, +): void { + const seen = new Set() + for (const source of sources) { + const key = sourceIdentityKey(source) + if (seen.has(key)) { + throw new Error( + `Invalid intent.lock: duplicate source identity "${source.kind}:${source.id}".`, + ) + } + seen.add(key) + } +} + +function parseSource(value: unknown): IntentLockfileSource { + const source = assertRecord(value, 'source') + assertNoUndeclaredFields( + source, + new Set([ + 'capabilities', + 'contentHash', + 'declaredSecrets', + 'id', + 'kind', + 'manifestHash', + 'mcpPolicy', + 'mcpTools', + 'resolution', + 'skills', + 'version', + ]), + 'source', + ) + const kind = source.kind + if (kind !== 'npm' && kind !== 'workspace') { + throw new Error( + 'Invalid intent.lock: source.kind must be npm or workspace.', + ) + } + + const skills = assertStringArray(source.skills, 'source.skills') + assertCanonicalPackageRelativePaths(skills, 'source.skills path') + + return { + id: assertString(source.id, 'source.id'), + kind, + version: assertNullableString(source.version, 'source.version'), + resolution: assertNullableString(source.resolution, 'source.resolution'), + skills, + contentHash: assertString(source.contentHash, 'source.contentHash'), + manifestHash: assertNullableString( + source.manifestHash, + 'source.manifestHash', + ), + capabilities: assertNullableStringArray( + source.capabilities, + 'source.capabilities', + ), + declaredSecrets: assertOptionalStringArray( + source.declaredSecrets, + 'source.declaredSecrets', + ), + mcpTools: assertOptionalStringArray(source.mcpTools, 'source.mcpTools'), + mcpPolicy: assertOptionalRecord(source.mcpPolicy, 'source.mcpPolicy'), + } +} + +function parsePolicyIgnore(value: unknown): IntentLockfilePolicyIgnore { + const ignore = assertRecord(value, 'policy.ignore') + const scope = assertRecord(ignore.scope, 'policy.ignore.scope') + assertNoUndeclaredFields( + ignore, + new Set(['createdAt', 'expiresAt', 'id', 'reason', 'scope']), + 'policy.ignore', + ) + assertNoUndeclaredFields( + scope, + new Set(['contentHash', 'source']), + 'policy.ignore.scope', + ) + return { + id: assertString(ignore.id, 'policy.ignore.id'), + scope: { + source: assertString(scope.source, 'policy.ignore.scope.source'), + contentHash: assertString( + scope.contentHash, + 'policy.ignore.scope.contentHash', + ), + }, + reason: assertString(ignore.reason, 'policy.ignore.reason'), + createdAt: assertString(ignore.createdAt, 'policy.ignore.createdAt'), + expiresAt: assertString(ignore.expiresAt, 'policy.ignore.expiresAt'), + } +} + +function parsePolicy(value: unknown): IntentLockfilePolicy { + const policy = assertRecord(value, 'policy') + assertNoUndeclaredFields(policy, new Set(['ignores']), 'policy') + if (!Array.isArray(policy.ignores)) { + throw new Error('Invalid intent.lock: policy.ignores must be an array.') + } + return { ignores: policy.ignores.map(parsePolicyIgnore) } +} + +function parseStaleness(value: unknown): IntentLockfileStaleness | undefined { + if (value === undefined) return undefined + const staleness = assertRecord(value, 'staleness') + const baseline = assertRecord(staleness.baseline, 'staleness.baseline') + assertNoUndeclaredFields(staleness, new Set(['baseline']), 'staleness') + assertNoUndeclaredFields( + baseline, + new Set(['commit', 'kind', 'ref']), + 'staleness.baseline', + ) + if (baseline.kind !== 'tag') { + throw new Error('Invalid intent.lock: staleness.baseline.kind must be tag.') + } + return { + baseline: { + kind: 'tag', + ref: assertString(baseline.ref, 'staleness.baseline.ref'), + commit: assertString(baseline.commit, 'staleness.baseline.commit'), + }, + } +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function sortedStrings(values: Array): Array { + return values.toSorted(compareStrings) +} + +function canonicalJsonValue(value: unknown, label: string): JsonValue { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return value + } + + if (Array.isArray(value)) { + return value.map((item, index) => + canonicalJsonValue(item, `${label}[${index}]`), + ) + } + + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => compareStrings(a, b)) + .map(([key, item]) => [ + key, + canonicalJsonValue(item, `${label}.${key}`), + ]), + ) + } + + throw new Error(`Invalid intent.lock: ${label} must be JSON-serializable.`) +} + +export function canonicalSource( + source: IntentLockfileSource, +): IntentLockfileSource { + return { + id: source.id, + kind: source.kind, + version: source.version, + resolution: source.resolution, + skills: sortedStrings(source.skills), + contentHash: source.contentHash, + manifestHash: source.manifestHash, + capabilities: source.capabilities + ? sortedStrings(source.capabilities) + : null, + ...(source.declaredSecrets !== undefined + ? { declaredSecrets: sortedStrings(source.declaredSecrets) } + : {}), + ...(source.mcpTools !== undefined + ? { mcpTools: sortedStrings(source.mcpTools) } + : {}), + ...(source.mcpPolicy !== undefined + ? { + mcpPolicy: canonicalJsonValue( + source.mcpPolicy, + 'source.mcpPolicy', + ) as Record, + } + : {}), + } +} + +function canonicalPolicyIgnore( + ignore: IntentLockfilePolicyIgnore, +): IntentLockfilePolicyIgnore { + return { + id: ignore.id, + scope: { + source: ignore.scope.source, + contentHash: ignore.scope.contentHash, + }, + reason: ignore.reason, + createdAt: ignore.createdAt, + expiresAt: ignore.expiresAt, + } +} + +function canonicalLockfile(lockfile: IntentLockfile): IntentLockfile { + return { + lockfileVersion: INTENT_LOCKFILE_VERSION, + intentVersion: lockfile.intentVersion, + ...(lockfile.staleness ? { staleness: lockfile.staleness } : {}), + sources: [...lockfile.sources] + .sort((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ) + .map(canonicalSource), + policy: { + ignores: [...lockfile.policy.ignores] + .sort((a, b) => { + const aKey = `${a.id}\u0000${a.scope.source}\u0000${a.scope.contentHash}` + const bKey = `${b.id}\u0000${b.scope.source}\u0000${b.scope.contentHash}` + return compareStrings(aKey, bKey) + }) + .map(canonicalPolicyIgnore), + }, + } +} + +export function parseIntentLockfile(content: string): IntentLockfile { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch (err) { + throw new Error( + `Invalid intent.lock JSON: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const lockfile = assertRecord(parsed, 'root') + assertNoUndeclaredFields( + lockfile, + new Set([ + 'intentVersion', + 'lockfileVersion', + 'policy', + 'sources', + 'staleness', + ]), + 'root', + ) + if (lockfile.lockfileVersion !== INTENT_LOCKFILE_VERSION) { + throw new Error( + `Unsupported intent.lock version: ${String(lockfile.lockfileVersion)}`, + ) + } + if (!Array.isArray(lockfile.sources)) { + throw new Error('Invalid intent.lock: sources must be an array.') + } + + const sources = lockfile.sources.map(parseSource) + assertNoDuplicateSourceIdentities(sources) + + return canonicalLockfile({ + lockfileVersion: INTENT_LOCKFILE_VERSION, + intentVersion: assertString(lockfile.intentVersion, 'intentVersion'), + ...(lockfile.staleness !== undefined + ? { staleness: parseStaleness(lockfile.staleness) } + : {}), + sources, + policy: parsePolicy(lockfile.policy), + }) +} + +export function serializeIntentLockfile(lockfile: IntentLockfile): string { + return `${JSON.stringify(canonicalLockfile(lockfile), null, 2)}\n` +} + +export function readIntentLockfile(filePath: string): ReadIntentLockfileResult { + let content: string + try { + content = readFileSync(filePath, 'utf8') + } catch (err) { + if ( + err instanceof Error && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return { status: 'missing' } + } + throw err + } + + return { status: 'found', lockfile: parseIntentLockfile(content) } +} + +export function writeIntentLockfile( + filePath: string, + lockfile: IntentLockfile, +): void { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, serializeIntentLockfile(lockfile), 'utf8') +} diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts new file mode 100644 index 00000000..b01fbb05 --- /dev/null +++ b/packages/intent/src/core/manifest.ts @@ -0,0 +1,391 @@ +// Package-side manifest: `skills/intent.manifest.json`. Ships inside a +// published skill package and gives skill metadata a stable, hashable home +// separate from SKILL.md content. Not a second lockfile — it's a maintainer- +// authored description of what a package's skills are and declare, never a +// consumer approval record, and it never lives in the consumer root. +import { writeFileSync } from 'node:fs' +import { dirname, relative } from 'node:path' +import { createHash } from 'node:crypto' +import { nodeReadFs } from '../shared/utils.js' +import { computeSkillFolderHash } from './lockfile/hash.js' +import { assertCanonicalPackageRelativePath } from './skill-path.js' +import type { SkillEntry } from '../shared/types.js' +import type { ReadFs } from '../shared/utils.js' + +export type IntentManifestCapability = + | 'reads_project_files' + | 'runs_install_command' + | 'ships_scripts' + | 'uses_network' + | 'writes_project_files' + +const MANIFEST_CAPABILITIES = new Set([ + 'reads_project_files', + 'runs_install_command', + 'ships_scripts', + 'uses_network', + 'writes_project_files', +]) +const MCP_TOOL_FIELDS = new Set(['description', 'inputSchema', 'name']) +const MANIFEST_FIELDS = new Set([ + 'manifestVersion', + 'package', + 'packageVersion', + 'skills', +]) +const MANIFEST_SKILL_FIELDS = new Set([ + 'capabilities', + 'contentHash', + 'declaredSecrets', + 'mcpTools', + 'name', + 'path', +]) + +interface IntentManifestSkill { + name: string + path: string + contentHash: string + capabilities: Array + declaredSecrets: Array + mcpTools: Array +} + +interface IntentManifestMcpTool { + name: string + description?: string + inputSchema?: Record +} + +type JsonValue = + | null + | boolean + | number + | string + | Array + | { [key: string]: JsonValue } + +export interface IntentManifest { + manifestVersion: 1 + package: string + packageVersion: string + skills: Array +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function toPosixPath(path: string): string { + return path.split('\\').join('/') +} + +// Deterministic: stable entry and key order with no generated timestamps. +export function serializeManifest(manifest: IntentManifest): string { + return `${JSON.stringify(canonicalManifest(manifest), null, 2)}\n` +} + +function canonicalManifest(manifest: IntentManifest): IntentManifest { + return { + manifestVersion: manifest.manifestVersion, + package: manifest.package, + packageVersion: manifest.packageVersion, + skills: manifest.skills + .toSorted((a, b) => compareStrings(a.path, b.path)) + .map((skill) => ({ + name: skill.name, + path: skill.path, + contentHash: skill.contentHash, + capabilities: skill.capabilities.toSorted(compareStrings), + declaredSecrets: skill.declaredSecrets.toSorted(compareStrings), + mcpTools: canonicalMcpTools(skill.mcpTools, 'mcpTools'), + })), + } +} + +export function writeIntentManifest( + filePath: string, + manifest: IntentManifest, +): void { + writeFileSync(filePath, serializeManifest(manifest)) +} + +function assertRecord(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid intent.manifest.json: ${label} must be an object.`) + } + return value as Record +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new Error(`Invalid intent.manifest.json: ${label} must be a string.`) + } + return value +} + +function assertNoUndeclaredFields( + record: Record, + fields: ReadonlySet, + label: string, +): void { + for (const field of Object.keys(record)) { + if (!fields.has(field)) { + throw new Error( + `Invalid intent.manifest.json: ${label} contains undeclared field "${field}".`, + ) + } + } +} + +function assertStringArray(value: unknown, label: string): Array { + if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { + throw new Error( + `Invalid intent.manifest.json: ${label} must be an array of strings.`, + ) + } + return value +} + +function assertCapabilities( + value: unknown, + label: string, +): Array { + const capabilities = assertStringArray(value, label) + for (const capability of capabilities) { + if (!MANIFEST_CAPABILITIES.has(capability as IntentManifestCapability)) { + throw new Error( + `Invalid intent.manifest.json: ${label} contains unknown capability "${capability}".`, + ) + } + } + return capabilities as Array +} + +function canonicalJsonValue(value: unknown, label: string): JsonValue { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'string' + ) { + return value + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error( + `Invalid intent.manifest.json: ${label} must be JSON-serializable.`, + ) + } + return value + } + if (Array.isArray(value)) { + return value.map((item, index) => + canonicalJsonValue(item, `${label}[${index}]`), + ) + } + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => compareStrings(a, b)) + .map(([key, item]) => [ + key, + canonicalJsonValue(item, `${label}.${key}`), + ]), + ) + } + throw new Error( + `Invalid intent.manifest.json: ${label} must be JSON-serializable.`, + ) +} + +function canonicalMcpTools( + value: unknown, + label: string, +): Array { + if (!Array.isArray(value)) { + throw new Error(`Invalid intent.manifest.json: ${label} must be an array.`) + } + + const tools = value.map((tool, index): IntentManifestMcpTool => { + const record = assertRecord(tool, `${label}[${index}]`) + for (const field of Object.keys(record)) { + if (!MCP_TOOL_FIELDS.has(field)) { + throw new Error( + `Invalid intent.manifest.json: ${label}[${index}] contains undeclared field "${field}".`, + ) + } + } + const name = assertString(record.name, `${label}[${index}].name`) + const description = + record.description === undefined + ? undefined + : assertString(record.description, `${label}[${index}].description`) + const inputSchema = + record.inputSchema === undefined + ? undefined + : canonicalJsonValue( + assertRecord(record.inputSchema, `${label}[${index}].inputSchema`), + `${label}[${index}].inputSchema`, + ) + + return { + name, + ...(description === undefined ? {} : { description }), + ...(inputSchema === undefined + ? {} + : { inputSchema: inputSchema as Record }), + } + }) + + const sorted = tools.toSorted((a, b) => compareStrings(a.name, b.name)) + for (let index = 1; index < sorted.length; index++) { + if (sorted[index - 1]!.name === sorted[index]!.name) { + throw new Error( + `Invalid intent.manifest.json: ${label} contains duplicate tool name "${sorted[index]!.name}".`, + ) + } + } + return sorted +} + +export function parseManifest(raw: unknown): IntentManifest { + const record = assertRecord(raw, 'manifest') + assertNoUndeclaredFields(record, MANIFEST_FIELDS, 'manifest') + if (record.manifestVersion !== 1) { + throw new Error('Invalid intent.manifest.json: manifestVersion must be 1.') + } + + const skillsRaw = record.skills + if (!Array.isArray(skillsRaw)) { + throw new Error('Invalid intent.manifest.json: skills must be an array.') + } + + const seenPaths = new Set() + const skills = skillsRaw.map((entry, index): IntentManifestSkill => { + const skillRecord = assertRecord(entry, `skills[${index}]`) + assertNoUndeclaredFields( + skillRecord, + MANIFEST_SKILL_FIELDS, + `skills[${index}]`, + ) + const path = assertString(skillRecord.path, `skills[${index}].path`) + assertCanonicalPackageRelativePath(path, `manifest skills[${index}].path`) + if (seenPaths.has(path)) { + throw new Error( + `Invalid intent.manifest.json: duplicate skill path "${path}".`, + ) + } + seenPaths.add(path) + + return { + name: assertString(skillRecord.name, `skills[${index}].name`), + path, + contentHash: assertString( + skillRecord.contentHash, + `skills[${index}].contentHash`, + ), + capabilities: assertCapabilities( + skillRecord.capabilities ?? [], + `skills[${index}].capabilities`, + ), + declaredSecrets: assertStringArray( + skillRecord.declaredSecrets ?? [], + `skills[${index}].declaredSecrets`, + ), + mcpTools: canonicalMcpTools( + skillRecord.mcpTools ?? [], + `skills[${index}].mcpTools`, + ), + } + }) + + return { + manifestVersion: 1, + package: assertString(record.package, 'package'), + packageVersion: assertString(record.packageVersion, 'packageVersion'), + skills, + } +} + +export function assertManifestMatchesPackage( + manifest: IntentManifest, + packageRoot: string, + packageName: string, + packageVersion: string, + skills: ReadonlyArray, + fs: ReadFs = nodeReadFs, +): void { + if (manifest.package !== packageName) { + throw new Error( + `intent.manifest.json package "${manifest.package}" does not match discovered package "${packageName}".`, + ) + } + if (manifest.packageVersion !== packageVersion) { + throw new Error( + `intent.manifest.json packageVersion "${manifest.packageVersion}" does not match discovered version "${packageVersion}".`, + ) + } + + const expected = new Map( + skills.map((skill) => { + const path = toPosixPath(relative(packageRoot, skill.path)) + return [ + path, + computeSkillFolderHash(dirname(skill.path), packageRoot, fs), + ] + }), + ) + if (manifest.skills.length !== expected.size) { + throw new Error( + 'intent.manifest.json skill set does not match discovered skills.', + ) + } + + for (const skill of manifest.skills) { + const expectedHash = expected.get(skill.path) + if (!expectedHash) { + throw new Error( + `intent.manifest.json skill path "${skill.path}" does not match discovered skills.`, + ) + } + if (skill.contentHash !== expectedHash) { + throw new Error( + `intent.manifest.json skill hash for "${skill.path}" does not match installed content.`, + ) + } + } +} + +export function readIntentManifest( + filePath: string, + fs: Pick = nodeReadFs, +): IntentManifest | null { + if (!fs.existsSync(filePath)) return null + + let content: string + try { + content = fs.readFileSync(filePath, 'utf8') + } catch (err) { + throw new Error( + `Failed to read intent.manifest.json at "${filePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + try { + return parseManifest(JSON.parse(content)) + } catch (err) { + throw new Error( + `Invalid intent.manifest.json at "${filePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +// Aggregate manifestHash carried on the lockfile's source entry: a hash of +// the manifest's own skills[] content, so a lockfile diff detects any +// manifest change (added/removed skill, capability change, hash change) +// without needing to store the whole manifest inline. +export function computeManifestHash(manifest: IntentManifest): string { + const hash = createHash('sha256') + hash.update(serializeManifest(manifest)) + return `sha256-${hash.digest('hex')}` +} diff --git a/packages/intent/src/core/skill-path.ts b/packages/intent/src/core/skill-path.ts new file mode 100644 index 00000000..4c1ecd1d --- /dev/null +++ b/packages/intent/src/core/skill-path.ts @@ -0,0 +1,46 @@ +import { isAbsolute, win32 } from 'node:path' + +export function assertCanonicalPackageRelativePath( + path: string, + label: string, +): void { + if (path.length === 0) { + throw new Error(`Invalid ${label}: path must not be empty.`) + } + if (isAbsolute(path) || win32.isAbsolute(path)) { + throw new Error( + `Invalid ${label}: path must be package-relative (must be relative), got "${path}".`, + ) + } + if (path.includes('\\')) { + throw new Error( + `Invalid ${label}: path must use "/" separators, got "${path}".`, + ) + } + if (path.includes('\0')) { + throw new Error(`Invalid ${label}: path must not contain a NUL byte.`) + } + if ( + path + .split('/') + .some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error( + `Invalid ${label}: path must be package-relative without empty, "." or ".." segments, got "${path}".`, + ) + } +} + +export function assertCanonicalPackageRelativePaths( + paths: ReadonlyArray, + label: string, +): void { + const seen = new Set() + for (const path of paths) { + assertCanonicalPackageRelativePath(path, label) + if (seen.has(path)) { + throw new Error(`Invalid ${label}: duplicate path "${path}".`) + } + seen.add(path) + } +} diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index e9525f86..bfa286f6 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -1,6 +1,6 @@ import { scanForIntents } from '../discovery/scanner.js' import { detectIntentAudience } from '../shared/environment.js' -import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js' +import { ALLOW_ALL_NOTICE, escapeReviewValue } from '../shared/cli-output.js' import { compileExcludePatterns, getConfigDirs, @@ -48,7 +48,7 @@ export interface LoadRefusal { message: string } -export function isSourcePermitted( +function isSourcePermitted( config: SkillSourcesConfig, packageName: string, packageKind?: 'npm' | 'workspace', @@ -83,12 +83,13 @@ export function checkLoadAllowed( params: { config: SkillSourcesConfig excludeMatchers: Array + sourceKind?: IntentPackage['kind'] }, ): LoadRefusal | null { - const { config, excludeMatchers } = params + const { config, excludeMatchers, sourceKind } = params const { packageName, skillName } = parsed - if (isPackageExcluded(packageName, excludeMatchers)) { + if (isPackageExcluded(packageName, excludeMatchers, sourceKind)) { return { code: 'package-excluded', message: `Cannot load skill use "${use}": package "${packageName}" is excluded by Intent configuration.`, @@ -98,11 +99,11 @@ export function checkLoadAllowed( // Name-only pre-check: kind isn't known yet at this point in the load path. // A late, kind-aware isSourcePermitted call happens once resolution reveals // the actual kind (see intent-core.ts). - if (!isSourcePermitted(config, packageName)) { + if (!isSourcePermitted(config, packageName, sourceKind)) { return packageNotListedRefusal(use, packageName) } - if (isSkillExcluded(packageName, skillName, excludeMatchers)) { + if (isSkillExcluded(packageName, skillName, excludeMatchers, sourceKind)) { return { code: 'skill-excluded', message: `Cannot load skill use "${use}": skill "${packageName}#${skillName}" is excluded by Intent configuration.`, @@ -120,7 +121,11 @@ function formatUnlistedNotice( hiddenSources: Array, audience: IntentAudience, ): string { - const sorted = [...hiddenSources].sort((a, b) => a.name.localeCompare(b.name)) + const sorted = hiddenSources.toSorted((a, b) => + sourceIdentityKey({ kind: a.kind, id: a.name }).localeCompare( + sourceIdentityKey({ kind: b.kind, id: b.name }), + ), + ) const sourceCount = sorted.length const skillCount = sorted.reduce((sum, source) => sum + source.skillCount, 0) @@ -129,7 +134,18 @@ function formatUnlistedNotice( } const noun = sourceCount === 1 ? 'package ships' : 'packages ship' - return `${sourceCount} discovered ${noun} skills but ${sourceCount === 1 ? 'is' : 'are'} not listed in intent.skills: ${sorted.map((source) => source.name).join(', ')}. Add to opt in.` + const sources = sorted + .map((source) => { + const provenance = source.provenance + ?.map((path) => path.map(escapeReviewValue).join(' -> ')) + .join('; ') + const label = `${source.kind}:${escapeReviewValue(source.name)}` + return provenance + ? `${label} (via ${provenance})` + : `${label} (provenance unknown)` + }) + .join(', ') + return `${sourceCount} discovered ${noun} skills but ${sourceCount === 1 ? 'is' : 'are'} not listed in intent.skills: ${sources}. Add to opt in.` } export interface SourcePolicyResult { @@ -158,24 +174,30 @@ export function applySourcePolicy( const hiddenSources: Array = [] for (const pkg of scanResult.packages) { - if (isPackageExcluded(pkg.name, excludeMatchers)) continue + if (isPackageExcluded(pkg.name, excludeMatchers, pkg.kind)) continue if (!isSourcePermitted(config, pkg.name, pkg.kind)) { - if (config.mode === 'explicit') { - hiddenSources.push({ name: pkg.name, skillCount: pkg.skills.length }) + if (config.mode === 'explicit' || config.mode === 'empty') { + hiddenSources.push({ + kind: pkg.kind, + name: pkg.name, + skillCount: pkg.skills.length, + ...(pkg.provenance ? { provenance: pkg.provenance } : {}), + }) } continue } const skills = pkg.skills.filter( - (skill) => !isSkillExcluded(pkg.name, skill.name, excludeMatchers), + (skill) => + !isSkillExcluded(pkg.name, skill.name, excludeMatchers, pkg.kind), ) packages.push( skills.length === pkg.skills.length ? pkg : { ...pkg, skills }, ) } - if (hiddenSources.length > 0) { + if (hiddenSources.length > 0 && config.mode === 'explicit') { emit(formatUnlistedNotice(hiddenSources, audience)) } @@ -263,8 +285,6 @@ export function scanForPolicedIntents(params: { excludeMatchers, }) - // Name-only Sets, correct because the scanner guarantees at most one - // package per name (createPackageRegistrar dedups before this runs). const survivingNames = new Set(policy.packages.map((pkg) => pkg.name)) const droppedNames = scanResult.packages .map((pkg) => pkg.name) diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index cf897035..cc179107 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -18,8 +18,10 @@ export interface IntentCoreOptions { export type IntentAudience = 'agent' | 'human' export interface IntentHiddenSourceSummary { + kind: IntentPackage['kind'] name: string skillCount: number + provenance?: Array> } export interface IntentSkillSummary { @@ -117,6 +119,7 @@ export function sourceIdentityEquals( export type IntentCoreErrorCode = | 'invalid-options' | 'invalid-skill-use' + | 'package-ambiguous' | 'package-not-found' | 'package-excluded' | 'package-not-listed' diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index 90516a07..a93ee476 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' import { join, sep } from 'node:path' +import { sourceIdentityKey } from '../core/types.js' import { rewriteSkillLoadPaths } from '../skills/paths.js' import { listNodeModulesPackageDirs } from '../shared/utils.js' import type { @@ -11,6 +12,8 @@ import type { type PackageJson = Record +const MAX_PROVENANCE_PATHS = 3 + function isLocalToProject(dirPath: string, projectRoot: string): boolean { return ( dirPath.startsWith(projectRoot + sep) || @@ -42,8 +45,32 @@ export interface CreatePackageRegistrarOptions { export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { const attemptedPackageRoots = new Set() + const provenanceByPackageRoot = new Map>>() + const registeredPackagesByRoot = new Map() const scannedNodeModulesDirs = new Set() + function recordProvenance( + dirPath: string, + provenance: ReadonlyArray | undefined, + ): void { + if (!provenance || provenance.length === 0) return + + const rootKey = opts.getFsIdentity(dirPath) + const paths = provenanceByPackageRoot.get(rootKey) ?? [] + if (!provenanceByPackageRoot.has(rootKey)) { + provenanceByPackageRoot.set(rootKey, paths) + } + + if ( + paths.length < MAX_PROVENANCE_PATHS && + !paths.some((path) => path.join('\0') === provenance.join('\0')) + ) { + paths.push([...provenance]) + const registered = registeredPackagesByRoot.get(rootKey) + if (registered) registered.provenance = paths + } + } + function shouldAttemptPackageRoot(dirPath: string): boolean { const key = opts.getFsIdentity(dirPath) if (attemptedPackageRoots.has(key)) return false @@ -79,7 +106,9 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { dirPath: string, fallbackName: string, source: IntentPackage['source'] = 'local', + provenance?: ReadonlyArray, ): boolean { + recordProvenance(dirPath, provenance) if (!shouldAttemptPackageRoot(dirPath)) return false const skillsDir = join(dirPath, 'skills') @@ -105,12 +134,14 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { } const skills = opts.discoverSkills(skillsDir, name) + const kind = opts.getPackageKind(dirPath) if (isLocalToProject(dirPath, opts.projectRoot)) { rewriteSkillLoadPaths({ packageName: name, packageRoot: dirPath, projectRoot: opts.projectRoot, + preferStableNodeModulesPath: kind === 'npm', skills, }) } @@ -121,13 +152,25 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { intent, skills, packageRoot: dirPath, - kind: opts.getPackageKind(dirPath), + kind, source, + ...(provenanceByPackageRoot.get(opts.getFsIdentity(dirPath))?.length + ? { + provenance: provenanceByPackageRoot.get( + opts.getFsIdentity(dirPath), + ), + } + : {}), } - const existingIndex = opts.packageIndexes.get(name) + const candidateKey = sourceIdentityKey({ + kind: candidate.kind, + id: candidate.name, + }) + const existingIndex = opts.packageIndexes.get(candidateKey) if (existingIndex === undefined) { opts.rememberVariant(candidate) - opts.packageIndexes.set(name, opts.packages.push(candidate) - 1) + opts.packageIndexes.set(candidateKey, opts.packages.push(candidate) - 1) + registeredPackagesByRoot.set(opts.getFsIdentity(dirPath), candidate) return true } @@ -154,6 +197,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { if (shouldReplace) { opts.packages[existingIndex] = candidate + registeredPackagesByRoot.set(opts.getFsIdentity(dirPath), candidate) } return true diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 622113ce..6e5993a7 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -17,6 +17,7 @@ import { findWorkspacePackages, findWorkspaceRoot, } from '../setup/workspace-patterns.js' +import { sourceIdentityKey } from '../core/types.js' import { createIntentFsCache } from './fs-cache.js' import { detectPackageManager } from './package-manager.js' import { createDependencyWalker, createPackageRegistrar } from './index.js' @@ -375,23 +376,32 @@ function getSkillNameHints( // --------------------------------------------------------------------------- function topoSort(packages: Array): Array { - const byName = new Map(packages.map((p) => [p.name, p])) + const byName = new Map>() + for (const pkg of packages) { + const matches = byName.get(pkg.name) + if (matches) { + matches.push(pkg) + } else { + byName.set(pkg.name, [pkg]) + } + } const visited = new Set() const sorted: Array = [] - function visit(name: string): void { - if (visited.has(name)) return - visited.add(name) - const pkg = byName.get(name) - if (!pkg) return + function visit(pkg: IntentPackage): void { + const key = sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) + if (visited.has(key)) return + visited.add(key) for (const dep of pkg.intent.requires ?? []) { - visit(dep) + for (const dependency of byName.get(dep) ?? []) { + visit(dependency) + } } sorted.push(pkg) } for (const pkg of packages) { - visit(pkg.name) + visit(pkg) } return sorted } @@ -400,6 +410,10 @@ function getPackageDepth(packageRoot: string, projectRoot: string): number { return relative(projectRoot, packageRoot).split(sep).length } +function packageIdentityKey(pkg: IntentPackage): string { + return sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) +} + function normalizeVersion(version: string): string | null { const validVersion = semver.valid(version) if (validVersion) return validVersion @@ -519,7 +533,6 @@ export function scanForIntents( : undefined, }, } - // Track registered package names to avoid duplicates across phases const packageIndexes = new Map() const packageVariants = new Map< string, @@ -549,10 +562,11 @@ export function scanForIntents( } function rememberVariant(pkg: IntentPackage): void { - let variants = packageVariants.get(pkg.name) + const key = packageIdentityKey(pkg) + let variants = packageVariants.get(key) if (!variants) { variants = new Map() - packageVariants.set(pkg.name, variants) + packageVariants.set(key, variants) } variants.set(pkg.packageRoot, { version: pkg.version, @@ -562,7 +576,7 @@ export function scanForIntents( function ensureGlobalNodeModules(): void { if (!nodeModules.global.path && !explicitGlobalNodeModules) { - const detected = detectGlobalNodeModules(packageManager) + const detected = detectGlobalNodeModules(packageManager, options.frozen) nodeModules.global.path = detected.path nodeModules.global.source = detected.source nodeModules.global.detected = Boolean(detected.path) @@ -710,11 +724,12 @@ export function scanForIntents( conflicts, nodeModules, stats: getStats(), + readFs: fsCache.getReadFs(), } } for (const pkg of packages) { - const variants = packageVariants.get(pkg.name) + const variants = packageVariants.get(packageIdentityKey(pkg)) if (!variants) continue const conflict = toVersionConflict(pkg.name, [...variants.values()], pkg) @@ -739,6 +754,7 @@ export function scanForIntents( conflicts, nodeModules, stats: getStats(), + readFs: fsCache.getReadFs(), } } diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts index ac233cf0..bf4b73a5 100644 --- a/packages/intent/src/discovery/walk.ts +++ b/packages/intent/src/discovery/walk.ts @@ -16,7 +16,12 @@ export interface CreateDependencyWalkerOptions { readPkgJson: (dirPath: string) => PackageJson | null getFsIdentity: (path: string) => string scanNodeModulesDir: (nodeModulesDir: string) => void - tryRegister: (dirPath: string, fallbackName: string) => boolean + tryRegister: ( + dirPath: string, + fallbackName: string, + source?: IntentPackage['source'], + provenance?: ReadonlyArray, + ) => boolean packages: Array warnings: Array } @@ -47,17 +52,23 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { pkgJson: PackageJson, fromDir: string, includeDevDeps = false, + provenance: ReadonlyArray = [], ): void { for (const depName of getDeps(pkgJson, includeDevDeps)) { const depDir = resolveDepDirCached(depName, fromDir) if (!depDir) continue - opts.tryRegister(depDir, depName) - walkDeps(depDir, depName) + const dependencyProvenance = [...provenance, depName] + opts.tryRegister(depDir, depName, 'local', dependencyProvenance) + walkDeps(depDir, depName, dependencyProvenance) } } - function walkDeps(pkgDir: string, pkgName: string): void { + function walkDeps( + pkgDir: string, + pkgName: string, + provenance: ReadonlyArray = [], + ): void { // Resolve from the realpath: a pnpm symlink path can't resolve store-only // transitive deps, and walkVisited dedups on realpath so no later retry. const pkgKey = opts.getFsIdentity(pkgDir) @@ -72,19 +83,21 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { return } - walkDepsOf(pkgJson, pkgKey) + walkDepsOf(pkgJson, pkgKey, false, provenance) } function walkKnownPackages(): void { for (const pkg of [...opts.packages]) { - walkDeps(pkg.packageRoot, pkg.name) + walkDeps(pkg.packageRoot, pkg.name, [pkg.name]) } } function walkProjectDeps(): void { const projectPkg = readPkgJsonWithWarning(opts.projectRoot, 'project') if (!projectPkg) return - walkDepsOf(projectPkg, opts.projectRoot, true) + const projectName = + typeof projectPkg.name === 'string' ? projectPkg.name : 'project' + walkDepsOf(projectPkg, opts.projectRoot, true, [projectName]) } function readPkgJsonWithWarning( @@ -115,7 +128,10 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { const wsPkg = readPkgJsonWithWarning(wsDir, 'workspace') if (wsPkg) { - walkDepsOf(wsPkg, wsDir) + const workspaceName = + typeof wsPkg.name === 'string' ? wsPkg.name : 'unknown' + opts.tryRegister(wsDir, workspaceName, 'local', [workspaceName]) + walkDepsOf(wsPkg, wsDir, false, [workspaceName]) } } } diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index dc71567d..f0e08447 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -48,3 +48,13 @@ export type { SkillStaleness, StalenessSignal, } from './shared/types.js' +export type { + IntentLockfile, + IntentLockfilePolicy, + IntentLockfilePolicyIgnore, + IntentLockfileSource, + IntentLockfileStaleness, + IntentLockfileStalenessBaseline, + ReadIntentLockfileResult, +} from './core/lockfile/lockfile.js' +export type { SourceIdentity } from './core/types.js' diff --git a/packages/intent/src/shared/cli-output.ts b/packages/intent/src/shared/cli-output.ts index f281d886..ae7fab23 100644 --- a/packages/intent/src/shared/cli-output.ts +++ b/packages/intent/src/shared/cli-output.ts @@ -1,8 +1,29 @@ +import { isEnvFlagSet } from './env-flag.js' + // Lives here (not core/source-policy.ts) so printNotices can enforce // non-suppressibility by identity without core importing this module. export const ALLOW_ALL_NOTICE = 'All skill sources allowed (intent.skills: ["*"]) — unvetted skills may be surfaced into agent guidance.' +function escapeUnsafeUnicode(value: string): string { + return value.replace( + /[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, + (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`, + ) +} + +export function formatReviewJson(value: unknown): string { + return escapeUnsafeUnicode(String(JSON.stringify(value))) +} + +export function escapeReviewValue(value: string): string { + // JSON escaping neutralizes terminal control bytes. Escape additional + // bidi/C1 controls that JSON permits literally so untrusted skill content + // and paths cannot visually reorder or overwrite CLI output. + return formatReviewJson(value).slice(1, -1) +} + export function printWarnings(warnings: Array): void { if (warnings.length === 0) return @@ -16,11 +37,8 @@ export interface NoticeOutputOptions { noNotices?: boolean } -const TRUE_LIKE_VALUES = new Set(['1', 'true', 'yes', 'on']) - function envSuppressesNotices(): boolean { - const value = process.env.INTENT_NO_NOTICES?.trim().toLowerCase() - return value ? TRUE_LIKE_VALUES.has(value) : false + return isEnvFlagSet('INTENT_NO_NOTICES') } function shouldSuppressNotices(options: NoticeOutputOptions = {}): boolean { diff --git a/packages/intent/src/shared/env-flag.ts b/packages/intent/src/shared/env-flag.ts new file mode 100644 index 00000000..f89b2ea4 --- /dev/null +++ b/packages/intent/src/shared/env-flag.ts @@ -0,0 +1,6 @@ +const TRUE_LIKE_VALUES = new Set(['1', 'true', 'yes', 'on']) + +export function isEnvFlagSet(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase() + return value ? TRUE_LIKE_VALUES.has(value) : false +} diff --git a/packages/intent/src/shared/mode.ts b/packages/intent/src/shared/mode.ts new file mode 100644 index 00000000..0ea748dd --- /dev/null +++ b/packages/intent/src/shared/mode.ts @@ -0,0 +1,28 @@ +import { isEnvFlagSet } from './env-flag.js' + +export interface FrozenModeOptions { + frozen?: boolean + noFrozen?: boolean +} + +export interface FrozenModeContext { + isTTY?: boolean +} + +// M2-SPEC §8.1: --no-frozen is the highest-precedence explicit override, +// beating INTENT_FROZEN and the CI auto-detect alike. +export function isFrozenMode( + options: FrozenModeOptions = {}, + context: FrozenModeContext = {}, +): boolean { + if (options.frozen && options.noFrozen) { + throw new Error('Use either --frozen or --no-frozen, not both.') + } + + if (options.frozen) return true + if (options.noFrozen) return false + if (isEnvFlagSet('INTENT_FROZEN')) return true + + const isTTY = context.isTTY ?? process.stdin.isTTY + return isEnvFlagSet('CI') && isTTY !== true +} diff --git a/packages/intent/src/shared/types.ts b/packages/intent/src/shared/types.ts index 994ed539..e4f03127 100644 --- a/packages/intent/src/shared/types.ts +++ b/packages/intent/src/shared/types.ts @@ -1,3 +1,5 @@ +import type { ReadFs } from './utils.js' + // --------------------------------------------------------------------------- // Intent config (lives in library package.json under "intent" key) // --------------------------------------------------------------------------- @@ -24,6 +26,7 @@ export interface ScanResult { global: NodeModulesScanTarget } stats: ScanStats + readFs?: ReadFs } export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' @@ -33,6 +36,8 @@ export type ScanScope = 'local' | 'local-and-global' | 'global' export interface ScanOptions { includeGlobal?: boolean scope?: ScanScope + // Omitted falls back to bare env-based isFrozenMode() detection. + frozen?: boolean } export interface ScanStats { @@ -56,6 +61,7 @@ export interface IntentPackage { packageRoot: string kind: 'npm' | 'workspace' source: 'local' | 'global' + provenance?: Array> } export interface InstalledVariant { diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index f3d6930e..10405bc0 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -12,6 +12,7 @@ import { import { createRequire } from 'node:module' import { dirname, join, resolve, sep } from 'node:path' import { parse as parseYaml } from 'yaml' +import { isFrozenMode } from './mode.js' import type { Dirent } from 'node:fs' /** @@ -48,9 +49,6 @@ export const nodeReadFs: ReadFs = { closeSync, } -/** - * Convert a path to use forward slashes (for cross-platform consistency). - */ export function toPosixPath(p: string): string { return p.split(sep).join('/') } @@ -80,9 +78,6 @@ export function createFsIdentityCache( } } -/** - * Recursively find all SKILL.md files under a directory. - */ export function findSkillFiles( dir: string, fs: ReadFs = nodeReadFs, @@ -138,10 +133,6 @@ export function hasAnySkillFile(dir: string, fs: ReadFs = nodeReadFs): boolean { return false } -/** - * Read dependencies and peerDependencies (and optionally devDependencies) from - * a parsed package.json object. - */ export function getDeps( pkgJson: Record, includeDevDeps = false, @@ -258,7 +249,10 @@ export function listNestedNodeModulesPackageDirs( const GLOBAL_NODE_MODULES_COMMAND_TIMEOUT_MS = 5_000 -export function detectGlobalNodeModules(packageManager: string): { +export function detectGlobalNodeModules( + packageManager: string, + frozen?: boolean, +): { path: string | null source?: string } { @@ -270,6 +264,9 @@ export function detectGlobalNodeModules(packageManager: string): { } } + // An explicit caller decision takes precedence over env-based detection. + if (frozen ?? isFrozenMode()) return { path: null } + const commands: Array<{ command: string args: Array @@ -309,11 +306,6 @@ export function detectGlobalNodeModules(packageManager: string): { return { path: null } } -/** - * Resolve the directory of a dependency by name. Tries createRequire first - * (handles pnpm symlinks), then falls back to walking up node_modules - * directories (handles packages with export maps that block ./package.json). - */ /** * `createRequire` builds a full module-resolution context; constructing it is * non-trivial and `resolveDepDir` is called once per dependency, often many @@ -338,7 +330,6 @@ export function resolveDepDir( depName: string, parentDir: string, ): string | null { - // Try createRequire — works for most packages including pnpm virtual store try { const req = getRequireForBase(join(parentDir, 'package.json')) const pkgJsonPath = req.resolve(join(depName, 'package.json')) @@ -359,8 +350,6 @@ export function resolveDepDir( } } - // Fallback: walk up from parentDir checking node_modules/. - // Handles packages with exports maps that don't expose ./package.json. let dir = parentDir let prev: string | undefined while (dir !== prev) { @@ -390,9 +379,6 @@ export function readScalarField( return typeof top === 'string' ? top : undefined } -/** - * Parse YAML frontmatter from a file. Returns null if no frontmatter or on error. - */ export function parseFrontmatter( filePath: string, fs: ReadFs = nodeReadFs, @@ -413,11 +399,6 @@ const FRONTMATTER_READ_LIMIT = 16 * 1024 /** Reused across calls; safe because reads are synchronous and single-threaded. */ const frontmatterBuffer = Buffer.allocUnsafe(FRONTMATTER_READ_LIMIT) -/** - * Read just the leading region of a file, enough to cover its frontmatter, - * instead of its whole body. Falls back to a full read when the bounded read - * primitives are unavailable or the frontmatter exceeds the probe limit. - */ function readFrontmatterRegion(filePath: string, fs: ReadFs): string | null { if (fs.openSync && fs.readSync && fs.closeSync) { let region: string | null = null diff --git a/packages/intent/src/skills/paths.ts b/packages/intent/src/skills/paths.ts index cc236722..69157bcb 100644 --- a/packages/intent/src/skills/paths.ts +++ b/packages/intent/src/skills/paths.ts @@ -42,14 +42,17 @@ export function rewriteSkillLoadPaths({ packageName, packageRoot, projectRoot, + preferStableNodeModulesPath = true, skills, }: { packageName: string packageRoot: string projectRoot: string + preferStableNodeModulesPath?: boolean skills: Array }): void { const hasStableSymlink = + preferStableNodeModulesPath && packageName !== '' && existsSync(join(projectRoot, 'node_modules', packageName)) diff --git a/packages/intent/src/skills/resolver.ts b/packages/intent/src/skills/resolver.ts index 93f0142d..8f3ea650 100644 --- a/packages/intent/src/skills/resolver.ts +++ b/packages/intent/src/skills/resolver.ts @@ -18,7 +18,10 @@ export interface ResolveSkillResult { conflict: VersionConflict | null } -export type ResolveSkillUseErrorCode = 'package-not-found' | 'skill-not-found' +export type ResolveSkillUseErrorCode = + | 'package-ambiguous' + | 'package-not-found' + | 'skill-not-found' export class ResolveSkillUseError extends Error { readonly code: ResolveSkillUseErrorCode @@ -149,10 +152,8 @@ export function resolveSkillUse( ): ResolveSkillResult { const { packageName, skillName } = parseSkillUse(use) const packages = scanResult.packages.filter((pkg) => pkg.name === packageName) - const pkg = - packages.find((candidate) => candidate.source === 'local') ?? packages[0] - if (!pkg) { + if (packages.length === 0) { throw new ResolveSkillUseError({ availablePackages: scanResult.packages.map((candidate) => candidate.name), code: 'package-not-found', @@ -162,7 +163,46 @@ export function resolveSkillUse( }) } - const resolvedSkill = resolveSkillEntry(packageName, skillName, pkg.skills) + const packagesByIdentity = new Map>() + for (const candidate of packages) { + const identity = `${candidate.kind}:${candidate.name}` + const identityPackages = packagesByIdentity.get(identity) + if (identityPackages) { + identityPackages.push(candidate) + } else { + packagesByIdentity.set(identity, [candidate]) + } + } + const candidates = [...packagesByIdentity.entries()].map( + ([identity, identityPackages]) => { + const pkg = + identityPackages.find((candidate) => candidate.source === 'local') ?? + identityPackages[0]! + return { + identity, + pkg, + resolvedSkill: resolveSkillEntry(packageName, skillName, pkg.skills), + } + }, + ) + const matches = candidates.filter( + (candidate) => candidate.resolvedSkill.skill, + ) + + if (matches.length > 1) { + throw new ResolveSkillUseError({ + availablePackages: matches + .map((candidate) => candidate.identity) + .toSorted(), + code: 'package-ambiguous', + packageName, + skillName, + use, + }) + } + + const selected = matches[0] ?? candidates[0]! + const { pkg, resolvedSkill } = selected const skill = resolvedSkill.skill if (!skill) { @@ -213,6 +253,8 @@ function formatResolveSkillUseErrorMessage({ use: string }): string { switch (code) { + case 'package-ambiguous': + return `Cannot resolve skill use "${use}": package "${packageName}" is ambiguous between ${availablePackages.join(' and ')}.` case 'package-not-found': { const available = availablePackages.length > 0 diff --git a/packages/intent/src/staleness/check.ts b/packages/intent/src/staleness/check.ts index 561b0b3f..9d58a9d1 100644 --- a/packages/intent/src/staleness/check.ts +++ b/packages/intent/src/staleness/check.ts @@ -7,6 +7,7 @@ import { readScalarField, toPosixPath, } from '../shared/utils.js' +import { isFrozenMode } from '../shared/mode.js' import { readIntentArtifacts } from './artifact-coverage.js' import type { IntentArtifactSet, @@ -93,6 +94,10 @@ function readLocalVersion(packageDir: string): string | null { const NPM_REGISTRY_FETCH_TIMEOUT_MS = 5_000 async function fetchNpmVersion(packageName: string): Promise { + // Frozen mode forbids outbound network calls entirely; treat the registry + // as unreachable rather than threading a frozen flag through every caller. + if (isFrozenMode()) return null + try { const res = await fetch( `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index aedcbe47..88686eb4 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -222,6 +222,17 @@ describe('cli commands', () => { expect(output).toContain('--show-hidden') }) + it('does not claim --no-frozen defaults to true in skills help', async () => { + const exitCode = await main(['skills', '--help']) + const output = getHelpOutput() + + expect(exitCode).toBe(0) + expect(output).toContain('--no-frozen') + expect(output).not.toContain( + '--no-frozen Force interactive mode, overriding INTENT_FROZEN/CI auto-detect (default: true)', + ) + }) + it('prints the install prompt', async () => { const exitCode = await main(['install', '--print-prompt']) const output = String(logSpy.mock.calls[0]?.[0]) @@ -901,12 +912,45 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Hidden skill sources:') - expect(output).toContain('get-tsconfig') + expect(output).toContain('npm:get-tsconfig') expect(output).toContain('1 skill') - expect(stderr).toContain('get-tsconfig') + expect(stderr).toContain('npm:get-tsconfig') expect(stderr).toContain('Add to opt in') }) + it('escapes hidden source names in human list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-escape-')) + tempDirs.push(root) + 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: 'evil\u001b[2J\u202e', + version: '1.0.0', + skillName: 'escape', + description: 'Untrusted package name', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list', '--show-hidden'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(output).toContain('npm:evil\\u001b[2J\\u202e') + expect(stderr).toContain('npm:evil\\u001b[2J\\u202e') + expect(`${output}\n${stderr}`).not.toContain('\u001b') + expect(`${output}\n${stderr}`).not.toContain('\u202e') + }) + it('does not reveal hidden skill sources to agent list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-agent-')) tempDirs.push(root) @@ -3541,6 +3585,226 @@ describe('cli commands', () => { }) }) +describe('intent skills', () => { + let previousCi: string | undefined + let previousIntentFrozen: string | undefined + + beforeEach(() => { + previousCi = process.env.CI + previousIntentFrozen = process.env.INTENT_FROZEN + delete process.env.CI + delete process.env.INTENT_FROZEN + }) + + afterEach(() => { + if (previousCi === undefined) { + delete process.env.CI + } else { + process.env.CI = previousCi + } + if (previousIntentFrozen === undefined) { + delete process.env.INTENT_FROZEN + } else { + process.env.INTENT_FROZEN = previousIntentFrozen + } + }) + + function makeSkillsProject(): string { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-skills-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + return root + } + + it('reports no lockfile for `skills scan` with no flags', async () => { + const root = makeSkillsProject() + process.chdir(root) + + const exitCode = await main(['skills', 'scan']) + + expect(exitCode).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('No intent.lock found') + }) + + // Regression test: cac's --no-x negation convention gives the "frozen" key + // a default of `true` whenever --no-frozen is *registered*, even when + // neither --frozen nor --no-frozen is passed on the command line. Confirms + // frozenOptionsFromGlobalFlags reads raw argv instead of cac's options. + it('does not force frozen mode when neither --frozen nor --no-frozen is passed', async () => { + const root = makeSkillsProject() + process.chdir(root) + + const exitCode = await main(['skills', 'scan', '--json']) + + expect(exitCode).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(JSON.parse(output)).toMatchObject({ frozen: false }) + }) + + it('forces frozen mode with --frozen and fails on a missing lockfile', async () => { + const root = makeSkillsProject() + process.chdir(root) + + const exitCode = await main(['skills', 'scan', '--frozen']) + + expect(exitCode).not.toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Frozen mode requires intent.lock'), + ) + }) + + it('--no-frozen disables CI auto-detection', async () => { + const root = makeSkillsProject() + process.chdir(root) + process.env.CI = 'true' + + const exitCode = await main(['skills', 'scan', '--no-frozen', '--json']) + + expect(exitCode).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(JSON.parse(output)).toMatchObject({ frozen: false }) + }) + + it('reports no lockfile for `skills diff` with no flags', async () => { + const root = makeSkillsProject() + process.chdir(root) + + const exitCode = await main(['skills', 'diff']) + + expect(exitCode).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('No intent.lock found') + }) + + // Regression test: scanIntentsOrFail discards hiddenSourceCount/hiddenSources + // from PolicedScan, so an unlisted skill-bearing package never reached + // enforceFrozenMode — it silently passed `--frozen` even though the RFC + // requires unlisted sources to hard-fail independently of lockfile drift. + it('fails `skills scan --frozen` when a discovered package is not in intent.skills', async () => { + const root = makeSkillsProject() + 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: 'get-tsconfig', + version: '4.0.0', + skillName: 'config', + description: 'TypeScript config lookup', + }) + process.chdir(root) + + const exitCode = await main(['skills', 'scan', '--frozen']) + + expect(exitCode).not.toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('unlisted skill-bearing source'), + ) + }) + + it('`skills approve --all` writes intent.lock from currently-installed listed sources', async () => { + const root = makeSkillsProject() + 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', + }) + process.chdir(root) + + const exitCode = await main(['skills', 'approve', '--all']) + + expect(exitCode).toBe(0) + const lockfile = JSON.parse( + readFileSync(join(root, 'intent.lock'), 'utf8'), + ) as { sources: Array<{ id: string }> } + expect(lockfile.sources.map((s) => s.id)).toEqual(['@tanstack/query']) + }) + + it('`skills approve` refuses to run in frozen mode', async () => { + const root = makeSkillsProject() + process.chdir(root) + + const exitCode = await main(['skills', 'approve', '--all', '--frozen']) + + expect(exitCode).not.toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('cannot run in frozen mode'), + ) + }) + + it('`skills update --all` re-syncs a version bump into intent.lock', async () => { + const root = makeSkillsProject() + 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', + }) + process.chdir(root) + await main(['skills', 'approve', '--all']) + + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.1.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + const exitCode = await main(['skills', 'update', '--all']) + + expect(exitCode).toBe(0) + const lockfile = JSON.parse( + readFileSync(join(root, 'intent.lock'), 'utf8'), + ) as { sources: Array<{ id: string; version: string }> } + expect(lockfile.sources).toEqual([ + expect.objectContaining({ id: '@tanstack/query', version: '5.1.0' }), + ]) + }) + + it('`skills update` refuses to run in frozen mode', async () => { + const root = makeSkillsProject() + process.chdir(root) + + const exitCode = await main(['skills', 'update', '--all', '--frozen']) + + expect(exitCode).not.toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('cannot run in frozen mode'), + ) + }) + + it('fails on an unknown skills action', async () => { + const root = makeSkillsProject() + process.chdir(root) + + const exitCode = await main(['skills', 'nope']) + + expect(exitCode).not.toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown skills action'), + ) + }) +}) + describe('package metadata', () => { it('uses a package-manager-neutral prepack script', () => { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { diff --git a/packages/intent/tests/content-review.test.ts b/packages/intent/tests/content-review.test.ts new file mode 100644 index 00000000..ceef5a2a --- /dev/null +++ b/packages/intent/tests/content-review.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { assertSourceContentReviewsMatch } from '../src/core/lockfile/content-review.js' +import type { SourceContentReview } from '../src/core/lockfile/content-review.js' +import type { IntentLockfileSource } from '../src/core/lockfile/lockfile.js' + +function review(contentHash: string): SourceContentReview { + return { + id: 'foo', + kind: 'npm', + version: '1.0.0', + files: [], + contentHash, + } +} + +function source(contentHash: string): IntentLockfileSource { + return { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: [], + contentHash, + manifestHash: null, + capabilities: null, + } +} + +describe('assertSourceContentReviewsMatch', () => { + it('accepts reviewed bytes matching the lock candidate hash', () => { + expect(() => + assertSourceContentReviewsMatch( + [review('sha256-current')], + [source('sha256-current')], + ), + ).not.toThrow() + }) + + it('rejects content changed between hashing and review', () => { + expect(() => + assertSourceContentReviewsMatch( + [review('sha256-review')], + [source('sha256-current')], + ), + ).toThrow('changed while it was being reviewed') + }) +}) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 0989b1fb..7a7848a9 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -15,6 +15,8 @@ import { loadIntentSkill, resolveIntentSkill, } from '../src/core/index.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' const realTmpdir = realpathSync(tmpdir()) @@ -268,10 +270,10 @@ describe('listIntentSkills', () => { expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) expect(result.hiddenSourceCount).toBe(1) expect(result.hiddenSources).toEqual([ - { name: '@tanstack/unlisted', skillCount: 1 }, + { kind: 'npm', name: '@tanstack/unlisted', skillCount: 1 }, ]) expect(result.notices).toEqual([ - '1 discovered package ships skills but is not listed in intent.skills: @tanstack/unlisted. Add to opt in.', + '1 discovered package ships skills but is not listed in intent.skills: npm:@tanstack/unlisted (provenance unknown). Add to opt in.', ]) }) @@ -469,6 +471,148 @@ describe('loadIntentSkill', () => { }) }) + it('refuses changed content when frozen mode has an approved lockfile entry', () => { + const previousFrozen = process.env.INTENT_FROZEN + process.env.INTENT_FROZEN = '1' + try { + 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 current = listIntentSkills({ cwd: root }) + const pkg = { + name: current.packages[0]!.name, + version: current.packages[0]!.version, + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + skills: [ + { + name: 'fetching', + path: join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'SKILL.md', + ), + description: 'Query data fetching patterns', + }, + ], + packageRoot: join(root, 'node_modules', '@tanstack', 'query'), + kind: 'npm' as const, + source: 'local' as const, + } + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: buildCurrentLockfileSources([pkg]), + policy: { ignores: [] }, + }) + writeFileSync(pkg.skills[0]!.path, 'changed guidance') + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow('intent.lock is out of date') + } finally { + if (previousFrozen === undefined) delete process.env.INTENT_FROZEN + else process.env.INTENT_FROZEN = previousFrozen + } + }) + + it('refuses changed content in ordinary mode when an approved lockfile exists', () => { + 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 packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const skillPath = join(packageRoot, 'skills', 'fetching', 'SKILL.md') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: buildCurrentLockfileSources([ + { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + skills: [ + { + name: 'fetching', + path: skillPath, + description: 'Query data fetching patterns', + }, + ], + packageRoot, + kind: 'npm', + source: 'local', + }, + ]), + policy: { ignores: [] }, + }) + writeFileSync(skillPath, 'changed guidance') + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow('intent.lock is out of date') + }) + + it('refuses a drifted agent catalog when an approved lockfile exists', () => { + 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 packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const skillPath = join(packageRoot, 'skills', 'fetching', 'SKILL.md') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: buildCurrentLockfileSources([ + { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + skills: [ + { + name: 'fetching', + path: skillPath, + description: 'Query data fetching patterns', + }, + ], + packageRoot, + kind: 'npm', + source: 'local', + }, + ]), + policy: { ignores: [] }, + }) + writeFileSync(skillPath, 'changed guidance') + + expect(() => listIntentSkills({ audience: 'agent', cwd: root })).toThrow( + 'intent.lock is out of date', + ) + }) + it('does not change process cwd when loading from an explicit cwd', () => { writeInstalledIntentPackage(root, { name: '@tanstack/query', diff --git a/packages/intent/tests/excludes.test.ts b/packages/intent/tests/excludes.test.ts index 8ff007be..6d237812 100644 --- a/packages/intent/tests/excludes.test.ts +++ b/packages/intent/tests/excludes.test.ts @@ -18,6 +18,14 @@ describe('exclude matching — package level (backward compatible)', () => { expect(isPackageExcluded('@other/pkg', matchers)).toBe(false) }) + it('matches a kind-qualified package pattern only for that kind', () => { + const matchers = compileExcludePatterns(['workspace:foo']) + + expect(isPackageExcluded('foo', matchers, 'workspace')).toBe(true) + expect(isPackageExcluded('foo', matchers, 'npm')).toBe(false) + expect(isPackageExcluded('foo', 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) diff --git a/packages/intent/tests/global-node-modules.test.ts b/packages/intent/tests/global-node-modules.test.ts new file mode 100644 index 00000000..49e58c33 --- /dev/null +++ b/packages/intent/tests/global-node-modules.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const execFileSyncMock = vi.fn() + +vi.mock('node:child_process', () => ({ + execFileSync: (...args: Array) => execFileSyncMock(...args), +})) + +const { detectGlobalNodeModules } = await import('../src/shared/utils.js') + +afterEach(() => { + vi.unstubAllEnvs() + delete process.env.INTENT_GLOBAL_NODE_MODULES + execFileSyncMock.mockReset() +}) + +describe('detectGlobalNodeModules', () => { + it('shells out to the package manager outside frozen mode', () => { + execFileSyncMock.mockReturnValue('/global/pnpm/node_modules') + + const result = detectGlobalNodeModules('pnpm') + + expect(execFileSyncMock).toHaveBeenCalled() + expect(result.path).toBe('/global/pnpm/node_modules') + }) + + it('makes no subprocess call in frozen mode', () => { + vi.stubEnv('INTENT_FROZEN', '1') + + const result = detectGlobalNodeModules('pnpm') + + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(result).toEqual({ path: null }) + }) + + it('honors an explicit frozen=true override even when no env signal is set', () => { + const result = detectGlobalNodeModules('pnpm', true) + + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(result).toEqual({ path: null }) + }) + + it('honors an explicit frozen=false override even when CI env would auto-detect frozen', () => { + vi.stubEnv('CI', 'true') + execFileSyncMock.mockReturnValue('/global/pnpm/node_modules') + + const result = detectGlobalNodeModules('pnpm', false) + + expect(execFileSyncMock).toHaveBeenCalled() + expect(result.path).toBe('/global/pnpm/node_modules') + }) + + it('still honors INTENT_GLOBAL_NODE_MODULES override in frozen mode', () => { + vi.stubEnv('INTENT_FROZEN', '1') + process.env.INTENT_GLOBAL_NODE_MODULES = '/override/node_modules' + + const result = detectGlobalNodeModules('pnpm') + + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(result).toEqual({ + path: '/override/node_modules', + source: 'INTENT_GLOBAL_NODE_MODULES', + }) + }) +}) diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts new file mode 100644 index 00000000..3f198488 --- /dev/null +++ b/packages/intent/tests/hash.test.ts @@ -0,0 +1,475 @@ +import { + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + HASH_LIMITS, + computeSkillFolderHash, + computeSourceContentHash, +} from '../src/core/lockfile/hash.js' + +const roots: Array = [] + +function createRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'intent-hash-test-')) + roots.push(root) + return root +} + +function writeFile( + dir: string, + relativePath: string, + content: string | Buffer, +): string { + const filePath = join(dir, relativePath) + mkdirSync(join(filePath, '..'), { recursive: true }) + writeFileSync(filePath, content) + return filePath +} + +function sourceHash(root: string, skillPath: string): string { + return computeSourceContentHash(root, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: skillPath }, + ]).contentHash +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('computeSourceContentHash', () => { + it('is deterministic for the same file set', () => { + const root = createRoot() + writeFile(root, 'skills/a/SKILL.md', 'hello') + writeFile(root, 'skills/b/SKILL.md', 'world') + const entries = [ + { + relativePath: 'skills/a/SKILL.md', + absolutePath: join(root, 'skills/a/SKILL.md'), + }, + { + relativePath: 'skills/b/SKILL.md', + absolutePath: join(root, 'skills/b/SKILL.md'), + }, + ] + + expect(computeSourceContentHash(root, entries)).toEqual( + computeSourceContentHash(root, entries), + ) + }) + + it('is independent of input array order', () => { + const root = createRoot() + writeFile(root, 'skills/a/SKILL.md', 'hello') + writeFile(root, 'skills/b/SKILL.md', 'world') + const a = { + relativePath: 'skills/a/SKILL.md', + absolutePath: join(root, 'skills/a/SKILL.md'), + } + const b = { + relativePath: 'skills/b/SKILL.md', + absolutePath: join(root, 'skills/b/SKILL.md'), + } + + expect(computeSourceContentHash(root, [a, b]).contentHash).toBe( + computeSourceContentHash(root, [b, a]).contentHash, + ) + }) + + it('sorts the returned skills[] ordinally regardless of input order', () => { + const root = createRoot() + writeFile(root, 'skills/b/SKILL.md', 'b') + writeFile(root, 'skills/a/SKILL.md', 'a') + const entries = [ + { + relativePath: 'skills/b/SKILL.md', + absolutePath: join(root, 'skills/b/SKILL.md'), + }, + { + relativePath: 'skills/a/SKILL.md', + absolutePath: join(root, 'skills/a/SKILL.md'), + }, + ] + + expect(computeSourceContentHash(root, entries).skills).toEqual([ + 'skills/a/SKILL.md', + 'skills/b/SKILL.md', + ]) + }) + + it('changes when file content changes', () => { + const root = createRoot() + const filePath = writeFile(root, 'skills/a/SKILL.md', 'hello') + const entries = [ + { relativePath: 'skills/a/SKILL.md', absolutePath: filePath }, + ] + const before = computeSourceContentHash(root, entries).contentHash + + writeFileSync(filePath, 'hello!') + + expect(computeSourceContentHash(root, entries).contentHash).not.toBe(before) + }) + + it('changes when a file is renamed with the same content', () => { + const root = createRoot() + const path1 = writeFile(root, 'skills/a/SKILL.md', 'hello') + const path2 = writeFile(root, 'skills/b/SKILL.md', 'hello') + + const original = computeSourceContentHash(root, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: path1 }, + ]) + const renamed = computeSourceContentHash(root, [ + { relativePath: 'skills/b/SKILL.md', absolutePath: path2 }, + ]) + + expect(original.contentHash).not.toBe(renamed.contentHash) + }) + + it('does not collide across a path/content boundary shift', () => { + const root = createRoot() + const pathA = writeFile(root, 'a', 'bc') + const pathB = writeFile(root, 'ab', 'c') + + const hashA = computeSourceContentHash(root, [ + { relativePath: 'a', absolutePath: pathA }, + ]).contentHash + const hashB = computeSourceContentHash(root, [ + { relativePath: 'ab', absolutePath: pathB }, + ]).contentHash + + expect(hashA).not.toBe(hashB) + }) + + it('returns a sha256- prefixed digest, including for an empty skill set', () => { + const root = createRoot() + expect(computeSourceContentHash(root, []).contentHash).toMatch( + /^sha256-[0-9a-f]{64}$/, + ) + }) + + it('rejects duplicate relative paths', () => { + const root = createRoot() + const filePath = writeFile(root, 'SKILL.md', 'a') + + expect(() => + computeSourceContentHash(root, [ + { relativePath: 'SKILL.md', absolutePath: filePath }, + { relativePath: 'SKILL.md', absolutePath: filePath }, + ]), + ).toThrow(/duplicate path/) + }) + + it('rejects an absolute relative path', () => { + const root = createRoot() + const filePath = writeFile(root, 'SKILL.md', 'a') + + expect(() => + computeSourceContentHash(root, [ + { relativePath: '/etc/passwd', absolutePath: filePath }, + ]), + ).toThrow(/must be relative/) + }) + + it("rejects a path containing a '..' segment", () => { + const root = createRoot() + const filePath = writeFile(root, 'SKILL.md', 'a') + + expect(() => + computeSourceContentHash(root, [ + { relativePath: '../outside.md', absolutePath: filePath }, + ]), + ).toThrow(/segments/) + }) + + it('rejects a relative path containing an embedded NUL byte', () => { + const root = createRoot() + const filePath = writeFile(root, 'SKILL.md', 'a') + + expect(() => + computeSourceContentHash(root, [ + { relativePath: 'assets/a\0b.md', absolutePath: filePath }, + ]), + ).toThrow(/NUL byte/) + }) + + it('normalizes CRLF and lone CR to LF in text content', () => { + const root = createRoot() + const filePath = writeFile( + root, + 'SKILL.md', + Buffer.from('line1\r\nline2\rline3\n'), + ) + const normalized = computeSourceContentHash(root, [ + { relativePath: 'SKILL.md', absolutePath: filePath }, + ]) + const already = computeSourceContentHash(root, [ + { + relativePath: 'SKILL.md', + absolutePath: writeFile( + root, + 'already-lf/SKILL.md', + 'line1\nline2\nline3\n', + ), + }, + ]) + + expect(normalized.contentHash).toBe(already.contentHash) + }) + + it('classifies a large buffer with a NUL byte past the first bytes as binary (no CRLF normalization)', () => { + const root = createRoot() + const content = Buffer.concat([ + Buffer.alloc(9000, 0x41), + Buffer.from([0x00]), + Buffer.from('\r\n'), + ]) + const filePath = writeFile(root, 'SKILL.md', content) + + expect( + computeSourceContentHash(root, [ + { relativePath: 'SKILL.md', absolutePath: filePath }, + ]).contentHash, + ).toMatch(/^sha256-[0-9a-f]{64}$/) + }) + + it('keeps non-UTF-8 binary bytes exact when no NUL byte is present', () => { + const root = createRoot() + const filePath = writeFile( + root, + 'assets/data.bin', + Buffer.from([0xff, 0x0d, 0x0a]), + ) + const entries = [ + { relativePath: 'assets/data.bin', absolutePath: filePath }, + ] + const withCrLf = computeSourceContentHash(root, entries).contentHash + + writeFileSync(filePath, Buffer.from([0xff, 0x0a])) + + expect(computeSourceContentHash(root, entries).contentHash).not.toBe( + withCrLf, + ) + }) + + it('is identical across different physical roots for identical relative paths and bytes', () => { + const rootA = createRoot() + const rootB = createRoot() + const pathA = writeFile(rootA, 'skills/a/SKILL.md', 'shared body') + const pathB = writeFile(rootB, 'skills/a/SKILL.md', 'shared body') + + const hashA = computeSourceContentHash(rootA, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: pathA }, + ]).contentHash + const hashB = computeSourceContentHash(rootB, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: pathB }, + ]).contentHash + + expect(hashA).toBe(hashB) + }) + + it('hashes a reference file in a skill folder', () => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + writeFile(root, 'skills/a/references/deep-dive.md', 'reference') + + const before = computeSourceContentHash(root, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: skillPath }, + ]).contentHash + + writeFileSync(join(root, 'skills/a/references/deep-dive.md'), 'changed') + + expect( + computeSourceContentHash(root, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: skillPath }, + ]).contentHash, + ).not.toBe(before) + }) + + it.each([ + ['references', 'deep-dive.md'], + ['assets', 'fixture.bin'], + ['scripts', 'run.mjs'], + ] as const)( + 'changes when a %s file is modified, added, removed, or renamed', + (directory, fileName) => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + const supportPath = `skills/a/${directory}/${fileName}` + const renamedPath = `skills/a/${directory}/renamed-${fileName}` + writeFile(root, supportPath, 'original') + const before = sourceHash(root, skillPath) + + writeFile(root, supportPath, 'modified') + expect(sourceHash(root, skillPath)).not.toBe(before) + + writeFile(root, supportPath, 'original') + writeFile(root, `skills/a/${directory}/added-${fileName}`, 'added') + expect(sourceHash(root, skillPath)).not.toBe(before) + + rmSync(join(root, `skills/a/${directory}/added-${fileName}`)) + rmSync(join(root, supportPath)) + expect(sourceHash(root, skillPath)).not.toBe(before) + + writeFile(root, supportPath, 'original') + renameSync(join(root, supportPath), join(root, renamedPath)) + expect(sourceHash(root, skillPath)).not.toBe(before) + }, + ) + + it('keeps binary supporting-file bytes exact', () => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + const assetPath = writeFile( + root, + 'skills/a/assets/data.bin', + Buffer.from([0x00, 0x0d, 0x0a, 0xff]), + ) + const before = sourceHash(root, skillPath) + + writeFileSync(assetPath, Buffer.from([0x00, 0x0a, 0xff])) + + expect(sourceHash(root, skillPath)).not.toBe(before) + }) + + it('rejects support directories beyond the recursion depth limit', () => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + let nestedDir = 'skills/a/references' + for (let index = 0; index <= HASH_LIMITS.maxRecursionDepth; index++) { + nestedDir = join(nestedDir, `level-${index}`) + } + writeFile(root, join(nestedDir, 'note.md'), 'content') + + expect(() => sourceHash(root, skillPath)).toThrow(/recursion depth limit/) + }) + + it('rejects support file sets beyond the file count limit', () => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + for (let index = 0; index < HASH_LIMITS.maxFileCount; index++) { + writeFile(root, `skills/a/assets/file-${index}.txt`, 'content') + } + + expect(() => sourceHash(root, skillPath)).toThrow(/file count limit/) + }) + + it('rejects support directory sets beyond the entry count limit', () => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + for (let index = 0; index <= HASH_LIMITS.maxEntryCount; index++) { + mkdirSync(join(root, `skills/a/assets/empty-${index}`), { + recursive: true, + }) + } + + expect(() => sourceHash(root, skillPath)).toThrow(/entry count limit/) + }) + + it('rejects files beyond the per-file size limit for source and manifest hashes', () => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + writeFile( + root, + 'skills/a/assets/large.bin', + Buffer.alloc(HASH_LIMITS.maxFileBytes + 1), + ) + + expect(() => sourceHash(root, skillPath)).toThrow(/file size limit/) + expect(() => computeSkillFolderHash(join(root, 'skills/a'), root)).toThrow( + /file size limit/, + ) + }) + + it('rejects content sets beyond the total size limit', () => { + const root = createRoot() + const skillPath = writeFile(root, 'skills/a/SKILL.md', 'body') + const fileSize = Math.floor(HASH_LIMITS.maxFileBytes * 0.75) + const fileCount = Math.ceil((HASH_LIMITS.maxTotalBytes + 1) / fileSize) + for (let index = 0; index < fileCount; index++) { + writeFile( + root, + `skills/a/assets/part-${index}.bin`, + Buffer.alloc(fileSize), + ) + } + + expect(() => sourceHash(root, skillPath)).toThrow(/total size limit/) + }) + + it('fails closed when a symlinked SKILL.md escapes the package root', () => { + const root = createRoot() + const outside = join( + root, + '..', + 'outside-' + Math.random().toString(36).slice(2), + ) + mkdirSync(outside, { recursive: true }) + writeFileSync(join(outside, 'secret.md'), 'leaked') + mkdirSync(join(root, 'skills/a'), { recursive: true }) + symlinkSync(join(outside, 'secret.md'), join(root, 'skills/a/SKILL.md')) + + expect(() => + computeSourceContentHash(root, [ + { + relativePath: 'skills/a/SKILL.md', + absolutePath: join(root, 'skills/a/SKILL.md'), + }, + ]), + ).toThrow(/escapes the package root/) + + rmSync(outside, { recursive: true, force: true }) + }) + + it('fails closed on a dangling symlink', () => { + const root = createRoot() + mkdirSync(join(root, 'skills/a'), { recursive: true }) + symlinkSync( + join(root, 'skills/a', 'missing-target.md'), + join(root, 'skills/a', 'SKILL.md'), + ) + + expect(() => + computeSourceContentHash(root, [ + { + relativePath: 'skills/a/SKILL.md', + absolutePath: join(root, 'skills/a/SKILL.md'), + }, + ]), + ).toThrow(/Failed to resolve skill file/) + }) + + it('follows an in-bounds symlink and hashes its target content', () => { + const root = createRoot() + writeFile(root, 'skills/a/canonical.md', 'shared content') + symlinkSync( + join(root, 'skills/a/canonical.md'), + join(root, 'skills/a/SKILL.md'), + ) + + const direct = computeSourceContentHash(root, [ + { + relativePath: 'skills/a/canonical.md', + absolutePath: join(root, 'skills/a/canonical.md'), + }, + ]) + const viaLink = computeSourceContentHash(root, [ + { + relativePath: 'skills/a/SKILL.md', + absolutePath: join(root, 'skills/a/SKILL.md'), + }, + ]) + + // Same bytes, different (path-included) identity — a rename/symlink + // through a different logical path is a real content-set change (§6.4). + expect(direct.contentHash).not.toBe(viaLink.contentHash) + }) +}) diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index 193cc64d..c1130fec 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, + readFileSync, readdirSync, realpathSync, rmSync, @@ -11,6 +12,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' +import { computeSourceContentHash } from '../../src/core/lockfile/hash.js' /** * Regression guard for discussion #119: skill discovery in a real Yarn Berry @@ -41,6 +43,8 @@ const realTmpdir = realpathSync(tmpdir()) // Never block on corepack's interactive download prompt in a non-TTY shell. const corepackEnv = { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' } +const skillContent = + '---\nname: core\ndescription: Core skill from the leaf package.\n---\n# Core\n' function berryAvailable(): boolean { try { @@ -73,7 +77,27 @@ function writeJson(path: string, data: unknown): void { writeFileSync(path, JSON.stringify(data, null, 2)) } -function scaffoldBerryProject(): string { +function writeSkillPackage(packageRoot: string): string { + const skillPath = join(packageRoot, 'skills', 'core', 'SKILL.md') + mkdirSync(dirname(skillPath), { recursive: true }) + writeFileSync(skillPath, skillContent) + const skillDir = dirname(skillPath) + mkdirSync(join(skillDir, 'references'), { recursive: true }) + writeFileSync(join(skillDir, 'references', 'guide.md'), '# Guide\n') + mkdirSync(join(skillDir, 'assets'), { recursive: true }) + writeFileSync(join(skillDir, 'assets', 'data.bin'), Buffer.from([0x00, 0xff])) + mkdirSync(join(skillDir, 'scripts'), { recursive: true }) + writeFileSync(join(skillDir, 'scripts', 'run.mjs'), 'export {}\n') + return skillPath +} + +function hashSkillPackage(packageRoot: string, skillPath: string): string { + return computeSourceContentHash(packageRoot, [ + { relativePath: 'skills/core/SKILL.md', absolutePath: skillPath }, + ]).contentHash +} + +function scaffoldBerryProject(): { root: string; packageSourceRoot: string } { const dir = mkdtempSync(join(realTmpdir, 'intent-berry-corepack-')) tempDirs.push(dir) @@ -87,10 +111,7 @@ function scaffoldBerryProject(): string { intent: { version: 1, repo: 'repro/leaf', docs: 'https://example.com' }, repository: { type: 'git', url: 'git+https://github.com/repro/leaf.git' }, }) - writeFileSync( - join(pkgSrc, 'skills', 'core', 'SKILL.md'), - '---\nname: core\ndescription: Core skill from the leaf package.\n---\n# Core\n', - ) + writeSkillPackage(pkgSrc) execFileSync('npm', ['pack', '--pack-destination', dir], { cwd: pkgSrc, timeout: CMD_TIMEOUT_MS, @@ -116,12 +137,12 @@ function scaffoldBerryProject(): string { timeout: CMD_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, }) - return dir + return { root: dir, packageSourceRoot: pkgSrc } } describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { - it('discovers and loads skills from a zip-backed dependency', () => { - const cwd = scaffoldBerryProject() + it('discovers, loads, and hashes skills from a zip-backed dependency', () => { + const { root: cwd, packageSourceRoot } = scaffoldBerryProject() const list = execFileSync('node', [cliPath, 'list', '--json'], { cwd, @@ -148,5 +169,55 @@ describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { }, ) expect(load).toContain('# Core') + + execFileSync('node', [cliPath, 'skills', 'approve', '--all', '--yes'], { + cwd, + encoding: 'utf8', + timeout: CMD_TIMEOUT_MS, + maxBuffer: 5 * 1024 * 1024, + }) + + const expectedHash = hashSkillPackage( + packageSourceRoot, + join(packageSourceRoot, 'skills', 'core', 'SKILL.md'), + ) + const lockfile = JSON.parse( + readFileSync(join(cwd, 'intent.lock'), 'utf8'), + ) as { sources: Array<{ id: string; contentHash: string }> } + const pnpHash = lockfile.sources.find( + (source) => source.id === '@repro/skills-leaf', + )?.contentHash + + const npmRoot = join( + cwd, + 'npm-layout', + 'node_modules', + '@repro', + 'skills-leaf', + ) + const pnpmRoot = join( + cwd, + 'pnpm-layout', + 'node_modules', + '.pnpm', + '@repro+skills-leaf@1.0.0', + 'node_modules', + '@repro', + 'skills-leaf', + ) + const workspaceRoot = join( + cwd, + 'workspace-layout', + 'packages', + 'skills-leaf', + ) + const layoutHashes = [ + hashSkillPackage(npmRoot, writeSkillPackage(npmRoot)), + hashSkillPackage(pnpmRoot, writeSkillPackage(pnpmRoot)), + hashSkillPackage(workspaceRoot, writeSkillPackage(workspaceRoot)), + ] + + expect(pnpHash).toBe(expectedHash) + expect(layoutHashes).toEqual([expectedHash, expectedHash, expectedHash]) }, 120_000) }) diff --git a/packages/intent/tests/integration/skills-frozen-cli.test.ts b/packages/intent/tests/integration/skills-frozen-cli.test.ts new file mode 100644 index 00000000..175bb78d --- /dev/null +++ b/packages/intent/tests/integration/skills-frozen-cli.test.ts @@ -0,0 +1,183 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { computeSkillFolderHash } from '../../src/core/lockfile/hash.js' + +const thisDir = dirname(fileURLToPath(import.meta.url)) +const cliPath = join(thisDir, '..', '..', 'dist', 'cli.mjs') +const roots: Array = [] + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) +} + +function writeSkillPackage( + root: string, + name: string, + content = 'Guidance.', +): string { + const packageRoot = join(root, 'node_modules', ...name.split('/')) + writeJson(join(packageRoot, 'package.json'), { + name, + version: '1.0.0', + intent: { version: 1, repo: `test/${name}`, docs: 'docs/' }, + }) + mkdirSync(join(packageRoot, 'skills', 'core'), { recursive: true }) + writeFileSync( + join(packageRoot, 'skills', 'core', 'SKILL.md'), + `---\nname: core\ndescription: ${name} skill\n---\n\n${content}\n`, + ) + return packageRoot +} + +function writeManifest( + root: string, + name: string, + capabilities: Array<'uses_network'> = [], +): void { + const packageRoot = join(root, 'node_modules', ...name.split('/')) + const skillDir = join(packageRoot, 'skills', 'core') + writeJson(join(packageRoot, 'skills', 'intent.manifest.json'), { + manifestVersion: 1, + package: name, + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: computeSkillFolderHash(skillDir, packageRoot), + capabilities, + declaredSecrets: [], + mcpTools: [], + }, + ], + }) +} + +function writeProject(root: string, skills: Array): void { + writeJson(join(root, 'package.json'), { + name: 'frozen-cli-fixture', + private: true, + intent: { skills }, + }) +} + +function createProject(skills: Array = ['foo']): string { + const root = mkdtempSync(join(tmpdir(), 'intent-frozen-cli-')) + roots.push(root) + writeProject(root, skills) + writeSkillPackage(root, 'foo') + return root +} + +function runCommand(root: string, args: Array): number | null { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, CI: '', INTENT_FROZEN: '' }, + timeout: 30_000, + }).status +} + +function runCli(root: string, args: Array): number | null { + return runCommand(root, ['skills', ...args]) +} + +function approveInitialLock(root: string): void { + expect(runCli(root, ['approve', '--all', '--yes'])).toBe(0) +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('built CLI frozen mode', () => { + it('fails when intent.lock is missing', () => { + expect(runCli(createProject(), ['scan', '--frozen'])).toBe(4) + }) + + it('fails when intent.lock is missing with --frozen=true', () => { + expect(runCli(createProject(), ['scan', '--frozen=true'])).toBe(4) + }) + + it('fails when intent.lock is malformed', () => { + const root = createProject() + writeFileSync(join(root, 'intent.lock'), '{"lockfileVersion":2}\n') + + expect(runCli(root, ['scan', '--frozen'])).toBe(6) + }) + + it('fails when a discovered source is unlisted', () => { + const root = createProject() + writeSkillPackage(root, 'unlisted') + approveInitialLock(root) + + expect(runCli(root, ['scan', '--frozen'])).toBe(3) + }) + + it('fails when a discovered source is denied by an empty allowlist', () => { + const root = createProject([]) + writeJson(join(root, 'intent.lock'), { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: [], + policy: { ignores: [] }, + }) + + expect(runCli(root, ['scan', '--frozen'])).toBe(3) + }) + + it('fails when an allowlisted source is added', () => { + const root = createProject() + approveInitialLock(root) + writeProject(root, ['foo', 'bar']) + writeSkillPackage(root, 'bar') + + expect(runCli(root, ['scan', '--frozen'])).toBe(2) + }) + + it('fails when a locked source is removed', () => { + const root = createProject() + approveInitialLock(root) + rmSync(join(root, 'node_modules', 'foo'), { recursive: true, force: true }) + + expect(runCli(root, ['scan', '--frozen'])).toBe(2) + }) + + it('fails when a locked source content hash changes', () => { + const root = createProject() + approveInitialLock(root) + writeFileSync( + join(root, 'node_modules', 'foo', 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: foo skill\n---\n\nChanged guidance.\n', + ) + + expect(runCli(root, ['scan', '--frozen'])).toBe(2) + }) + + it('fails when a locked source manifest metadata changes', () => { + const root = createProject() + writeManifest(root, 'foo') + approveInitialLock(root) + writeManifest(root, 'foo', ['uses_network']) + + expect(runCli(root, ['scan', '--frozen'])).toBe(2) + }) + + it('refuses to load changed approved content in frozen mode', () => { + const root = createProject() + approveInitialLock(root) + writeFileSync( + join(root, 'node_modules', 'foo', 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: foo skill\n---\n\nChanged guidance.\n', + ) + + expect(runCommand(root, ['load', 'foo#core', '--frozen'])).toBe(1) + }) +}) diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 62417361..be381d6e 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -10,6 +10,9 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' import { main } from '../../src/cli.js' +import { readIntentLockfile } from '../../src/core/lockfile/lockfile.js' +import { scanForPolicedIntents } from '../../src/core/source-policy.js' +import { scanForIntents } from '../../src/discovery/scanner.js' const realTmpdir = realpathSync(tmpdir()) @@ -36,6 +39,30 @@ function writeIntentPackage( ) } +function writeWorkspaceIntentPackage( + baseDir: string, + name: string, + skillName: string, +): void { + const pkgDir = join(baseDir, 'packages', name) + 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`, + ) +} + +function sourceKeys( + packages: Array<{ kind: 'npm' | 'workspace'; name: string }>, +): Array { + return packages.map((pkg) => `${pkg.kind}:${pkg.name}`).sort() +} + const LISTED = '@scope/listed' const UNLISTED = '@scope/unlisted' const EXCLUDED = '@scope/excluded' @@ -73,9 +100,10 @@ describe('source policy — all four surfaces filter excluded and unlisted', () it('list surfaces only the listed package', () => { writeStandaloneFixture() - const result = listIntentSkills({ cwd: root }) + const result = listIntentSkills({ cwd: root, audience: 'human' }) expect(result.packages.map((pkg) => pkg.name)).toEqual([LISTED]) + expect(result.hiddenSourceCount).toBe(1) expect(result.notices.some((notice) => notice.includes(UNLISTED))).toBe( true, ) @@ -150,3 +178,127 @@ describe('source policy — all four surfaces filter excluded and unlisted', () fetchSpy.mockRestore() }) }) + +describe('source identity lifecycle', () => { + let root: string + let originalCwd: string + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + originalCwd = process.cwd() + root = mkdtempSync(join(realTmpdir, 'intent-source-identity-')) + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + process.chdir(originalCwd) + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) + }) + + function writeRootConfig(intent: Record): void { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + workspaces: ['packages/*'], + intent, + }) + } + + it('rejects an ambiguous load when npm and workspace sources share a skill', () => { + writeRootConfig({ skills: ['foo', 'workspace:foo'] }) + writeWorkspaceIntentPackage(root, 'foo', 'core') + writeIntentPackage(root, 'foo', 'core') + + expect(() => loadIntentSkill('foo#core', { cwd: root })).toThrow( + 'Cannot resolve skill use "foo#core": package "foo" is ambiguous between npm:foo and workspace:foo.', + ) + }) + + it('preserves same-name sources through policy, locking, diffing, and frozen checks', async () => { + writeRootConfig({ skills: ['workspace:foo'] }) + writeWorkspaceIntentPackage(root, 'foo', 'workspace') + writeIntentPackage(root, 'foo', 'npm') + + expect(sourceKeys(scanForIntents(root).packages)).toEqual([ + 'npm:foo', + 'workspace:foo', + ]) + + let policed = scanForPolicedIntents({ + cwd: root, + scanOptions: {}, + coreOptions: { cwd: root }, + }) + expect(sourceKeys(policed.scan.packages)).toEqual(['workspace:foo']) + expect(policed.hiddenSourceCount).toBe(1) + + writeRootConfig({ + skills: ['foo', 'workspace:foo'], + exclude: ['foo#workspace'], + }) + policed = scanForPolicedIntents({ + cwd: root, + scanOptions: {}, + coreOptions: { cwd: root }, + }) + expect(sourceKeys(policed.scan.packages)).toEqual([ + 'npm:foo', + 'workspace:foo', + ]) + expect( + policed.scan.packages.find((pkg) => pkg.kind === 'npm')?.skills, + ).toHaveLength(1) + expect( + policed.scan.packages.find((pkg) => pkg.kind === 'workspace')?.skills, + ).toEqual([]) + + writeRootConfig({ skills: ['foo', 'workspace:foo'] }) + process.chdir(root) + const approveExitCode = await main(['skills', 'approve', '--all']) + expect(errorSpy).not.toHaveBeenCalled() + expect(approveExitCode).toBe(0) + + const locked = readIntentLockfile(join(root, 'intent.lock')) + expect(locked.status).toBe('found') + if (locked.status !== 'found') return + expect( + locked.lockfile.sources + .map((source) => `${source.kind}:${source.id}`) + .sort(), + ).toEqual(['npm:foo', 'workspace:foo']) + + writeFileSync( + join(root, 'packages', 'foo', 'skills', 'workspace', 'SKILL.md'), + 'workspace drift', + ) + logSpy.mockClear() + expect(await main(['skills', 'diff', '--json'])).toBe(0) + const diff = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0])) as { + changed: Array<{ kind: string; id: string }> + } + expect( + diff.changed.map((change) => ({ kind: change.kind, id: change.id })), + ).toEqual([{ kind: 'workspace', id: 'foo' }]) + + errorSpy.mockClear() + expect(await main(['skills', 'approve', 'foo', '--yes'])).not.toBe(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Ambiguous source "foo"'), + ) + + expect(await main(['skills', 'approve', 'workspace:foo', '--yes'])).toBe(0) + + writeFileSync( + join(root, 'node_modules', 'foo', 'skills', 'npm', 'SKILL.md'), + 'npm drift', + ) + errorSpy.mockClear() + expect(await main(['skills', 'diff', '--frozen'])).toBe(2) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('intent.lock is out of date'), + ) + }) +}) diff --git a/packages/intent/tests/lockfile-diff.test.ts b/packages/intent/tests/lockfile-diff.test.ts new file mode 100644 index 00000000..ccceeeb0 --- /dev/null +++ b/packages/intent/tests/lockfile-diff.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it } from 'vitest' +import { diffLockfileSources } from '../src/core/lockfile/lockfile-diff.js' +import { computeManifestHash, parseManifest } from '../src/core/manifest.js' +import type { + IntentLockfile, + IntentLockfileSource, +} from '../src/core/lockfile/lockfile.js' + +function createSource( + overrides: Partial & + Pick, +): IntentLockfileSource { + return { + version: '1.0.0', + resolution: null, + skills: [], + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: null, + ...overrides, + } +} + +function createLockfile(sources: Array): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '1.0.0', + sources, + policy: { ignores: [] }, + } +} + +describe('diffLockfileSources', () => { + it('reports no lockfile as not clean with nothing itemized', () => { + const result = diffLockfileSources([], { status: 'missing' }) + + expect(result).toEqual({ + hasLockfile: false, + added: [], + removed: [], + changed: [], + isClean: false, + }) + }) + + it('reports clean when current matches the lockfile exactly', () => { + const source = createSource({ id: '@tanstack/router', kind: 'npm' }) + + const result = diffLockfileSources([source], { + status: 'found', + lockfile: createLockfile([source]), + }) + + expect(result.isClean).toBe(true) + expect(result.added).toEqual([]) + expect(result.removed).toEqual([]) + expect(result.changed).toEqual([]) + }) + + it('reports a new source as added', () => { + const current = createSource({ id: '@tanstack/router', kind: 'npm' }) + + const result = diffLockfileSources([current], { + status: 'found', + lockfile: createLockfile([]), + }) + + expect(result.isClean).toBe(false) + expect(result.added).toEqual([current]) + expect(result.removed).toEqual([]) + }) + + it('reports a missing source as removed', () => { + const locked = createSource({ id: '@tanstack/router', kind: 'npm' }) + + const result = diffLockfileSources([], { + status: 'found', + lockfile: createLockfile([locked]), + }) + + expect(result.isClean).toBe(false) + expect(result.removed).toEqual([locked]) + expect(result.added).toEqual([]) + }) + + it('reports a version change', () => { + const locked = createSource({ + id: '@tanstack/router', + kind: 'npm', + version: '1.0.0', + }) + const current = createSource({ + id: '@tanstack/router', + kind: 'npm', + version: '1.1.0', + }) + + const result = diffLockfileSources([current], { + status: 'found', + lockfile: createLockfile([locked]), + }) + + expect(result.isClean).toBe(false) + expect(result.changed).toEqual([ + { + id: '@tanstack/router', + kind: 'npm', + fields: [{ field: 'version', from: '1.0.0', to: '1.1.0' }], + }, + ]) + }) + + it('reports a contentHash change', () => { + const locked = createSource({ + id: 'router', + kind: 'workspace', + contentHash: 'sha256-aaa', + }) + const current = createSource({ + id: 'router', + kind: 'workspace', + contentHash: 'sha256-bbb', + }) + + const result = diffLockfileSources([current], { + status: 'found', + lockfile: createLockfile([locked]), + }) + + expect(result.changed).toEqual([ + { + id: 'router', + kind: 'workspace', + fields: [ + { field: 'contentHash', from: 'sha256-aaa', to: 'sha256-bbb' }, + ], + }, + ]) + }) + + it.each([ + [ + 'declared secrets', + { + declaredSecrets: ['API_TOKEN'], + mcpTools: [], + }, + ], + [ + 'an MCP tool name', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch' }], + }, + ], + [ + 'an MCP tool description', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch', description: 'Fetch a resource.' }], + }, + ], + [ + 'an MCP tool schema', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch', inputSchema: { type: 'object' } }], + }, + ], + ])('reports manifestHash drift when %s changes', (_, disclosure) => { + const baseManifest = parseManifest({ + manifestVersion: 1, + package: 'foo', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + }, + ], + }) + const changedManifest = structuredClone(baseManifest) + Object.assign(changedManifest.skills[0]!, disclosure) + const locked = createSource({ + id: 'foo', + kind: 'npm', + manifestHash: computeManifestHash(baseManifest), + }) + const current = createSource({ + id: 'foo', + kind: 'npm', + manifestHash: computeManifestHash(changedManifest), + }) + + const result = diffLockfileSources([current], { + status: 'found', + lockfile: createLockfile([locked]), + }) + + expect(result.changed).toEqual([ + { + id: 'foo', + kind: 'npm', + fields: [ + { + field: 'manifestHash', + from: locked.manifestHash, + to: current.manifestHash, + }, + ], + }, + ]) + }) + + it('does not confuse a workspace source with an npm source of the same name', () => { + const lockedNpm = createSource({ id: 'foo', kind: 'npm' }) + const currentWorkspace = createSource({ id: 'foo', kind: 'workspace' }) + + const result = diffLockfileSources([currentWorkspace], { + status: 'found', + lockfile: createLockfile([lockedNpm]), + }) + + expect(result.added).toEqual([currentWorkspace]) + expect(result.removed).toEqual([lockedNpm]) + expect(result.changed).toEqual([]) + }) + + it('is unaffected by array order differences in capabilities', () => { + const locked = createSource({ + id: 'foo', + kind: 'npm', + capabilities: ['write', 'read'], + }) + const current = createSource({ + id: 'foo', + kind: 'npm', + capabilities: ['read', 'write'], + }) + + const result = diffLockfileSources([current], { + status: 'found', + lockfile: createLockfile([locked]), + }) + + expect(result.isClean).toBe(true) + }) + + it('sorts added/removed by (kind, id)', () => { + const lockedA = createSource({ id: 'b-pkg', kind: 'npm' }) + const currentX = createSource({ id: 'a-pkg', kind: 'npm' }) + const currentY = createSource({ id: 'c-pkg', kind: 'npm' }) + + const result = diffLockfileSources([currentX, currentY], { + status: 'found', + lockfile: createLockfile([lockedA]), + }) + + expect(result.added.map((source) => source.id)).toEqual(['a-pkg', 'c-pkg']) + expect(result.removed.map((source) => source.id)).toEqual(['b-pkg']) + }) + + it('reports multiple changed fields on the same source in one entry', () => { + const locked = createSource({ + id: '@tanstack/router', + kind: 'npm', + version: '1.0.0', + contentHash: 'sha256-aaa', + }) + const current = createSource({ + id: '@tanstack/router', + kind: 'npm', + version: '1.1.0', + contentHash: 'sha256-bbb', + }) + + const result = diffLockfileSources([current], { + status: 'found', + lockfile: createLockfile([locked]), + }) + + expect(result.changed).toEqual([ + { + id: '@tanstack/router', + kind: 'npm', + fields: [ + { field: 'version', from: '1.0.0', to: '1.1.0' }, + { field: 'contentHash', from: 'sha256-aaa', to: 'sha256-bbb' }, + ], + }, + ]) + }) + + it('sorts multiple changed sources by (kind, id)', () => { + const lockedA = createSource({ + id: 'b-pkg', + kind: 'npm', + version: '1.0.0', + }) + const lockedB = createSource({ + id: 'a-pkg', + kind: 'npm', + version: '1.0.0', + }) + const currentA = createSource({ + id: 'b-pkg', + kind: 'npm', + version: '2.0.0', + }) + const currentB = createSource({ + id: 'a-pkg', + kind: 'npm', + version: '2.0.0', + }) + + const result = diffLockfileSources([currentA, currentB], { + status: 'found', + lockfile: createLockfile([lockedA, lockedB]), + }) + + expect(result.changed.map((change) => change.id)).toEqual([ + 'a-pkg', + 'b-pkg', + ]) + }) + + it('canonicalizes added sources so array order does not leak through', () => { + const current = createSource({ + id: '@tanstack/router', + kind: 'npm', + capabilities: ['write', 'read'], + }) + + const result = diffLockfileSources([current], { + status: 'found', + lockfile: createLockfile([]), + }) + + expect(result.added).toEqual([ + { ...current, capabilities: ['read', 'write'] }, + ]) + }) +}) diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts new file mode 100644 index 00000000..32b8cf07 --- /dev/null +++ b/packages/intent/tests/lockfile-state.test.ts @@ -0,0 +1,532 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { computeLockfileState } from '../src/commands/skills/support.js' +import { computeSkillFolderHash } from '../src/core/lockfile/hash.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { writeIntentManifest } from '../src/core/manifest.js' +import { nodeReadFs } from '../src/shared/utils.js' +import type { + IntentManifest, + IntentManifestCapability, +} from '../src/core/manifest.js' +import type { IntentPackage, ScanResult } from '../src/shared/types.js' +import type { ReadFs } from '../src/shared/utils.js' + +const roots: Array = [] + +function createRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'intent-lockfile-state-test-')) + roots.push(root) + return root +} + +function writeSkill( + packageRoot: string, + skillName: string, + content: string, +): string { + const skillDir = join(packageRoot, 'skills', skillName) + mkdirSync(skillDir, { recursive: true }) + const skillPath = join(skillDir, 'SKILL.md') + writeFileSync(skillPath, content) + return skillPath +} + +function createPackage( + overrides: Partial & + Pick, +): IntentPackage { + return { + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/test', docs: 'docs/' }, + source: 'local', + ...overrides, + } +} + +function createManifest( + pkg: IntentPackage, + capabilities: Array = [], +): IntentManifest { + return { + manifestVersion: 1, + package: pkg.name, + packageVersion: pkg.version, + skills: pkg.skills.map((skill) => ({ + name: skill.name, + path: relative(pkg.packageRoot, skill.path).split('\\').join('/'), + contentHash: computeSkillFolderHash(dirname(skill.path), pkg.packageRoot), + capabilities, + declaredSecrets: [], + mcpTools: [], + })), + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('buildCurrentLockfileSources', () => { + it('builds one entry per package with npm resolution', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'fetching', 'body') + const pkg = createPackage({ + name: '@tanstack/query', + kind: 'npm', + packageRoot: root, + version: '5.0.0', + skills: [{ name: 'fetching', path: skillPath, description: 'desc' }], + }) + + const [entry] = buildCurrentLockfileSources([pkg]) + + expect(entry).toMatchObject({ + id: '@tanstack/query', + kind: 'npm', + version: '5.0.0', + resolution: 'npm:@tanstack/query@5.0.0', + manifestHash: null, + capabilities: null, + skills: ['skills/fetching/SKILL.md'], + }) + expect(entry!.declaredSecrets).toBeUndefined() + expect(entry!.mcpTools).toBeUndefined() + expect(entry!.mcpPolicy).toBeUndefined() + expect(entry!.contentHash).toMatch(/^sha256-[0-9a-f]{64}$/) + }) + + it('populates manifestHash and capabilities when the package ships a manifest', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'net', 'Run `curl https://example.com`.') + const pkg = createPackage({ + name: '@acme/pkg', + kind: 'npm', + packageRoot: root, + skills: [{ name: 'net', path: skillPath, description: 'desc' }], + }) + + writeIntentManifest( + join(root, 'skills', 'intent.manifest.json'), + createManifest(pkg, ['uses_network']), + ) + + const [entry] = buildCurrentLockfileSources([pkg]) + + expect(entry!.manifestHash).toMatch(/^sha256-[0-9a-f]{64}$/) + expect(entry!.capabilities).toEqual(['uses_network']) + }) + + it('uses an empty capabilities array when a manifest declares none', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'plain guidance') + const pkg = createPackage({ + name: '@acme/pkg', + kind: 'npm', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + writeIntentManifest( + join(root, 'skills', 'intent.manifest.json'), + createManifest(pkg), + ) + + const [entry] = buildCurrentLockfileSources([pkg]) + + expect(entry!.manifestHash).toMatch(/^sha256-[0-9a-f]{64}$/) + expect(entry!.capabilities).toEqual([]) + }) + + it('fails when an existing manifest is malformed', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'body') + const pkg = createPackage({ + name: '@acme/pkg', + kind: 'npm', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + writeFileSync(join(root, 'skills', 'intent.manifest.json'), '{not json') + + expect(() => buildCurrentLockfileSources([pkg])).toThrow( + /Invalid intent.manifest.json/, + ) + }) + + it.each([ + ['package name', { package: '@acme/other' }], + ['package version', { packageVersion: '2.0.0' }], + ['skill set', { skills: [] }], + [ + 'skill content hash', + { + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-stale', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + }, + ], + }, + ], + ])('fails when a manifest has a mismatched %s', (_, override) => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'body') + const pkg = createPackage({ + name: '@acme/pkg', + kind: 'npm', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + const manifest = createManifest(pkg) + writeFileSync( + join(root, 'skills', 'intent.manifest.json'), + JSON.stringify({ ...manifest, ...override }), + ) + + expect(() => buildCurrentLockfileSources([pkg])).toThrow(/does not match/) + }) + + it.each([ + [ + 'declared secrets', + { + declaredSecrets: ['API_TOKEN'], + mcpTools: [], + }, + ], + [ + 'an MCP tool name', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch' }], + }, + ], + [ + 'an MCP tool description', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch', description: 'Fetch a resource.' }], + }, + ], + [ + 'an MCP tool schema', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch', inputSchema: { type: 'object' } }], + }, + ], + ])('changes manifestHash when %s changes', (_, disclosure) => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'body') + const pkg = createPackage({ + name: '@acme/pkg', + kind: 'npm', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + const manifestPath = join(root, 'skills', 'intent.manifest.json') + const baseManifest = createManifest(pkg) + writeFileSync(manifestPath, JSON.stringify(baseManifest)) + const before = buildCurrentLockfileSources([pkg])[0]!.manifestHash + + const changedManifest = structuredClone(baseManifest) + Object.assign(changedManifest.skills[0]!, disclosure) + writeFileSync(manifestPath, JSON.stringify(changedManifest)) + + expect(buildCurrentLockfileSources([pkg])[0]!.manifestHash).not.toBe(before) + }) + + it('does not set a resolution for workspace packages', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'body') + const pkg = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + + const [entry] = buildCurrentLockfileSources([pkg]) + + expect(entry!.resolution).toBeNull() + }) + + it('changes contentHash when a skill file changes', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'version 1') + const pkg = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + + const before = buildCurrentLockfileSources([pkg])[0]!.contentHash + + writeFileSync(skillPath, 'version 2') + + const after = buildCurrentLockfileSources([pkg])[0]!.contentHash + + expect(after).not.toBe(before) + }) + + it('reads source bytes through the scanner filesystem', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'native bytes') + const pkg = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + const realSkillPath = nodeReadFs.realpathSync(skillPath) + const readFs: ReadFs = { + ...nodeReadFs, + readFileSync: ((path: string | Buffer | URL | number) => { + if (String(path) === realSkillPath) { + return Buffer.from('patched zip bytes') + } + return nodeReadFs.readFileSync(path) + }) as typeof nodeReadFs.readFileSync, + } + + const nativeHash = buildCurrentLockfileSources([pkg])[0]!.contentHash + const scan: ScanResult = { + packageManager: 'yarn', + packages: [pkg], + warnings: [], + notices: [], + conflicts: [], + nodeModules: { + local: { path: null, detected: false, exists: false, scanned: false }, + global: { path: null, detected: false, exists: false, scanned: false }, + }, + stats: { packageJsonReadCount: 0, packageJsonCacheHits: 0 }, + readFs, + } + const patchedHash = computeLockfileState(scan, root).current[0]!.contentHash + + expect(patchedHash).not.toBe(nativeHash) + }) + + it('produces a stable hash for an unchanged package', () => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'body') + const pkg = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + + const a = buildCurrentLockfileSources([pkg])[0]!.contentHash + const b = buildCurrentLockfileSources([pkg])[0]!.contentHash + + expect(a).toBe(b) + }) + + it('produces an identical hash across different physical package roots', () => { + const rootA = createRoot() + const rootB = createRoot() + const skillA = writeSkill(rootA, 'core', 'shared body') + const skillB = writeSkill(rootB, 'core', 'shared body') + const pkgA = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: rootA, + skills: [{ name: 'core', path: skillA, description: 'desc' }], + }) + const pkgB = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: rootB, + skills: [{ name: 'core', path: skillB, description: 'desc' }], + }) + + const hashA = buildCurrentLockfileSources([pkgA])[0]!.contentHash + const hashB = buildCurrentLockfileSources([pkgB])[0]!.contentHash + + expect(hashA).toBe(hashB) + }) + + it('changes the aggregate hash when one of several skills changes, without needing the others to change', () => { + const root = createRoot() + const skillOne = writeSkill(root, 'one', 'body one') + const skillTwo = writeSkill(root, 'two', 'body two') + const pkg = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [ + { name: 'one', path: skillOne, description: 'desc' }, + { name: 'two', path: skillTwo, description: 'desc' }, + ], + }) + + const before = buildCurrentLockfileSources([pkg])[0]!.contentHash + + writeFileSync(skillOne, 'body one changed') + + const after = buildCurrentLockfileSources([pkg])[0]!.contentHash + + expect(after).not.toBe(before) + }) + + it('is unaffected by the order of the skills array', () => { + const root = createRoot() + const skillOne = writeSkill(root, 'one', 'body one') + const skillTwo = writeSkill(root, 'two', 'body two') + const pkg = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [ + { name: 'one', path: skillOne, description: 'desc' }, + { name: 'two', path: skillTwo, description: 'desc' }, + ], + }) + const reordered = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [ + { name: 'two', path: skillTwo, description: 'desc' }, + { name: 'one', path: skillOne, description: 'desc' }, + ], + }) + + const hashA = buildCurrentLockfileSources([pkg])[0]!.contentHash + const hashB = buildCurrentLockfileSources([reordered])[0]!.contentHash + + expect(hashA).toBe(hashB) + }) + + it('gives each nested skill its own independent content hash (no folder-scope bleed)', () => { + const root = createRoot() + const parentDir = join(root, 'skills', 'parent') + const nestedDir = join(parentDir, 'nested') + const parentSkill = writeSkill(root, 'parent', 'parent body') + mkdirSync(nestedDir, { recursive: true }) + const nestedSkill = join(nestedDir, 'SKILL.md') + writeFileSync(nestedSkill, 'nested body') + const pkg = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: root, + skills: [ + { name: 'parent', path: parentSkill, description: 'desc' }, + { name: 'nested', path: nestedSkill, description: 'desc' }, + ], + }) + + const before = buildCurrentLockfileSources([pkg])[0]!.contentHash + + // Only the parent's own SKILL.md bytes changed — the nested skill's + // separate SKILL.md path is unaffected, so the aggregate still moves + // (it's part of the same source), but changing the nested file alone + // (not the parent) proves each path is hashed independently. + writeFileSync(nestedSkill, 'nested body changed') + const nestedChanged = buildCurrentLockfileSources([pkg])[0]!.contentHash + expect(nestedChanged).not.toBe(before) + + writeFileSync(nestedSkill, 'nested body') + writeFileSync(parentSkill, 'parent body changed') + const parentChanged = buildCurrentLockfileSources([pkg])[0]!.contentHash + expect(parentChanged).not.toBe(before) + expect(parentChanged).not.toBe(nestedChanged) + }) + + it('throws on a duplicate (kind, id) identity', () => { + const rootA = createRoot() + const rootB = createRoot() + const skillA = writeSkill(rootA, 'a', 'a') + const skillB = writeSkill(rootB, 'b', 'b') + const first = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: rootA, + skills: [{ name: 'a', path: skillA, description: 'desc' }], + }) + const duplicate = createPackage({ + name: 'router', + kind: 'workspace', + packageRoot: rootB, + skills: [{ name: 'b', path: skillB, description: 'desc' }], + }) + + expect(() => buildCurrentLockfileSources([first, duplicate])).toThrow( + /Duplicate skill source identity/, + ) + }) + + it('handles a package with no skills without crashing', () => { + const root = createRoot() + const pkg = createPackage({ + name: 'empty-pkg', + kind: 'npm', + packageRoot: root, + skills: [], + }) + + const [entry] = buildCurrentLockfileSources([pkg]) + + expect(entry!.contentHash).toMatch(/^sha256-[0-9a-f]{64}$/) + }) + + it('sorts entries by kind before id', () => { + const rootA = createRoot() + const rootB = createRoot() + const skillA = writeSkill(rootA, 'a', 'a') + const skillB = writeSkill(rootB, 'b', 'b') + const npmPkg = createPackage({ + name: 'zzz', + kind: 'npm', + packageRoot: rootA, + skills: [{ name: 'a', path: skillA, description: 'desc' }], + }) + const workspacePkg = createPackage({ + name: 'aaa', + kind: 'workspace', + packageRoot: rootB, + skills: [{ name: 'b', path: skillB, description: 'desc' }], + }) + + const entries = buildCurrentLockfileSources([npmPkg, workspacePkg]) + + expect(entries.map((entry) => `${entry.kind}:${entry.id}`)).toEqual([ + 'npm:zzz', + 'workspace:aaa', + ]) + }) + + it('sorts entries alphabetically by id within the same kind', () => { + const rootA = createRoot() + const rootB = createRoot() + const skillA = writeSkill(rootA, 'a', 'a') + const skillB = writeSkill(rootB, 'b', 'b') + const banana = createPackage({ + name: 'banana', + kind: 'npm', + packageRoot: rootA, + skills: [{ name: 'a', path: skillA, description: 'desc' }], + }) + const apple = createPackage({ + name: 'apple', + kind: 'npm', + packageRoot: rootB, + skills: [{ name: 'b', path: skillB, description: 'desc' }], + }) + + const entries = buildCurrentLockfileSources([banana, apple]) + + expect(entries.map((entry) => entry.id)).toEqual(['apple', 'banana']) + }) +}) diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts new file mode 100644 index 00000000..57bf9459 --- /dev/null +++ b/packages/intent/tests/lockfile.test.ts @@ -0,0 +1,331 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + parseIntentLockfile, + readIntentLockfile, + serializeIntentLockfile, + writeIntentLockfile, +} from '../src/core/lockfile/lockfile.js' +import type { IntentLockfile } from '../src/core/lockfile/lockfile.js' + +const roots: Array = [] + +function createRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'intent-lockfile-test-')) + roots.push(root) + return root +} + +function createLockfile(): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '1.0.0', + staleness: { + baseline: { + kind: 'tag', + ref: 'v1.42.0', + commit: 'abc123', + }, + }, + sources: [ + { + id: 'router', + kind: 'workspace', + version: '1.42.0', + resolution: null, + skills: [], + manifestHash: null, + contentHash: 'sha256-workspace-router', + capabilities: null, + }, + { + id: '@tanstack/router', + kind: 'npm', + version: '1.42.0', + resolution: 'npm:@tanstack/router@1.42.0', + skills: [], + manifestHash: null, + contentHash: 'sha256-npm-router', + capabilities: null, + }, + ], + policy: { + ignores: [], + }, + } +} + +function createCanonicalLockfile(): IntentLockfile { + return { + ...createLockfile(), + sources: [...createLockfile().sources].sort((a, b) => + `${a.kind}\u0000${a.id}` < `${b.kind}\u0000${b.id}` ? -1 : 1, + ), + } +} + +function createUnsortedSemanticEquivalentLockfile(): IntentLockfile { + return { + ...createLockfile(), + sources: [ + { + ...createLockfile().sources[0]!, + capabilities: ['write', 'read'], + declaredSecrets: ['TOKEN', 'API_KEY'], + mcpTools: ['tool-b', 'tool-a'], + mcpPolicy: { + zebra: { nested: { beta: true, alpha: true } }, + alpha: ['b', { z: 1, a: 2 }], + }, + }, + createLockfile().sources[1]!, + ], + policy: { + ignores: [ + { + id: 'z-ignore', + scope: { source: 'router', contentHash: 'sha256-z' }, + reason: 'z reason', + createdAt: '2026-05-27T00:00:00Z', + expiresAt: '2026-08-27', + }, + { + id: 'a-ignore', + scope: { source: '@tanstack/router', contentHash: 'sha256-a' }, + reason: 'a reason', + createdAt: '2026-05-26T00:00:00Z', + expiresAt: '2026-08-26', + }, + ], + }, + } +} + +function createSortedSemanticEquivalentLockfile(): IntentLockfile { + return { + ...createLockfile(), + sources: [ + createLockfile().sources[1]!, + { + ...createLockfile().sources[0]!, + capabilities: ['read', 'write'], + declaredSecrets: ['API_KEY', 'TOKEN'], + mcpTools: ['tool-a', 'tool-b'], + mcpPolicy: { + alpha: ['b', { a: 2, z: 1 }], + zebra: { nested: { alpha: true, beta: true } }, + }, + }, + ], + policy: { + ignores: [ + { + id: 'a-ignore', + scope: { source: '@tanstack/router', contentHash: 'sha256-a' }, + reason: 'a reason', + createdAt: '2026-05-26T00:00:00Z', + expiresAt: '2026-08-26', + }, + { + id: 'z-ignore', + scope: { source: 'router', contentHash: 'sha256-z' }, + reason: 'z reason', + createdAt: '2026-05-27T00:00:00Z', + expiresAt: '2026-08-27', + }, + ], + }, + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('serializeIntentLockfile', () => { + it('serializes sources in stable identity order', () => { + expect(serializeIntentLockfile(createLockfile())).toMatch( + /"id": "@tanstack\/router"[\s\S]+"id": "router"/, + ) + }) + + it('omits generation timestamps', () => { + expect(serializeIntentLockfile(createLockfile())).not.toMatch( + /generated(?:At|On)/, + ) + }) + + it('serializes byte-identically for the same semantic input', () => { + expect( + serializeIntentLockfile(createUnsortedSemanticEquivalentLockfile()), + ).toBe(serializeIntentLockfile(createSortedSemanticEquivalentLockfile())) + }) +}) + +describe('parseIntentLockfile', () => { + it('parses a serialized lockfile', () => { + expect( + parseIntentLockfile(serializeIntentLockfile(createLockfile())), + ).toEqual(createCanonicalLockfile()) + }) + + it('preserves null and empty capabilities as distinct states', () => { + const lockfile = createLockfile() + lockfile.sources[0]!.capabilities = [] + lockfile.sources[1]!.capabilities = null + + const parsed = parseIntentLockfile(JSON.stringify(lockfile)) + + expect( + parsed.sources.find((source) => source.id === 'router')?.capabilities, + ).toEqual([]) + expect( + parsed.sources.find((source) => source.id === '@tanstack/router') + ?.capabilities, + ).toBeNull() + }) + + it('rejects an unsupported lockfile version', () => { + expect(() => + parseIntentLockfile( + JSON.stringify({ ...createLockfile(), lockfileVersion: 2 }), + ), + ).toThrow('Unsupported intent.lock version: 2') + }) + + it.each([ + '', + '/absolute/SKILL.md', + 'skills\\core\\SKILL.md', + 'skills/core/\0SKILL.md', + 'skills//core/SKILL.md', + './skills/core/SKILL.md', + 'skills/../core/SKILL.md', + ])('rejects an unsafe source skill path: %j', (skillPath) => { + const lockfile = createLockfile() + lockfile.sources[0]!.skills = [skillPath] + + expect(() => parseIntentLockfile(JSON.stringify(lockfile))).toThrow( + /Invalid source.skills path/, + ) + }) + + it('rejects duplicate source skill paths', () => { + const lockfile = createLockfile() + lockfile.sources[0]!.skills = [ + 'skills/core/SKILL.md', + 'skills/core/SKILL.md', + ] + + expect(() => parseIntentLockfile(JSON.stringify(lockfile))).toThrow( + /duplicate path/, + ) + }) + + it.each([ + [ + 'root', + (lockfile: Record) => ({ ...lockfile, extra: true }), + ], + [ + 'source', + (lockfile: Record) => ({ + ...lockfile, + sources: [{ ...(lockfile.sources as Array)[0], extra: true }], + }), + ], + [ + 'policy', + (lockfile: Record) => ({ + ...lockfile, + policy: { ...(lockfile.policy as object), extra: true }, + }), + ], + [ + 'policy ignore scope', + (lockfile: Record) => ({ + ...lockfile, + policy: { + ignores: [ + { + scope: { + source: '@tanstack/router', + contentHash: 'sha256-router', + extra: true, + }, + id: 'ignore', + reason: 'test', + createdAt: '2026-01-01T00:00:00Z', + expiresAt: '2026-02-01T00:00:00Z', + }, + ], + }, + }), + ], + [ + 'staleness', + (lockfile: Record) => ({ + ...lockfile, + staleness: { ...(lockfile.staleness as object), extra: true }, + }), + ], + [ + 'staleness baseline', + (lockfile: Record) => ({ + ...lockfile, + staleness: { + ...(lockfile.staleness as { baseline: object }), + baseline: { + ...(lockfile.staleness as { baseline: object }).baseline, + extra: true, + }, + }, + }), + ], + ])('rejects an undeclared %s field', (_, addField) => { + const lockfile = createLockfile() + + expect(() => + parseIntentLockfile( + JSON.stringify( + addField(lockfile as unknown as Record), + ), + ), + ).toThrow(/undeclared field/) + }) +}) + +describe('readIntentLockfile', () => { + it('reports a missing lockfile without throwing', () => { + expect(readIntentLockfile(join(createRoot(), 'intent.lock'))).toEqual({ + status: 'missing', + }) + }) + + it('reads an existing lockfile', () => { + const filePath = join(createRoot(), 'intent.lock') + writeIntentLockfile(filePath, createLockfile()) + + expect(readIntentLockfile(filePath)).toEqual({ + status: 'found', + lockfile: createCanonicalLockfile(), + }) + }) +}) + +describe('writeIntentLockfile', () => { + it('writes deterministic lockfile content', () => { + const root = createRoot() + const filePath = join(root, 'nested', 'intent.lock') + + writeIntentLockfile(filePath, createLockfile()) + + expect(readFileSync(filePath, 'utf8')).toBe( + serializeIntentLockfile(createLockfile()), + ) + }) +}) diff --git a/packages/intent/tests/manifest.test.ts b/packages/intent/tests/manifest.test.ts new file mode 100644 index 00000000..27db3170 --- /dev/null +++ b/packages/intent/tests/manifest.test.ts @@ -0,0 +1,273 @@ +import { mkdirSync, mkdtempSync, 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 { + computeManifestHash, + parseManifest, + readIntentManifest, + serializeManifest, + writeIntentManifest, +} from '../src/core/manifest.js' +import type { IntentManifest } from '../src/core/manifest.js' + +let packageRoot: string + +beforeEach(() => { + packageRoot = mkdtempSync(join(tmpdir(), 'manifest-test-')) +}) + +afterEach(() => { + rmSync(packageRoot, { recursive: true, force: true }) +}) + +function manifestFixture(): IntentManifest { + return parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + }, + ], + }) +} + +describe('parseManifest', () => { + it.each([ + [ + 'root', + { + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [], + securityReview: 'unreviewed', + }, + ], + [ + 'skill', + { + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + extraMetadata: 'unreviewed', + }, + ], + }, + ], + ])('rejects undeclared %s fields', (_label, manifest) => { + expect(() => parseManifest(manifest)).toThrow(/undeclared field/) + }) + + it('rejects duplicate skill paths', () => { + expect(() => + parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { name: 'a', path: 'skills/a/SKILL.md', contentHash: 'sha256-1' }, + { name: 'b', path: 'skills/a/SKILL.md', contentHash: 'sha256-2' }, + ], + }), + ).toThrow(/duplicate skill path/) + }) + + it('rejects path escapes', () => { + expect(() => + parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { name: 'a', path: '../escape/SKILL.md', contentHash: 'sha256-1' }, + ], + }), + ).toThrow(/package-relative/) + }) + + it('rejects unknown capabilities', () => { + expect(() => + parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: ['unknown_capability'], + }, + ], + }), + ).toThrow(/unknown capability/) + }) + + it.each([ + [{}], + [{ name: 1 }], + [{ name: 'fetch', description: 1 }], + [{ name: 'fetch', inputSchema: [] }], + [{ name: 'fetch', inputSchema: { type: undefined } }], + [{ name: 'fetch' }, { name: 'fetch' }], + [{ name: 'fetch', command: 'curl' }], + ])('rejects invalid MCP tool metadata', (mcpTools) => { + expect(() => + parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + mcpTools, + }, + ], + }), + ).toThrow(/mcpTools/) + }) +}) + +describe('manifest serialization', () => { + it('round-trips and serializes identical inputs byte-identically', () => { + const manifest = manifestFixture() + const serialized = serializeManifest(manifest) + + expect(parseManifest(JSON.parse(serialized))).toEqual(manifest) + expect(serializeManifest(manifestFixture())).toBe(serialized) + }) + + it('canonicalizes arrays, tool order, and schema object keys', () => { + const unsorted = parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: ['uses_network', 'runs_install_command'], + declaredSecrets: ['Z_TOKEN', 'A_TOKEN'], + mcpTools: [ + { name: 'zeta', inputSchema: { z: 1, a: { y: true, x: false } } }, + { name: 'alpha', description: 'Alpha tool.' }, + ], + }, + ], + }) + const sorted = parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: ['runs_install_command', 'uses_network'], + declaredSecrets: ['A_TOKEN', 'Z_TOKEN'], + mcpTools: [ + { name: 'alpha', description: 'Alpha tool.' }, + { name: 'zeta', inputSchema: { a: { x: false, y: true }, z: 1 } }, + ], + }, + ], + }) + + expect(serializeManifest(unsorted)).toBe(serializeManifest(sorted)) + expect(computeManifestHash(unsorted)).toBe(computeManifestHash(sorted)) + }) +}) + +describe('readIntentManifest', () => { + it('writes and reads back a manifest file', () => { + const manifest = manifestFixture() + const manifestPath = join(packageRoot, 'skills', 'intent.manifest.json') + mkdirSync(dirname(manifestPath), { recursive: true }) + + writeIntentManifest(manifestPath, manifest) + + expect(readIntentManifest(manifestPath)).toEqual(manifest) + }) + + it('returns null when the manifest file does not exist', () => { + expect(readIntentManifest(join(packageRoot, 'nope.json'))).toBeNull() + }) + + it('fails when an existing manifest is malformed', () => { + const manifestPath = join(packageRoot, 'skills', 'intent.manifest.json') + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, '{not json') + + expect(() => readIntentManifest(manifestPath)).toThrow( + /Invalid intent.manifest.json/, + ) + }) +}) + +describe('computeManifestHash', () => { + it('is stable for identical content and changes with disclosures', () => { + const manifest = manifestFixture() + const before = computeManifestHash(manifest) + const changed = structuredClone(manifest) + changed.skills[0]!.capabilities = ['uses_network'] + + expect(computeManifestHash(manifest)).toBe(before) + expect(computeManifestHash(changed)).not.toBe(before) + }) + + it.each([ + [ + 'declared secret', + (manifest: IntentManifest) => { + manifest.skills[0]!.declaredSecrets = ['API_TOKEN'] + }, + ], + [ + 'MCP tool name', + (manifest: IntentManifest) => { + manifest.skills[0]!.mcpTools = [{ name: 'fetch' }] + }, + ], + [ + 'MCP tool description', + (manifest: IntentManifest) => { + manifest.skills[0]!.mcpTools = [ + { name: 'fetch', description: 'Fetch a resource.' }, + ] + }, + ], + [ + 'MCP tool schema', + (manifest: IntentManifest) => { + manifest.skills[0]!.mcpTools = [ + { name: 'fetch', inputSchema: { type: 'object' } }, + ] + }, + ], + ])('changes when a %s changes', (_label, mutate) => { + const manifest = manifestFixture() + const before = computeManifestHash(manifest) + const changed = structuredClone(manifest) + + mutate(changed) + + expect(computeManifestHash(changed)).not.toBe(before) + }) +}) diff --git a/packages/intent/tests/mode.test.ts b/packages/intent/tests/mode.test.ts new file mode 100644 index 00000000..b59b97ce --- /dev/null +++ b/packages/intent/tests/mode.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isFrozenMode } from '../src/shared/mode.js' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('isFrozenMode', () => { + it('is not frozen by default with no signals', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({}, { isTTY: true })).toBe(false) + }) + + it('is frozen when --frozen is passed', () => { + vi.stubEnv('CI', undefined) + + expect(isFrozenMode({ frozen: true }, { isTTY: true })).toBe(true) + }) + + it('is frozen when INTENT_FROZEN=1', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', '1') + + expect(isFrozenMode({}, { isTTY: true })).toBe(true) + }) + + it('is frozen when INTENT_FROZEN=true', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', 'true') + + expect(isFrozenMode({}, { isTTY: true })).toBe(true) + }) + + it('treats INTENT_FROZEN=0 as unset', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', '0') + + expect(isFrozenMode({}, { isTTY: true })).toBe(false) + }) + + it('treats INTENT_FROZEN=false as unset', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', 'false') + + expect(isFrozenMode({}, { isTTY: true })).toBe(false) + }) + + it('auto-detects frozen mode when CI=true and stdin is not a TTY', () => { + vi.stubEnv('CI', 'true') + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({}, { isTTY: undefined })).toBe(true) + }) + + it('auto-detects using the same truthy set for CI (e.g. CI=1)', () => { + vi.stubEnv('CI', '1') + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({}, { isTTY: undefined })).toBe(true) + }) + + it('CI detection is case-insensitive and trims whitespace', () => { + vi.stubEnv('CI', ' TRUE ') + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({}, { isTTY: undefined })).toBe(true) + }) + + it('does not auto-detect frozen mode when CI=true but stdin is a TTY', () => { + vi.stubEnv('CI', 'true') + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({}, { isTTY: true })).toBe(false) + }) + + it('does not auto-detect frozen mode when stdin is not a TTY but CI is unset', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({}, { isTTY: undefined })).toBe(false) + }) + + it('does not auto-detect frozen mode when CI is set to a falsy value', () => { + vi.stubEnv('CI', 'false') + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({}, { isTTY: undefined })).toBe(false) + }) + + it('--no-frozen overrides the CI auto-detect', () => { + vi.stubEnv('CI', 'true') + vi.stubEnv('INTENT_FROZEN', undefined) + + expect(isFrozenMode({ noFrozen: true }, { isTTY: undefined })).toBe(false) + }) + + it('--no-frozen overrides an explicit INTENT_FROZEN=1', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', '1') + + expect(isFrozenMode({ noFrozen: true }, { isTTY: true })).toBe(false) + }) + + it('--no-frozen overrides CI+INTENT_FROZEN stacked together', () => { + vi.stubEnv('CI', 'true') + vi.stubEnv('INTENT_FROZEN', '1') + + expect(isFrozenMode({ noFrozen: true }, { isTTY: undefined })).toBe(false) + }) + + it('throws when both --frozen and --no-frozen are passed', () => { + expect(() => + isFrozenMode({ frozen: true, noFrozen: true }, { isTTY: true }), + ).toThrow(/--frozen.*--no-frozen/) + }) +}) diff --git a/packages/intent/tests/public-lockfile-types.test.ts b/packages/intent/tests/public-lockfile-types.test.ts new file mode 100644 index 00000000..b93e86ad --- /dev/null +++ b/packages/intent/tests/public-lockfile-types.test.ts @@ -0,0 +1,52 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { + IntentLockfile, + IntentLockfilePolicy, + IntentLockfilePolicyIgnore, + IntentLockfileSource, + IntentLockfileStaleness, + IntentLockfileStalenessBaseline, + ReadIntentLockfileResult, + SourceIdentity, +} from '@tanstack/intent' + +describe('public lockfile types', () => { + it('imports lockfile and source identity types from the package root', () => { + const source: IntentLockfileSource = { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: ['skills/core/SKILL.md'], + contentHash: 'sha256-foo', + manifestHash: null, + capabilities: null, + } + const ignore: IntentLockfilePolicyIgnore = { + id: 'ignored', + scope: { source: 'npm:foo', contentHash: 'sha256-foo' }, + reason: 'reviewed', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + } + const policy: IntentLockfilePolicy = { ignores: [ignore] } + const baseline: IntentLockfileStalenessBaseline = { + kind: 'tag', + ref: 'v1.0.0', + commit: 'abc123', + } + const staleness: IntentLockfileStaleness = { baseline } + const lockfile: IntentLockfile = { + lockfileVersion: 1, + intentVersion: '1.0.0', + staleness, + sources: [source], + policy, + } + const result: ReadIntentLockfileResult = { status: 'found', lockfile } + const identity: SourceIdentity = { kind: 'npm', id: 'foo' } + + expectTypeOf(result).toMatchTypeOf() + expectTypeOf(identity).toMatchTypeOf() + }) +}) diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 1ffb28e6..926adf71 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -77,6 +77,42 @@ function scanResult( } describe('resolveSkillUse', () => { + it('rejects a same-name skill use when npm and workspace sources both match', () => { + const npm = intentPackage({ name: 'foo', kind: 'npm' }) + const workspace = intentPackage({ + name: 'foo', + kind: 'workspace', + packageRoot: 'packages/foo', + skills: [skill('core', 'packages/foo/skills/core/SKILL.md')], + }) + + expect(() => + resolveSkillUse('foo#core', scanResult([npm, workspace])), + ).toThrow( + 'Cannot resolve skill use "foo#core": package "foo" is ambiguous between npm:foo and workspace:foo.', + ) + }) + + it('resolves a same-name skill use when only one source provides the skill', () => { + const npm = intentPackage({ name: 'foo', kind: 'npm' }) + const workspace = intentPackage({ + name: 'foo', + kind: 'workspace', + packageRoot: 'packages/foo', + skills: [ + skill('workspace-only', 'packages/foo/skills/workspace-only/SKILL.md'), + ], + }) + + expect( + resolveSkillUse('foo#workspace-only', scanResult([npm, workspace])), + ).toMatchObject({ + packageRoot: 'packages/foo', + path: 'packages/foo/skills/workspace-only/SKILL.md', + skillName: 'workspace-only', + }) + }) + it('resolves a local package and exact skill', () => { const pkg = intentPackage({ name: '@tanstack/query', diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 970016c3..e3caa29d 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -135,6 +135,43 @@ describe('scanForIntents', () => { expect(result.stats.packageJsonReadCount).toBeGreaterThan(0) }) + it('retains npm and workspace packages with the same name', () => { + writeJson(join(root, 'package.json'), { + name: 'consumer', + private: true, + workspaces: ['packages/*'], + }) + const workspacePkg = createDir(root, 'packages', 'foo') + writeJson(join(workspacePkg, 'package.json'), { + name: 'foo', + version: '1.0.0', + intent: { version: 1, repo: 'test/workspace-foo', docs: 'docs/' }, + }) + writeSkillMd(createDir(workspacePkg, 'skills', 'workspace'), { + name: 'workspace', + description: 'Workspace skill', + }) + + const npmPkg = createDir(root, 'node_modules', 'foo') + writeJson(join(npmPkg, 'package.json'), { + name: 'foo', + version: '2.0.0', + intent: { version: 1, repo: 'test/npm-foo', docs: 'docs/' }, + }) + writeSkillMd(createDir(npmPkg, 'skills', 'npm'), { + name: 'npm', + description: 'npm skill', + }) + + const result = scanForIntents(root) + + expect( + result.packages.map((pkg) => `${pkg.kind}:${pkg.name}`).sort(), + ).toEqual(['npm:foo', 'workspace:foo']) + expect(result.conflicts).toEqual([]) + expect(result.warnings).toEqual([]) + }) + it('does not throw when skills exists but is not a directory', () => { const pkgDir = createDir(root, 'node_modules', '@tanstack', 'db') writeJson(join(pkgDir, 'package.json'), { @@ -198,6 +235,98 @@ describe('scanForIntents', () => { expect(result.packages[0]!.name).toBe('my-lib') }) + it('records the parent chain for a direct skill package', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + dependencies: { leaf: '1.0.0' }, + }) + const leafDir = createDir(root, 'node_modules', 'leaf') + writeJson(join(leafDir, 'package.json'), { + name: 'leaf', + version: '1.0.0', + intent: { version: 1, repo: 'test/leaf', docs: 'docs/' }, + }) + writeSkillMd(createDir(leafDir, 'skills', 'core'), { + name: 'core', + description: 'Leaf skill', + }) + + const result = scanForIntents(root) + + expect(result.packages[0]!.provenance).toEqual([['app', 'leaf']]) + }) + + it('records the parent chain for a transitive skill package', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + dependencies: { parent: '1.0.0' }, + }) + const parentDir = createDir(root, 'node_modules', 'parent') + writeJson(join(parentDir, 'package.json'), { + name: 'parent', + version: '1.0.0', + dependencies: { leaf: '1.0.0' }, + }) + const leafDir = createDir(parentDir, 'node_modules', 'leaf') + writeJson(join(leafDir, 'package.json'), { + name: 'leaf', + version: '1.0.0', + intent: { version: 1, repo: 'test/leaf', docs: 'docs/' }, + }) + writeSkillMd(createDir(leafDir, 'skills', 'core'), { + name: 'core', + description: 'Leaf skill', + }) + + const result = scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.provenance).toEqual([['app', 'parent', 'leaf']]) + }) + + it('retains a bounded number of parent chains for the same skill package', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + dependencies: { + alpha: '1.0.0', + beta: '1.0.0', + gamma: '1.0.0', + delta: '1.0.0', + }, + }) + for (const name of ['alpha', 'beta', 'gamma', 'delta']) { + const parentDir = createDir(root, 'node_modules', name) + writeJson(join(parentDir, 'package.json'), { + name, + version: '1.0.0', + dependencies: { leaf: '1.0.0' }, + }) + } + const leafDir = createDir(root, 'node_modules', 'leaf') + writeJson(join(leafDir, 'package.json'), { + name: 'leaf', + version: '1.0.0', + intent: { version: 1, repo: 'test/leaf', docs: 'docs/' }, + }) + writeSkillMd(createDir(leafDir, 'skills', 'core'), { + name: 'core', + description: 'Leaf skill', + }) + + const result = scanForIntents(root) + const provenance = result.packages[0]!.provenance + + expect(provenance).toHaveLength(3) + expect(provenance).toEqual( + expect.arrayContaining([ + ['app', 'alpha', 'leaf'], + ['app', 'beta', 'leaf'], + ['app', 'gamma', 'leaf'], + ]), + ) + expect(provenance).not.toContainEqual(['app', 'delta', 'leaf']) + }) + it('discovers transitive skills of a skill-bearing direct dep under pnpm isolated linker (#153)', () => { // pnpm isolated layout: a store-only transitive dep (start-core) reached // only through its skill-bearing parent's (react-start) store dir. diff --git a/packages/intent/tests/skills-approve.test.ts b/packages/intent/tests/skills-approve.test.ts new file mode 100644 index 00000000..9daddf81 --- /dev/null +++ b/packages/intent/tests/skills-approve.test.ts @@ -0,0 +1,660 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../src/core/lockfile/lockfile.js' +import { runSkillsApproveCommand } from '../src/commands/skills/approve.js' +import type { IntentLockfile } from '../src/core/lockfile/lockfile.js' +import type { PolicedScan } from '../src/core/source-policy.js' +import type { IntentPackage, ScanResult } from '../src/shared/types.js' + +function emptyScanResult(packages: Array = []): ScanResult { + return { + packageManager: 'npm', + packages, + warnings: [], + notices: [], + conflicts: [], + nodeModules: { + local: { root: null, packages: [] }, + global: { root: null, packages: [] }, + }, + stats: { + packageJsonReadCount: 0, + packageJsonCacheHits: 0, + }, + } as unknown as ScanResult +} + +function policedScan(overrides: Partial = {}): PolicedScan { + return { + scan: emptyScanResult(), + hiddenSourceCount: 0, + hiddenSources: [], + excludePatterns: [], + droppedNames: [], + ...overrides, + } +} + +function baseLockfile(): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: [], + policy: { ignores: [] }, + } +} + +function lockedSource( + overrides: Partial = {}, +): IntentLockfile['sources'][number] { + return { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: [], + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: null, + ...overrides, + } +} + +describe('runSkillsApproveCommand', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const tempDirs: Array = [] + + afterEach(() => { + logSpy.mockClear() + vi.unstubAllEnvs() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeTempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'intent-skills-approve-')) + tempDirs.push(dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x' })) + return dir + } + + function makeReviewPackage(cwd: string): IntentPackage { + const packageRoot = join(cwd, 'node_modules', 'foo') + const skillDir = join(packageRoot, 'skills', 'core') + mkdirSync(join(skillDir, 'assets'), { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Review fixture\n---\n\nExact review body.\n', + ) + writeFileSync( + join(skillDir, 'assets', 'data.bin'), + Buffer.from([0xff, 0, 1]), + ) + return { + name: 'foo', + kind: 'npm', + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: 'docs/' }, + packageRoot, + skills: [ + { + name: 'core', + path: join(skillDir, 'SKILL.md'), + description: 'Review fixture', + }, + ], + source: 'local', + } + } + + it('refuses to run in frozen mode', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsApproveCommand( + undefined, + { all: true, frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('cannot run in frozen mode'), + exitCode: 5, + }) + }) + + it('rejects passing both a source id and --all', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsApproveCommand( + 'npm:foo', + { all: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('either a source id or --all'), + }) + }) + + it('reports nothing to approve when current matches the lockfile', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsApproveCommand( + undefined, + {}, + () => Promise.resolve(policedScan()), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Nothing to approve') + }) + + it('--all creates the initial lockfile on first run', async () => { + const cwd = makeTempProject() + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources).toHaveLength(1) + expect(result.lockfile.sources[0]).toMatchObject({ + id: 'foo', + kind: 'npm', + }) + } + }) + + it('--all displays exact text and binary summaries before first approval', async () => { + const cwd = makeTempProject() + const pkg = makeReviewPackage(cwd) + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => Promise.resolve(policedScan({ scan: emptyScanResult([pkg]) })), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Reviewing npm:foo@1.0.0') + expect(output).toContain('Text: skills/core/SKILL.md') + expect(output).toContain('Exact review body.') + expect(output).toMatch( + /Binary: skills\/core\/assets\/data\.bin \(3 bytes, sha256-[0-9a-f]{64}\)/, + ) + }) + + it('escapes terminal and bidi controls in reviewed text', async () => { + const cwd = makeTempProject() + const pkg = makeReviewPackage(cwd) + writeFileSync( + pkg.skills[0]!.path, + 'safe\u001b[31mred\u001b[0m\u202ereordered\n', + ) + writeFileSync( + join(pkg.packageRoot, 'skills', 'core', 'assets', '\u001b[2J.bin'), + Buffer.from([0]), + ) + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => Promise.resolve(policedScan({ scan: emptyScanResult([pkg]) })), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('safe\\u001b[31mred\\u001b[0m') + expect(output).toContain('\\u202ereordered') + expect(output).toContain('assets/\\u001b[2J.bin') + expect(output).not.toContain('\u001b') + expect(output).not.toContain('\u202e') + }) + + it('--all removes a locked source that is no longer discovered', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource()], + }) + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => Promise.resolve(policedScan()), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources).toHaveLength(0) + } + }) + + it('preserves metadata when approving a single source id', async () => { + const cwd = makeTempProject() + const metadata = { + staleness: { + baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + }, + policy: { + ignores: [ + { + id: 'rejected-thing', + scope: { source: 'npm:foo', contentHash: 'sha256-aaa' }, + reason: 'reviewed and rejected', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + }, + ], + }, + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + ...metadata, + sources: [lockedSource({ id: 'foo' }), lockedSource({ id: 'bar' })], + }) + + await runSkillsApproveCommand( + 'npm:foo', + {}, + () => Promise.resolve(policedScan()), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + // "bar" still has a pending removal (declined) — stays in the lock as drift. + expect(result.lockfile.sources.map((s) => s.id).sort()).toEqual(['bar']) + expect({ + staleness: result.lockfile.staleness, + policy: result.lockfile.policy, + }).toEqual(metadata) + } + }) + + it('fails when the given source id has no pending change', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsApproveCommand( + 'npm:does-not-exist', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('No pending change for'), + }) + }) + + it('rejects an invalid source id format', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsApproveCommand( + 'git:not-a-supported-kind', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Invalid source'), + }) + }) + + it('fails when a bare name matches no discovered source', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsApproveCommand( + 'not-discovered', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('No discovered source matches'), + }) + }) + + it('resolves a bare name to its single discovered match', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + lockedSource({ id: 'foo', version: '1.0.0' }), + lockedSource({ id: 'bar' }), + ], + }) + + await runSkillsApproveCommand( + 'foo', + {}, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + // "foo"'s version bump was approved; "bar"'s pending removal (declined, + // since only "foo" was targeted) stays in the lock as drift. + const foo = result.lockfile.sources.find((s) => s.id === 'foo') + expect(foo?.version).toBe('2.0.0') + expect(result.lockfile.sources.map((s) => s.id).sort()).toEqual([ + 'bar', + 'foo', + ]) + } + }) + + it('errors on an ambiguous bare name matching sources of two kinds', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsApproveCommand( + 'foo', + {}, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + { + name: 'foo', + kind: 'workspace', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Ambiguous source "foo"'), + }) + }) + + it('interactive mode only writes changes the confirm callback approves', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo' }), lockedSource({ id: 'bar' })], + }) + + // Both "foo" and "bar" are pending removals (not currently discovered). + // Approve removing "foo", decline removing "bar". + await runSkillsApproveCommand( + undefined, + {}, + () => Promise.resolve(policedScan()), + cwd, + (question) => Promise.resolve(question.includes('foo')), + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources.map((s) => s.id)).toEqual(['bar']) + } + }) + + it('reports hidden sources without blocking approval', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource()], + }) + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => Promise.resolve(policedScan({ hiddenSourceCount: 2 })), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('2 discovered skill-bearing source(s)') + }) + + it('reports hidden sources even when there is nothing else to approve', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsApproveCommand( + undefined, + {}, + () => Promise.resolve(policedScan({ hiddenSourceCount: 3 })), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('3 discovered skill-bearing source(s)') + expect(output).toContain('Nothing to approve') + }) + + it('approves a version/hash change (update) for a source that still exists', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo', version: '1.0.0' })], + }) + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources).toHaveLength(1) + expect(result.lockfile.sources[0]).toMatchObject({ + id: 'foo', + version: '2.0.0', + }) + } + }) + + it('preserves the existing policy.ignores through approve --all', async () => { + const cwd = makeTempProject() + const ignore = { + id: 'ignored-thing', + scope: { source: 'npm:foo', contentHash: 'sha256-aaa' }, + reason: 'reviewed and accepted', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource()], + policy: { ignores: [ignore] }, + }) + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => Promise.resolve(policedScan()), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.policy.ignores).toEqual([ignore]) + } + }) + + it('preserves metadata through approve --all', async () => { + const cwd = makeTempProject() + const metadata = { + staleness: { + baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + }, + policy: { + ignores: [ + { + id: 'rejected-thing', + scope: { source: 'npm:foo', contentHash: 'sha256-aaa' }, + reason: 'reviewed and rejected', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + }, + ], + }, + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + ...metadata, + sources: [lockedSource({ version: '1.0.0' })], + }) + + await runSkillsApproveCommand( + undefined, + { all: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect({ + staleness: result.lockfile.staleness, + policy: result.lockfile.policy, + }).toEqual(metadata) + } + }) + + it('does not write intent.lock when every pending change is declined', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource()], + }) + const before = readFileSync(join(cwd, 'intent.lock'), 'utf8') + + await runSkillsApproveCommand( + undefined, + {}, + () => Promise.resolve(policedScan()), + cwd, + () => Promise.resolve(false), + ) + + const after = readFileSync(join(cwd, 'intent.lock'), 'utf8') + expect(after).toBe(before) + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('No changes approved') + }) + + it('fails instead of prompting when stdin is not a TTY and no --all/source id is given', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource()], + }) + + await expect( + runSkillsApproveCommand( + undefined, + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('stdin is not a TTY'), + }) + }) +}) diff --git a/packages/intent/tests/skills-diff.test.ts b/packages/intent/tests/skills-diff.test.ts new file mode 100644 index 00000000..7644d478 --- /dev/null +++ b/packages/intent/tests/skills-diff.test.ts @@ -0,0 +1,235 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { runSkillsDiffCommand } from '../src/commands/skills/diff.js' +import type { IntentLockfile } from '../src/core/lockfile/lockfile.js' +import type { PolicedScan } from '../src/core/source-policy.js' +import type { IntentPackage, ScanResult } from '../src/shared/types.js' + +function emptyScanResult(packages: Array = []): ScanResult { + return { + packageManager: 'npm', + packages, + warnings: [], + notices: [], + conflicts: [], + nodeModules: { + local: { root: null, packages: [] }, + global: { root: null, packages: [] }, + }, + stats: { + packageJsonReadCount: 0, + packageJsonCacheHits: 0, + }, + } as unknown as ScanResult +} + +function policedScan(overrides: Partial = {}): PolicedScan { + return { + scan: emptyScanResult(), + hiddenSourceCount: 0, + hiddenSources: [], + excludePatterns: [], + droppedNames: [], + ...overrides, + } +} + +function baseLockfile(): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: [], + policy: { ignores: [] }, + } +} + +describe('runSkillsDiffCommand', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const tempDirs: Array = [] + + afterEach(() => { + logSpy.mockClear() + vi.unstubAllEnvs() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeTempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'intent-skills-diff-')) + tempDirs.push(dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x' })) + return dir + } + + function makeReviewPackage(cwd: string): IntentPackage { + const packageRoot = join(cwd, 'node_modules', 'foo') + const skillDir = join(packageRoot, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + const skillPath = join(skillDir, 'SKILL.md') + writeFileSync(skillPath, 'approved body\n') + return { + name: 'foo', + kind: 'npm', + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: 'docs/' }, + packageRoot, + skills: [{ name: 'core', path: skillPath, description: 'Core' }], + source: 'local', + } + } + + it('shows current exact text for a changed source', async () => { + const cwd = makeTempProject() + const pkg = makeReviewPackage(cwd) + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: buildCurrentLockfileSources([pkg]), + }) + writeFileSync(pkg.skills[0]!.path, 'current exact body\n') + + await runSkillsDiffCommand( + {}, + () => Promise.resolve(policedScan({ scan: emptyScanResult([pkg]) })), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Reviewing npm:foo@1.0.0') + expect(output).toContain('Text: skills/core/SKILL.md') + expect(output).toContain('current exact body') + }) + + it('lists removed sources when the lockfile has an entry no longer present', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: [], + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: null, + }, + ], + }) + + await runSkillsDiffCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Removed:') + expect(output).toContain('npm:foo@1.0.0') + expect(output).toContain('skills: (none)') + expect(output).toContain('contentHash: sha256-aaa') + }) + + it('reports hidden (unlisted) sources even when nothing else has changed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsDiffCommand( + {}, + () => + Promise.resolve( + policedScan({ + hiddenSourceCount: 2, + hiddenSources: [ + { + kind: 'workspace', + name: 'leaf', + skillCount: 1, + provenance: [['app', 'parent', 'leaf']], + }, + { kind: 'npm', name: 'unknown', skillCount: 1 }, + ], + }), + ), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('2 discovered skill-bearing source(s)') + expect(output).toContain('workspace:leaf (via app -> parent -> leaf)') + expect(output).toContain('npm:unknown (provenance unknown)') + expect(output).toContain('intent.lock is up to date') + }) + + it('reports up to date when nothing has changed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsDiffCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('intent.lock is up to date') + }) + + it('outputs JSON with frozen and hiddenSourceCount fields when --json is passed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsDiffCommand( + { json: true }, + () => Promise.resolve(policedScan()), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + const parsed = JSON.parse(output) + expect(parsed).toMatchObject({ + frozen: false, + hiddenSourceCount: 0, + isClean: true, + }) + }) + + it('throws in frozen mode when intent.lock is missing', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsDiffCommand( + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Frozen mode requires intent.lock'), + }) + }) + + it('does not throw in frozen mode when intent.lock is clean', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsDiffCommand( + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).resolves.toBeUndefined() + }) + + it('throws in frozen mode when there are unlisted skill-bearing sources, even with a clean lockfile', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsDiffCommand( + { frozen: true }, + () => Promise.resolve(policedScan({ hiddenSourceCount: 1 })), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('unlisted skill-bearing source'), + }) + }) +}) diff --git a/packages/intent/tests/skills-resolve-source-arg.test.ts b/packages/intent/tests/skills-resolve-source-arg.test.ts new file mode 100644 index 00000000..a52b6418 --- /dev/null +++ b/packages/intent/tests/skills-resolve-source-arg.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { resolveSourceArg } from '../src/commands/skills/support.js' +import type { SourceIdentity } from '../src/core/types.js' + +describe('resolveSourceArg', () => { + it('parses an explicit npm:id form', () => { + expect(resolveSourceArg('npm:@tanstack/query', [])).toEqual({ + kind: 'npm', + id: '@tanstack/query', + }) + }) + + it('parses an explicit workspace:id form', () => { + expect(resolveSourceArg('workspace:router', [])).toEqual({ + kind: 'workspace', + id: 'router', + }) + }) + + it('rejects an unsupported kind prefix', () => { + expect(() => resolveSourceArg('git:foo', [])).toThrow(/Invalid source/) + }) + + it('rejects a bare colon with no kind', () => { + expect(() => resolveSourceArg(':foo', [])).toThrow(/Invalid source/) + }) + + it('strips a trailing @version label as produced by diff.ts for added/removed', () => { + expect(resolveSourceArg('npm:foo@1.2.3', [])).toEqual({ + kind: 'npm', + id: 'foo', + }) + }) + + it('does not strip a scoped package name as if it were an @version suffix', () => { + expect(resolveSourceArg('npm:@tanstack/query', [])).toEqual({ + kind: 'npm', + id: '@tanstack/query', + }) + }) + + it('does not strip a trailing @-segment that does not start with a digit', () => { + // Documents the known limitation: a hand-edited "v1.0.0"-style version + // (not diff.ts's own output format) is treated as part of the id, not + // stripped, since the heuristic only strips a digit-leading suffix. + expect(resolveSourceArg('npm:foo@vNext', [])).toEqual({ + kind: 'npm', + id: 'foo@vNext', + }) + }) + + it('resolves a bare name to its single discovered match', () => { + const discovered: Array = [{ kind: 'npm', id: 'foo' }] + + expect(resolveSourceArg('foo', discovered)).toEqual({ + kind: 'npm', + id: 'foo', + }) + }) + + it('errors when a bare name matches nothing discovered', () => { + expect(() => resolveSourceArg('foo', [])).toThrow( + /No discovered source matches "foo"/, + ) + }) + + it('errors on a bare name matching sources of two different kinds', () => { + const discovered: Array = [ + { kind: 'npm', id: 'foo' }, + { kind: 'workspace', id: 'foo' }, + ] + + expect(() => resolveSourceArg('foo', discovered)).toThrow( + /Ambiguous source "foo": matches npm:foo and workspace:foo/, + ) + }) + + it('does not consider a same-kind duplicate as ambiguous input (single discovered set is already deduped)', () => { + const discovered: Array = [{ kind: 'npm', id: 'foo' }] + + expect(resolveSourceArg('foo', discovered)).toEqual({ + kind: 'npm', + id: 'foo', + }) + }) +}) diff --git a/packages/intent/tests/skills-scan.test.ts b/packages/intent/tests/skills-scan.test.ts new file mode 100644 index 00000000..aed61dca --- /dev/null +++ b/packages/intent/tests/skills-scan.test.ts @@ -0,0 +1,296 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { runSkillsScanCommand } from '../src/commands/skills/scan.js' +import type { IntentLockfile } from '../src/core/lockfile/lockfile.js' +import type { PolicedScan } from '../src/core/source-policy.js' +import type { ScanResult } from '../src/shared/types.js' + +function emptyScanResult(): ScanResult { + return { + packageManager: 'npm', + packages: [], + warnings: [], + notices: [], + conflicts: [], + nodeModules: { + local: { root: null, packages: [] }, + global: { root: null, packages: [] }, + }, + stats: { + packageJsonReadCount: 0, + packageJsonCacheHits: 0, + }, + } as unknown as ScanResult +} + +function policedScan(overrides: Partial = {}): PolicedScan { + return { + scan: emptyScanResult(), + hiddenSourceCount: 0, + hiddenSources: [], + excludePatterns: [], + droppedNames: [], + ...overrides, + } +} + +function baseLockfile(): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: [], + policy: { ignores: [] }, + } +} + +describe('runSkillsScanCommand', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const tempDirs: Array = [] + + afterEach(() => { + logSpy.mockClear() + vi.unstubAllEnvs() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeTempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'intent-skills-scan-')) + tempDirs.push(dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x' })) + return dir + } + + it('reports no lockfile when intent.lock is missing', async () => { + const cwd = makeTempProject() + + await runSkillsScanCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('No intent.lock found') + }) + + it('reports up to date when current sources match the lockfile', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsScanCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('intent.lock is up to date') + }) + + it('reports hidden (unlisted) sources even when the lockfile is clean', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsScanCommand( + {}, + () => + Promise.resolve( + policedScan({ + hiddenSourceCount: 2, + hiddenSources: [ + { + kind: 'workspace', + name: 'leaf', + skillCount: 1, + provenance: [['app', 'parent', 'leaf']], + }, + { kind: 'npm', name: 'unknown', skillCount: 1 }, + ], + }), + ), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('2 discovered skill-bearing source(s)') + expect(output).toContain('workspace:leaf (via app -> parent -> leaf)') + expect(output).toContain('npm:unknown (provenance unknown)') + expect(output).toContain('intent.lock is up to date') + }) + + it('escapes untrusted hidden-source details', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsScanCommand( + {}, + () => + Promise.resolve( + policedScan({ + hiddenSourceCount: 1, + hiddenSources: [ + { + kind: 'npm', + name: 'evil\u001b[2J\u202e', + skillCount: 1, + provenance: [['root\nforged', 'dep\u001b[31m']], + }, + ], + }), + ), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('npm:evil\\u001b[2J\\u202e') + expect(output).toContain('via root\\nforged -> dep\\u001b[31m') + expect(output).not.toContain('\u001b') + expect(output).not.toContain('\u202e') + expect(output).not.toContain('root\nforged') + }) + + it('reports drift when the lockfile has a source no longer present', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: [], + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: null, + }, + ], + }) + + await runSkillsScanCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('out of date') + expect(output).toContain('1 removed') + }) + + it('outputs JSON with frozen and hiddenSourceCount fields when --json is passed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsScanCommand( + { json: true }, + () => Promise.resolve(policedScan()), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + const parsed = JSON.parse(output) + expect(parsed).toMatchObject({ + frozen: false, + hiddenSourceCount: 0, + isClean: true, + }) + }) + + it('throws in frozen mode when intent.lock is missing', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsScanCommand( + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Frozen mode requires intent.lock'), + exitCode: 4, + }) + }) + + it('throws in frozen mode when intent.lock is stale', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: [], + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: null, + }, + ], + }) + + await expect( + runSkillsScanCommand( + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('out of date'), + exitCode: 2, + }) + }) + + it('throws in frozen mode when there are unlisted skill-bearing sources, even with a clean lockfile', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsScanCommand( + { frozen: true }, + () => + Promise.resolve( + policedScan({ + hiddenSourceCount: 1, + hiddenSources: [ + { + kind: 'workspace', + name: 'leaf', + skillCount: 1, + provenance: [['app', 'parent', 'leaf']], + }, + ], + }), + ), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringMatching( + /unlisted skill-bearing source.*workspace:leaf \(via app -> parent -> leaf\)/, + ), + exitCode: 3, + }) + }) + + it('does not throw in frozen mode when intent.lock is clean and there are no hidden sources', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsScanCommand( + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).resolves.toBeUndefined() + }) + + it('fails with exit code 6 when intent.lock is malformed', async () => { + const cwd = makeTempProject() + writeFileSync( + join(cwd, 'intent.lock'), + JSON.stringify({ lockfileVersion: 2 }), + ) + + await expect( + runSkillsScanCommand({}, () => Promise.resolve(policedScan()), cwd), + ).rejects.toMatchObject({ + message: expect.stringContaining('Malformed intent.lock'), + exitCode: 6, + }) + }) +}) diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts new file mode 100644 index 00000000..ab7bd868 --- /dev/null +++ b/packages/intent/tests/skills-update.test.ts @@ -0,0 +1,735 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../src/core/lockfile/lockfile.js' +import { runSkillsUpdateCommand } from '../src/commands/skills/update.js' +import type { IntentLockfile } from '../src/core/lockfile/lockfile.js' +import type { PolicedScan } from '../src/core/source-policy.js' +import type { IntentPackage, ScanResult } from '../src/shared/types.js' + +function emptyScanResult(packages: Array = []): ScanResult { + return { + packageManager: 'npm', + packages, + warnings: [], + notices: [], + conflicts: [], + nodeModules: { + local: { root: null, packages: [] }, + global: { root: null, packages: [] }, + }, + stats: { + packageJsonReadCount: 0, + packageJsonCacheHits: 0, + }, + } as unknown as ScanResult +} + +function policedScan(overrides: Partial = {}): PolicedScan { + return { + scan: emptyScanResult(), + hiddenSourceCount: 0, + hiddenSources: [], + excludePatterns: [], + droppedNames: [], + ...overrides, + } +} + +function baseLockfile(): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: [], + policy: { ignores: [] }, + } +} + +function lockedSource( + overrides: Partial = {}, +): IntentLockfile['sources'][number] { + return { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: [], + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: null, + ...overrides, + } +} + +describe('runSkillsUpdateCommand', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const tempDirs: Array = [] + + afterEach(() => { + logSpy.mockClear() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeTempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'intent-skills-update-')) + tempDirs.push(dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x' })) + return dir + } + + function makeReviewPackage(cwd: string): IntentPackage { + const packageRoot = join(cwd, 'node_modules', 'foo') + const skillDir = join(packageRoot, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + const skillPath = join(skillDir, 'SKILL.md') + writeFileSync(skillPath, 'current update content\n') + return { + name: 'foo', + kind: 'npm', + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: 'docs/' }, + packageRoot, + skills: [{ name: 'core', path: skillPath, description: 'Core' }], + source: 'local', + } + } + + it('displays current content before --yes accepts a trust-bearing update', async () => { + const cwd = makeTempProject() + const pkg = makeReviewPackage(cwd) + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + lockedSource({ + skills: ['skills/core/SKILL.md'], + contentHash: 'sha256-previous', + }), + ], + }) + + await runSkillsUpdateCommand( + undefined, + { yes: true }, + () => Promise.resolve(policedScan({ scan: emptyScanResult([pkg]) })), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Reviewing npm:foo@1.0.0') + expect(output).toContain('Text: skills/core/SKILL.md') + expect(output).toContain('current update content') + }) + + it('refuses to run in frozen mode', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsUpdateCommand( + undefined, + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('cannot run in frozen mode'), + exitCode: 5, + }) + }) + + it('rejects passing both a source id and --all', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsUpdateCommand( + 'npm:foo', + { all: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('either a source id or --all'), + }) + }) + + it('fails when there is no intent.lock', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsUpdateCommand( + undefined, + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('No intent.lock found'), + }) + }) + + it('reports nothing to update when current matches the lockfile', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsUpdateCommand( + undefined, + {}, + () => Promise.resolve(policedScan()), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Nothing to update') + }) + + it('re-syncs a version/hash change for all locked sources', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo', version: '1.0.0' })], + }) + + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + + await runSkillsUpdateCommand( + undefined, + { yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + expect(fetchSpy).not.toHaveBeenCalled() + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources).toHaveLength(1) + expect(result.lockfile.sources[0]).toMatchObject({ + id: 'foo', + version: '2.0.0', + }) + } + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Updated 1 source(s)') + }) + + it('requires --yes before accepting a content hash change', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource()], + }) + + await expect( + runSkillsUpdateCommand( + undefined, + { all: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('--yes'), + }) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources[0]?.contentHash).toBe('sha256-aaa') + } + + await runSkillsUpdateCommand( + undefined, + { all: true, yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const updated = readIntentLockfile(join(cwd, 'intent.lock')) + expect(updated.status).toBe('found') + if (updated.status === 'found') { + expect(updated.lockfile.sources[0]?.contentHash).not.toBe('sha256-aaa') + } + }) + + it('preserves metadata while updating a targeted source', async () => { + const cwd = makeTempProject() + const metadata = { + staleness: { + baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + }, + policy: { + ignores: [ + { + id: 'rejected-thing', + scope: { source: 'npm:foo', contentHash: 'sha256-aaa' }, + reason: 'reviewed and rejected', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + }, + ], + }, + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + ...metadata, + sources: [ + lockedSource({ id: 'foo', version: '1.0.0' }), + lockedSource({ id: 'bar', version: '1.0.0' }), + ], + }) + + await runSkillsUpdateCommand( + 'npm:foo', + { yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + { + name: 'bar', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + const foo = result.lockfile.sources.find((s) => s.id === 'foo') + const bar = result.lockfile.sources.find((s) => s.id === 'bar') + expect(foo?.version).toBe('2.0.0') + expect(bar?.version).toBe('1.0.0') + expect({ + staleness: result.lockfile.staleness, + policy: result.lockfile.policy, + }).toEqual(metadata) + } + }) + + it('does not add newly discovered sources that are not yet locked', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsUpdateCommand( + undefined, + { all: true, yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'new-source', + kind: 'npm', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources).toHaveLength(0) + } + }) + + it('reports pending added/removed drift after updating, since update never touches it', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + lockedSource({ id: 'foo', version: '1.0.0' }), + lockedSource({ id: 'gone' }), + ], + }) + + await runSkillsUpdateCommand( + undefined, + { all: true, yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + { + name: 'new-source', + kind: 'npm', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Updated 1 source(s)') + expect(output).toContain('1 added, 1 removed source(s) still pending') + expect(output).toContain('intent skills approve') + }) + + it('does not remove a locked source that is no longer discovered', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo' })], + }) + + await runSkillsUpdateCommand( + undefined, + { all: true }, + () => Promise.resolve(policedScan()), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources).toHaveLength(1) + expect(result.lockfile.sources[0]).toMatchObject({ id: 'foo' }) + } + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Nothing to update') + }) + + it('fails when the given source id is not locked', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsUpdateCommand( + 'npm:does-not-exist', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('is not in intent.lock'), + }) + }) + + it('fails when the given source id is locked but no longer discovered', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo' })], + }) + + await expect( + runSkillsUpdateCommand( + 'npm:foo', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('no longer discovered'), + }) + }) + + it('rejects an invalid source id format', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsUpdateCommand( + 'git:not-a-supported-kind', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Invalid source'), + }) + }) + + it('fails when a bare name matches no discovered source', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo' })], + }) + + await expect( + runSkillsUpdateCommand( + 'not-discovered', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('No discovered source matches'), + }) + }) + + it('resolves a bare name to its single discovered match', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo', version: '1.0.0' })], + }) + + await runSkillsUpdateCommand( + 'foo', + { yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.sources[0]).toMatchObject({ + id: 'foo', + version: '2.0.0', + }) + } + }) + + it('errors on an ambiguous bare name matching sources of two kinds', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + lockedSource({ id: 'foo', kind: 'npm' }), + lockedSource({ id: 'foo', kind: 'workspace' }), + ], + }) + + await expect( + runSkillsUpdateCommand( + 'foo', + {}, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + { + name: 'foo', + kind: 'workspace', + version: '1.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Ambiguous source "foo"'), + }) + }) + + it('preserves the existing policy.ignores', async () => { + const cwd = makeTempProject() + const ignore = { + id: 'ignored-thing', + scope: { source: 'npm:foo', contentHash: 'sha256-aaa' }, + reason: 'reviewed and accepted', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [lockedSource({ id: 'foo', version: '1.0.0' })], + policy: { ignores: [ignore] }, + }) + + await runSkillsUpdateCommand( + undefined, + { yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.lockfile.policy.ignores).toEqual([ignore]) + } + }) + + it('preserves metadata through update --all', async () => { + const cwd = makeTempProject() + const metadata = { + staleness: { + baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + }, + policy: { + ignores: [ + { + id: 'rejected-thing', + scope: { source: 'npm:foo', contentHash: 'sha256-aaa' }, + reason: 'reviewed and rejected', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + }, + ], + }, + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + ...metadata, + sources: [lockedSource({ id: 'foo', version: '1.0.0' })], + }) + + await runSkillsUpdateCommand( + undefined, + { yes: true }, + () => + Promise.resolve( + policedScan({ + scan: emptyScanResult([ + { + name: 'foo', + kind: 'npm', + version: '2.0.0', + packageRoot: cwd, + skills: [], + } as unknown as IntentPackage, + ]), + }), + ), + cwd, + ) + + const result = readIntentLockfile(join(cwd, 'intent.lock')) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect({ + staleness: result.lockfile.staleness, + policy: result.lockfile.policy, + }).toEqual(metadata) + } + }) + + it('does not write intent.lock when nothing changed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + const before = readFileSync(join(cwd, 'intent.lock'), 'utf8') + + await runSkillsUpdateCommand( + undefined, + {}, + () => Promise.resolve(policedScan()), + cwd, + ) + + const after = readFileSync(join(cwd, 'intent.lock'), 'utf8') + expect(after).toBe(before) + }) +}) diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 122aef97..c2e6d562 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -59,17 +59,53 @@ describe('applySourcePolicy — allowlist matrix', () => { expect(result.notices).toEqual([]) }) - it('drops an unlisted discovered package and warns', () => { + it('falls back to unknown provenance for an unlisted package', () => { 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.', + '1 discovered package ships skills but is not listed in intent.skills: npm:@scope/b (provenance unknown). Add to opt in.', ]) }) + it('includes known provenance in an unlisted-source notice', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/a', ['x']), + { + ...pkg('@scope/b', ['y']), + provenance: [['app', '@scope/parent', '@scope/b']], + }, + ], + }, + { config: config(['@scope/a']), excludeMatchers: [] }, + ) + + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: npm:@scope/b (via app -> @scope/parent -> @scope/b). Add to opt in.', + ]) + }) + + it('escapes untrusted names and provenance in human notices', () => { + const hidden = { + ...pkg('evil\u001b[2J\u202e', ['y']), + provenance: [['root\nforged', 'dep\u001b[31m']], + } + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x']), hidden] }, + { config: config(['@scope/a']), excludeMatchers: [] }, + ) + + expect(result.notices[0]).toContain('npm:evil\\u001b[2J\\u202e') + expect(result.notices[0]).toContain('via root\\nforged -> dep\\u001b[31m') + expect(result.notices[0]).not.toContain('\u001b') + expect(result.notices[0]).not.toContain('\u202e') + expect(result.notices[0]).not.toContain('root\nforged') + }) + it('collapses several unlisted packages into one sorted summary warning', () => { const result = applySourcePolicy( { @@ -82,7 +118,7 @@ describe('applySourcePolicy — allowlist matrix', () => { { 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.', + '2 discovered packages ship skills but are not listed in intent.skills: npm:@scope/b (provenance unknown), npm:@scope/c (provenance unknown). Add to opt in.', ]) }) @@ -104,7 +140,7 @@ describe('applySourcePolicy — allowlist matrix', () => { ) expect(names(result.packages)).toEqual([]) expect(result.notices).toEqual([ - '1 discovered package ships skills but is not listed in intent.skills: foo. Add to opt in.', + '1 discovered package ships skills but is not listed in intent.skills: npm:foo (provenance unknown). Add to opt in.', '"workspace:foo" is declared in intent.skills but was not discovered.', ]) }) @@ -118,6 +154,35 @@ describe('applySourcePolicy — allowlist matrix', () => { expect(result.notices).toEqual([]) }) + it('distinguishes hidden npm and workspace sources with the same name', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/listed', ['listed']), + pkg('foo', ['npm-skill'], 'npm'), + pkg('foo', ['workspace-skill'], 'workspace'), + ], + }, + { config: config(['@scope/listed']), excludeMatchers: [] }, + ) + + expect(result.hiddenSources).toEqual([ + { + kind: 'npm', + name: 'foo', + skillCount: 1, + }, + { + kind: 'workspace', + name: 'foo', + skillCount: 1, + }, + ]) + expect(result.notices).toEqual([ + '2 discovered packages ship skills but are not listed in intent.skills: npm:foo (provenance unknown), workspace:foo (provenance unknown). Add to opt in.', + ]) + }) + 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'])] }, @@ -125,7 +190,7 @@ describe('applySourcePolicy — allowlist matrix', () => { ) 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.', + '1 discovered package ships skills but is not listed in intent.skills: npm:@scope/dep (provenance unknown). Add to opt in.', ]) }) @@ -138,7 +203,7 @@ describe('applySourcePolicy — allowlist matrix', () => { }, ) expect(result.notices).toEqual([ - '1 discovered package ships skills but is not listed in intent.skills: @scope/unlisted. Add to opt in.', + '1 discovered package ships skills but is not listed in intent.skills: npm:@scope/unlisted (provenance unknown). Add to opt in.', '"@scope/missing" is declared in intent.skills but was not discovered.', ]) }) @@ -157,6 +222,20 @@ describe('applySourcePolicy — allowlist matrix', () => { }) describe('applySourcePolicy — permit-all and empty modes', () => { + it('excludes only the matching kind for a kind-qualified package pattern', () => { + const result = applySourcePolicy( + { + packages: [pkg('foo', ['x'], 'npm'), pkg('foo', ['y'], 'workspace')], + }, + { + config: config(['*']), + excludeMatchers: compileExcludePatterns(['workspace:foo']), + }, + ) + + expect(result.packages).toEqual([pkg('foo', ['x'], 'npm')]) + }) + it('unqualified exclude hides both an npm and a workspace package of the same name (kind-agnostic, deliberate)', () => { const result = applySourcePolicy( { @@ -194,6 +273,7 @@ describe('applySourcePolicy — permit-all and empty modes', () => { { config: config([]), excludeMatchers: [] }, ) expect(names(result.packages)).toEqual([]) + expect(result.hiddenSourceCount).toBe(1) expect(result.notices).toEqual([EMPTY_NOTE]) }) diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index fa266570..8aad4200 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -86,6 +86,7 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch + vi.unstubAllEnvs() if (existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }) } @@ -256,6 +257,26 @@ describe('checkStaleness', () => { expect(report.versionDrift).toBeNull() }) + it('makes no npm registry fetch in frozen mode', async () => { + vi.stubEnv('INTENT_FROZEN', '1') + writeSkill(tmpDir, 'core', { + name: 'core', + description: 'Core', + library_version: '1.0.0', + }) + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '2.0.0' }), + } as Response) + globalThis.fetch = fetchSpy + + const report = await checkStaleness(tmpDir, '@example/lib') + expect(fetchSpy).not.toHaveBeenCalled() + expect(report.currentVersion).toBeNull() + expect(report.versionDrift).toBeNull() + }) + it('flags new sources not present in sync-state', async () => { writeSkill(tmpDir, 'core', { name: 'core', diff --git a/tsconfig.json b/tsconfig.json index 9134737c..0eb1bca4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "ES2024"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, @@ -21,7 +21,7 @@ "resolveJsonModule": true, "skipLibCheck": true, "strict": true, - "target": "ES2020", + "target": "ES2024", "noErrorTruncation": true, "types": ["node"] },