diff --git a/.changeset/many-forks-cough.md b/.changeset/many-forks-cough.md new file mode 100644 index 0000000..4aa2b7d --- /dev/null +++ b/.changeset/many-forks-cough.md @@ -0,0 +1,14 @@ +--- +'@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 changes, 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. +- Add `intent skills generate-manifest` for package maintainers to create `skills/intent.manifest.json` files containing per-skill hashes, declared capabilities, secret names, and MCP metadata. +- 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. +- Add `intent skills stale` to surface lockfile drift and Git-baseline skill changes for review. +- Export lockfile and manifest metadata types from `@tanstack/intent`. diff --git a/benchmarks/intent/README.md b/benchmarks/intent/README.md new file mode 100644 index 0000000..cadc1a7 --- /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 011fd52..84754bb 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 0000000..777f533 --- /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 ada58a1..f619b49 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 0000000..f6c793f --- /dev/null +++ b/docs/cli/intent-skills.md @@ -0,0 +1,125 @@ +--- +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. Six subcommands: `scan`, `diff` (read-only), `approve`, `update` (mutating), `stale` (read-only, checks for skill drift needing re-review), and `generate-manifest` (maintainer-only — writes a package's own `skills/intent.manifest.json`, never touches `intent.lock`). + +```bash +npx @tanstack/intent@latest skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen] [--baseline ] [--files ] +``` + +See [Lockfile and frozen mode](../security/lockfile) for what `intent.lock` is and what frozen mode guarantees. + +## `intent skills scan` + +```bash +npx @tanstack/intent@latest 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`: prints a count and points at `intent.skills`/`intent.exclude` +- `--json` prints `{ frozen, hiddenSourceCount, hasLockfile, added, removed, changed, isClean }` + +## `intent skills diff` + +```bash +npx @tanstack/intent@latest skills diff [--json] [--frozen] [--no-frozen] +``` + +Read-only. Same underlying computation as `scan`, but change-focused: prints only `Added:`/`Removed:`/`Changed:` sections with per-field diffs (`version`, `resolution`, `skills`, `contentHash`, `manifestHash`, `capabilities`). 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 @tanstack/intent@latest skills approve [source] [--all] [--yes] +``` + +Writes `intent.lock`. This is the trust decision — approving means a human reviewed this exact change. + +- **No arg, no `--all`/`--yes`:** interactive per-pending-change prompt (approve/skip each). Fails if stdin isn't a TTY. +- **`--all` or `--yes`:** accepts every pending change (added, removed, changed) 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 @tanstack/intent@latest 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` after reviewing `intent skills diff`. + +- 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`). + +## `intent skills stale` + +```bash +npx @tanstack/intent@latest skills stale [--json] [--baseline ] [--files ] [--frozen] [--no-frozen] +``` + +Read-only. Surfaces staleness **candidates** for human/agent review — never a hard verdict on its own (a candidate means "worth checking," not "broken"). Two layers: + +- **Self-integrity + version** (Layer 0/1): reuses the same `intent.lock` diff as `scan`/`diff` — a source whose on-disk `contentHash` or `version` no longer matches the lock is reported as a candidate. +- **Baseline drift** (Layer 2): compares each locked source's tracked skill files against a git baseline via blob SHA, independent of the lockfile diff. Baseline resolution order: `--baseline ` flag, then `intent.lock`'s recorded `staleness.baseline`, then the nearest local git tag. No implicit `HEAD~1` — pass `--baseline HEAD~1` explicitly if that's what you want. +- `--files ` restricts Layer 2 to specific repo-relative paths (CI optimization: pass only the files a diff touched). +- No `intent.lock`: prints `No intent.lock found. Run \`intent skills approve --all\` to create one.` (frozen mode fails, exit `4`). +- No baseline resolvable: interactive mode reports `Layer 2 (baseline drift) skipped: ` and continues with Layer 0/1 only; frozen mode fails closed (exit `5`). +- Makes no network calls. Requires git for Layer 2 only — if `cwd` isn't a git repository, Layer 2 fails the same way as an unresolvable baseline. +- Frozen mode: fails (exit `1`) if any candidate — Layer 0/1 or Layer 2 — was found, so CI gates a PR that hasn't refreshed staleness. + +## `intent skills generate-manifest` + +```bash +npx @tanstack/intent@latest skills generate-manifest [--json] +``` + +Maintainer-only. Writes `skills/intent.manifest.json` inside each discovered package, never `intent.lock`. It refuses frozen mode. For each skill folder, computes a content hash over `SKILL.md` plus any `references/`, `assets/`, and `scripts/` files. The consumer lockfile aggregate uses the same directory scope. Static heuristics pre-fill `capabilities`: `uses_network` (curl/wget/fetch reference), `runs_install_command` (npm/pnpm/yarn/bun/pip install reference), `ships_scripts` (non-empty `scripts/` dir). Heuristics only ever suggest. Review and edit the generated file before committing. + +- **Hard-fails generation** (no partial manifest written) if any hash-included skill file contains what looks like a literal secret *value* (GitHub/Slack tokens, AWS access key IDs, a PEM private key block, a generic `key = "..."` assignment). A secret's *name* belongs in the manifest's `declaredSecrets`, never its value in skill content. +- Once a package ships a manifest, `intent skills scan`/`diff`/`approve` pick up its `manifestHash` and unioned `capabilities` automatically — no separate wiring needed on the consumer side. +- After a package changes manifest metadata, consumers run `intent skills diff` and review it with `intent skills approve`. Frozen scans reject the changed `manifestHash` until that approval updates `intent.lock`. + +## 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) +- `--baseline `: with `stale`, override the git ref used as the staleness baseline +- `--files `: with `stale`, restrict Layer 2 baseline drift checks to these repo-relative paths + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | ok | +| `1` | generic CLI usage/parse error, or (`stale` only) staleness candidates found under frozen mode | +| `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 — frozen mode disallows mutation; or (`stale` only) no staleness baseline resolvable under frozen mode | +| `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 60fbeaf..83574d8 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/config.json b/docs/config.json index 11e0909..b424bfa 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 0000000..8eeea29 --- /dev/null +++ b/docs/security/lockfile.md @@ -0,0 +1,87 @@ +--- +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. Approving a source means **a human reviewed this exact change** — never "Intent verified this skill is safe." + +## 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` (see [`intent skills generate-manifest`](../cli/intent-skills#intent-skills-generate-manifest)). 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. `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 a reserved, optional field read by [`intent skills stale`](../cli/intent-skills#intent-skills-stale) as one input to baseline resolution. Nothing currently writes it — when absent, `stale` falls back to the nearest local git tag. +- 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`/`stale` (read-only) and `approve`/`update` (mutating). `generate-manifest` is maintainer-only and never touches `intent.lock` — it writes a package's own `skills/intent.manifest.json`. + +## 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. `skills stale` may use the read-only Git adapter for baseline comparison. + +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 @tanstack/intent@latest 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 skill content changing without a human noticing, 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 8a4aabd..71991dc 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 ` | @@ -119,17 +121,25 @@ The real risk with any derived artifact is staleness. `npx @tanstack/intent@late ## CLI Commands -| Command | Description | -| -------------------------------------------------- | --------------------------------------------------- | -| `npx @tanstack/intent@latest install` | Set up skill loading guidance in agent config files | -| `npx @tanstack/intent@latest hooks install` | Install hook enforcement for supported agents | -| `npx @tanstack/intent@latest list [--json]` | Discover local intent-enabled packages | -| `npx @tanstack/intent@latest load ` | Load `#` SKILL.md content | -| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | -| `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt | -| `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 | +| Command | Description | +| ------------------------------------------------------ | --------------------------------------------------- | +| `npx @tanstack/intent@latest install` | Set up skill loading guidance in agent config files | +| `npx @tanstack/intent@latest hooks install` | Install hook enforcement for supported agents | +| `npx @tanstack/intent@latest list [--json]` | Discover local intent-enabled packages | +| `npx @tanstack/intent@latest load ` | Load `#` SKILL.md content | +| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | +| `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt | +| `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` | Show pending `intent.lock` changes | +| `npx @tanstack/intent@latest skills approve` | Approve pending changes into `intent.lock` | +| `npx @tanstack/intent@latest skills update` | Re-sync already-locked sources to installed state | +| `npx @tanstack/intent@latest skills stale` | Check locked sources for content/version drift | +| `npx @tanstack/intent@latest skills generate-manifest` | Write a package's `skills/intent.manifest.json` | + +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 3aad348..c3fafef 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 d317b9d..5c127b4 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -10,6 +10,11 @@ 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 { SkillsGenerateManifestCommandOptions } from './commands/skills/generate-manifest.js' +import type { SkillsStaleCommandOptions } from './commands/skills/stale.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 +213,131 @@ function createCli(): CAC { }, ) + cli + .command( + 'skills [action] [source]', + 'Scan, diff, approve, update, check staleness, or generate a manifest for skills', + ) + .usage( + 'skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen] [--baseline ] [--files ]', + ) + .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', + ) + .option( + '--baseline ', + 'With `stale`, override the git ref used as the staleness baseline', + ) + .option( + '--files ', + 'With `stale`, restrict Layer 2 baseline drift checks to these repo-relative paths', + ) + .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') + .example('skills stale') + .example('skills stale --baseline v1.42.0') + .example('skills generate-manifest') + .action( + async ( + action: string | undefined, + source: string | undefined, + options: SkillsScanCommandOptions & + SkillsApproveCommandOptions & + SkillsUpdateCommandOptions & + SkillsStaleCommandOptions & + SkillsGenerateManifestCommandOptions, + ) => { + 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 + } + + if (action === 'stale') { + const { runSkillsStaleCommand } = + await import('./commands/skills/stale.js') + await runSkillsStaleCommand( + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + if (action === 'generate-manifest') { + const { runSkillsGenerateManifestCommand } = + await import('./commands/skills/generate-manifest.js') + await runSkillsGenerateManifestCommand( + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + fail( + 'Unknown skills action: expected scan, diff, approve, update, stale, or generate-manifest.', + ) + }, + ) + cli .command( 'edit-package-json', diff --git a/packages/intent/src/commands/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts index 0a7e4f1..f559138 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 0fc45c5..e9b1064 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -101,8 +101,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.join(' -> ')) + .join('; ') console.log( - ` ${source.name} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})`, + ` ${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 0000000..e8e5241 --- /dev/null +++ b/packages/intent/src/commands/skills/approve.ts @@ -0,0 +1,223 @@ +import { createInterface } from 'node:readline/promises' +import { getIntentPackageVersion } from '../support.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 { + computeLockfileState, + 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.join(', ')}`) + } + if (source.declaredSecrets && source.declaredSecrets.length > 0) { + parts.push(`declaredSecrets: ${source.declaredSecrets.join(', ')}`) + } + if (source.mcpTools && source.mcpTools.length > 0) { + parts.push(`mcpTools: ${source.mcpTools.join(', ')}`) + } + return parts.length > 0 ? ` [${parts.join('; ')}]` : '' +} + +function describeChange(change: PendingChange): string { + const label = `${change.source.kind}:${change.source.id}@${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}: ${JSON.stringify(field.from)} -> ${JSON.stringify(field.to)}`, + ) + .join('; ') + return `Approve change to ${label}? (${fieldSummary})` + } + } +} + +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 toApply: 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.`, + ) + } + toApply = [match] + } else if (changes.length === 0) { + console.log('intent.lock is up to date. Nothing to approve.') + return + } else if (options.all || options.yes) { + toApply = changes + } 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 changes) { + 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 0000000..807b74e --- /dev/null +++ b/packages/intent/src/commands/skills/diff.ts @@ -0,0 +1,94 @@ +import { isFrozenMode } from '../../shared/mode.js' +import { buildSkillsDiff, enforceFrozenMode } from './support.js' +import type { + LockfileDiffResult, + LockfileSourceChange, +} from '../../core/lockfile/lockfile-diff.js' +import type { IntentLockfileSource } from '../../core/lockfile/lockfile.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}:${source.id}@${source.version}` +} + +function formatChangeLabel(change: LockfileSourceChange): string { + return `${change.kind}:${change.id}` +} + +function printDiffDetails( + diff: LockfileDiffResult, + hiddenSourceCount: number, +): void { + if (hiddenSourceCount > 0) { + console.log( + `${hiddenSourceCount} discovered skill-bearing source(s) are not listed in intent.skills. 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() + } + + 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}: ${JSON.stringify(field.from)} -> ${JSON.stringify(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 } = await scanPolicedIntents() + const diff = buildSkillsDiff(scan, cwd) + + if (options.json) { + console.log(JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2)) + } else { + printDiffDetails(diff, hiddenSourceCount) + } + + enforceFrozenMode(diff, frozen, hiddenSourceCount) +} diff --git a/packages/intent/src/commands/skills/generate-manifest.ts b/packages/intent/src/commands/skills/generate-manifest.ts new file mode 100644 index 0000000..0ae1043 --- /dev/null +++ b/packages/intent/src/commands/skills/generate-manifest.ts @@ -0,0 +1,110 @@ +import { realpathSync } from 'node:fs' +import { join } from 'node:path' +import { generateManifest, writeIntentManifest } from '../../core/manifest.js' +import { resolveProjectContext } from '../../core/project-context.js' +import { findWorkspacePackages } from '../../setup/workspace-patterns.js' +import { fail } from '../../shared/cli-error.js' +import { isFrozenMode } from '../../shared/mode.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsGenerateManifestCommandOptions { + frozen?: boolean + json?: boolean + noFrozen?: boolean +} + +interface GenerateManifestResult { + id: string + kind: 'npm' | 'workspace' + status: 'written' | 'failed' + path?: string + reason?: string +} + +export async function runSkillsGenerateManifestCommand( + options: SkillsGenerateManifestCommandOptions, + scanPolicedIntents: () => Promise, + cwd: string = process.cwd(), +): Promise { + if ( + isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + ) { + fail('`intent skills generate-manifest` cannot run in frozen mode.', 5) + } + + const { scan } = await scanPolicedIntents() + const context = resolveProjectContext({ cwd }) + const ownedRoots = new Set( + [ + context.packageRoot, + ...(context.workspaceRoot + ? findWorkspacePackages(context.workspaceRoot) + : []), + ] + .filter((root): root is string => root !== null) + .map((root) => realpathSync(root)), + ) + const unowned = scan.packages.filter( + (pkg) => !ownedRoots.has(realpathSync(pkg.packageRoot)), + ) + if (unowned.length > 0) { + fail( + '`intent skills generate-manifest` only writes the current package or workspace members. Run it from the package you maintain.', + ) + } + const results: Array = [] + + for (const pkg of scan.packages) { + const outcome = generateManifest( + pkg.packageRoot, + pkg.name, + pkg.version, + pkg.skills, + ) + + if (!outcome.ok) { + const reason = outcome.secretFindings + .map((finding) => `${finding.skillPath} (${finding.patternName})`) + .join(', ') + results.push({ + id: pkg.name, + kind: pkg.kind, + status: 'failed', + reason: `literal secret value(s) found: ${reason}`, + }) + continue + } + + const manifestPath = join(pkg.packageRoot, 'skills', 'intent.manifest.json') + writeIntentManifest(manifestPath, outcome.manifest) + results.push({ + id: pkg.name, + kind: pkg.kind, + status: 'written', + path: manifestPath, + }) + } + + if (options.json) { + console.log(JSON.stringify(results, null, 2)) + } else if (results.length === 0) { + console.log('No intent-enabled packages found.') + } else { + for (const result of results) { + if (result.status === 'written') { + console.log(`Wrote ${result.path}`) + } else { + console.log(`Failed for ${result.kind}:${result.id} — ${result.reason}`) + } + } + } + + if (results.some((result) => result.status === 'failed')) { + fail( + 'One or more packages failed manifest generation: skill content contains a literal secret value. Declare the secret by name in declaredSecrets, never its value.', + ) + } +} diff --git a/packages/intent/src/commands/skills/scan.ts b/packages/intent/src/commands/skills/scan.ts new file mode 100644 index 0000000..384aa9c --- /dev/null +++ b/packages/intent/src/commands/skills/scan.ts @@ -0,0 +1,61 @@ +import { isFrozenMode } from '../../shared/mode.js' +import { buildSkillsDiff, enforceFrozenMode } from './support.js' +import type { LockfileDiffResult } from '../../core/lockfile/lockfile-diff.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsScanCommandOptions { + json?: boolean + frozen?: boolean + noFrozen?: boolean +} + +function printScanSummary( + diff: LockfileDiffResult, + hiddenSourceCount: number, +): void { + if (hiddenSourceCount > 0) { + console.log( + `${hiddenSourceCount} discovered skill-bearing source(s) are not listed in intent.skills. 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 } = await scanPolicedIntents() + const diff = buildSkillsDiff(scan, cwd) + + if (options.json) { + console.log(JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2)) + } else { + printScanSummary(diff, hiddenSourceCount) + } + + enforceFrozenMode(diff, frozen, hiddenSourceCount) +} diff --git a/packages/intent/src/commands/skills/stale.ts b/packages/intent/src/commands/skills/stale.ts new file mode 100644 index 0000000..7aacbf9 --- /dev/null +++ b/packages/intent/src/commands/skills/stale.ts @@ -0,0 +1,182 @@ +import { isFrozenMode } from '../../shared/mode.js' +import { fail } from '../../shared/cli-error.js' +import { + computeBaselineDrift, + resolveBaseline, +} from '../../core/lockfile/baseline-drift.js' +import { computeLockfileState } from './support.js' +import type { BaselineDriftCandidate } from '../../core/lockfile/baseline-drift.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsStaleCommandOptions { + json?: boolean + baseline?: string + files?: Array + frozen?: boolean + noFrozen?: boolean +} + +interface SkillsStaleLayer01Candidate { + id: string + kind: 'npm' | 'workspace' + layer: 'self-integrity' | 'version' + from: unknown + to: unknown +} + +interface SkillsStaleReport { + frozen: boolean + baselineRef: string | null + layer01: Array + layer2: Array + layer2Skipped: string | null +} + +function layer01FromDiffChanged( + changed: ReturnType['diff']['changed'], +): Array { + const candidates: Array = [] + for (const entry of changed) { + for (const field of entry.fields) { + if (field.field === 'contentHash') { + candidates.push({ + id: entry.id, + kind: entry.kind, + layer: 'self-integrity', + from: field.from, + to: field.to, + }) + } + if (field.field === 'version') { + candidates.push({ + id: entry.id, + kind: entry.kind, + layer: 'version', + from: field.from, + to: field.to, + }) + } + } + } + return candidates +} + +function printReport(report: SkillsStaleReport): void { + if (report.layer01.length === 0 && report.layer2.length === 0) { + console.log('No staleness candidates found.') + } else { + for (const candidate of report.layer01) { + console.log( + `${candidate.kind}:${candidate.id} — ${candidate.layer} changed (${JSON.stringify(candidate.from)} -> ${JSON.stringify(candidate.to)})`, + ) + } + for (const candidate of report.layer2) { + console.log( + `${candidate.kind}:${candidate.id} — ${candidate.path} ${candidate.reason} (baseline ${report.baselineRef})`, + ) + } + } + + if (report.layer2Skipped) { + console.log(`Layer 2 (baseline drift) skipped: ${report.layer2Skipped}`) + } +} + +export async function runSkillsStaleCommand( + options: SkillsStaleCommandOptions, + scanPolicedIntents: () => Promise, + cwd: string = process.cwd(), +): Promise { + const frozen = isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + + const { scan, hiddenSourceCount } = await scanPolicedIntents() + if (frozen && hiddenSourceCount > 0) { + fail( + `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.`, + 3, + ) + } + const state = computeLockfileState(scan, cwd) + + if (state.lockedResult.status !== 'found') { + if (frozen) { + fail( + 'Frozen mode requires intent.lock. Run `intent skills approve --all` outside frozen mode first.', + 4, + ) + } + console.log( + 'No intent.lock found. Run `intent skills approve --all` to create one.', + ) + return + } + + const lockfile = state.lockedResult.lockfile + const layer01 = layer01FromDiffChanged(state.diff.changed) + + const baselineOutcome = resolveBaseline(cwd, options.baseline, lockfile) + + let layer2: Array = [] + let layer2Skipped: string | null = null + let baselineRef: string | null = null + + if (!baselineOutcome.ok) { + if (frozen) { + fail( + `Frozen mode requires a resolvable staleness baseline: ${baselineOutcome.reason}`, + 5, + ) + } + layer2Skipped = baselineOutcome.reason + } else { + baselineRef = baselineOutcome.baseline.ref + const packageRoots = new Map( + scan.packages.map((pkg) => [`${pkg.kind}:${pkg.name}`, pkg.packageRoot]), + ) + const fileFilter = options.files ? new Set(options.files) : undefined + + const driftOutcome = computeBaselineDrift( + cwd, + baselineOutcome.baseline, + lockfile.sources, + packageRoots, + fileFilter, + ) + + if (!driftOutcome.ok) { + if (frozen) { + fail( + `Frozen mode: baseline drift check failed: ${driftOutcome.reason}`, + 5, + ) + } + layer2Skipped = driftOutcome.reason + } else { + layer2 = driftOutcome.candidates + } + } + + const report: SkillsStaleReport = { + frozen, + baselineRef, + layer01, + layer2, + layer2Skipped, + } + + if (options.json) { + console.log(JSON.stringify(report, null, 2)) + } else { + printReport(report) + } + + if (frozen && (layer01.length > 0 || layer2.length > 0)) { + fail( + 'Frozen mode: staleness candidates found. Refresh and re-approve outside frozen mode before merging.', + 1, + ) + } +} diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts new file mode 100644 index 0000000..f3e4398 --- /dev/null +++ b/packages/intent/src/commands/skills/support.ts @@ -0,0 +1,130 @@ +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 type { LockfileDiffResult } from '../../core/lockfile/lockfile-diff.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../../core/lockfile/lockfile.js' +import type { 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 enforceFrozenMode( + diff: LockfileDiffResult, + frozen: boolean, + hiddenSourceCount: number, +): void { + if (!frozen) return + + if (hiddenSourceCount > 0) { + fail( + `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.`, + 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 0000000..d5a7597 --- /dev/null +++ b/packages/intent/src/commands/skills/update.ts @@ -0,0 +1,175 @@ +import { getIntentPackageVersion } from '../support.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 { + computeLockfileState, + 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}:${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}: ${JSON.stringify(field.from)} -> ${JSON.stringify(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 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 d6ea381..f6aec40 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 05ddfba..51c2507 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/git-adapter.ts b/packages/intent/src/core/git-adapter.ts new file mode 100644 index 0000000..0471b86 --- /dev/null +++ b/packages/intent/src/core/git-adapter.ts @@ -0,0 +1,183 @@ +// Read-only Git adapter for staleness Layer 2 (baseline drift detection). +// +// "Read-only git" is not safe by subcommand alone — git has flags that +// execute external programs even during a read (pagers, textconv/filter +// drivers, inline `-c` config, `--exec-path`). This adapter constrains the +// entire argv per subcommand, never just the subcommand name, and never +// shells out through a string command line. +import { execFileSync } from 'node:child_process' +import { statSync } from 'node:fs' +import { resolve } from 'node:path' + +interface GitAdapterResult { + ok: true + value: T +} + +interface GitAdapterFailure { + ok: false + reason: string +} + +export type GitAdapterOutcome = GitAdapterResult | GitAdapterFailure + +function ok(value: T): GitAdapterOutcome { + return { ok: true, value } +} + +function fail(reason: string): GitAdapterOutcome { + return { ok: false, reason } +} + +// Forbidden regardless of subcommand: these flags can execute external +// programs or reroute config even on an otherwise-read-only invocation. +const FORBIDDEN_FLAG_PREFIXES = [ + '-c', + '-C', + '--exec-path', + '--textconv', + '--filters', +] + +const GIT_MAX_OUTPUT_BYTES = 1024 * 1024 +const GIT_TIMEOUT_MS = 10_000 + +function assertNoForbiddenFlags(args: ReadonlyArray): void { + for (const arg of args) { + for (const forbidden of FORBIDDEN_FLAG_PREFIXES) { + if (arg === forbidden || arg.startsWith(`${forbidden}=`)) { + throw new Error( + `git-adapter: refusing to run with forbidden flag "${arg}".`, + ) + } + } + } +} + +// Hardened, fixed leading environment: strips ambient config influence so +// the adapter's behavior doesn't depend on the invoking user's global git +// config, system git config, or a pager. +function hardenedEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_PAGER: 'cat', + GIT_TERMINAL_PROMPT: '0', + } +} + +// Runs one allowlisted read-only git invocation. `args` must already be a +// fully-formed argv for one of the allowlisted subcommands below — this +// function does not itself decide which subcommands are safe, it only +// enforces the flag blocklist and hardened env universally. +function runGit( + cwd: string, + args: ReadonlyArray, +): GitAdapterOutcome { + assertNoForbiddenFlags(args) + + try { + const stdout = execFileSync('git', args, { + cwd, + env: hardenedEnv(), + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + maxBuffer: GIT_MAX_OUTPUT_BYTES, + timeout: GIT_TIMEOUT_MS, + }) + return ok(stdout) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return fail(message) + } +} + +// Resolves the working tree root of the repository containing `cwd`, or a +// failure if `cwd` is not inside a git repository. +export function repoRoot(cwd: string): GitAdapterOutcome { + const result = runGit(cwd, ['rev-parse', '--show-toplevel']) + if (!result.ok) return result + return ok(result.value.trim()) +} + +// Resolves `ref` to a full commit SHA, or a failure if the ref does not +// resolve to a commit (including: not a git repo, ref does not exist). +// Uses `--end-of-options` rather than `--`: in `rev-parse --verify` mode, +// `--` has pathspec-disambiguation semantics that reject a bare rev, while +// `--end-of-options` still guarantees the ref value can't be parsed as a flag. +export function resolveCommit( + cwd: string, + ref: string, +): GitAdapterOutcome { + const result = runGit(cwd, [ + 'rev-parse', + '--verify', + '--end-of-options', + `${ref}^{commit}`, + ]) + if (!result.ok) return result + return ok(result.value.trim()) +} + +// Returns the most recent local tag reachable from HEAD, or a failure if +// there is no reachable tag (a distinct outcome from "not a repo"). +export function nearestReachableTag(cwd: string): GitAdapterOutcome { + const result = runGit(cwd, ['describe', '--tags', '--abbrev=0']) + if (!result.ok) return result + const tag = result.value.trim() + if (tag.length === 0) { + return fail('git-adapter: no reachable tag found.') + } + return ok(tag) +} + +// Returns the git blob SHA for `relPath` as it existed at `commit`, or null +// if the path did not exist in that commit's tree (not an error — the file +// may simply be new since the baseline). +export function blobShaAtCommit( + cwd: string, + commit: string, + relPath: string, +): GitAdapterOutcome { + const result = runGit(cwd, ['ls-tree', commit, '--', relPath]) + if (!result.ok) return result + + const line = result.value.trim() + if (line.length === 0) return ok(null) + + // Format: " \t" + const match = /^\d+\s+\w+\s+([0-9a-f]+)\t/.exec(line) + if (!match) { + return fail(`git-adapter: unrecognized ls-tree output for "${relPath}".`) + } + return ok(match[1] ?? null) +} + +// Computes the git blob SHA git would assign to the current working-tree +// contents of `relPath`, without writing anything to the object database +// (no `-w`). Returns null if the file does not exist on disk. +export function currentBlobSha( + cwd: string, + relPath: string, +): GitAdapterOutcome { + try { + if (!statSync(resolve(cwd, relPath)).isFile()) { + return fail(`git-adapter: "${relPath}" is not a regular file.`) + } + } catch (err) { + if ( + err instanceof Error && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return ok(null) + } + return fail( + `git-adapter: failed to inspect "${relPath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const result = runGit(cwd, ['hash-object', '--', relPath]) + if (!result.ok) return result + return ok(result.value.trim()) +} diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index d24f7a9..a513ee4 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,12 @@ 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 { + IntentPackage, + ScanOptions, + ScanResult, + ScanScope, +} from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, @@ -261,6 +269,48 @@ function createLoadedSkillDebug({ } } +function assertFrozenLoadLockfile( + cwd: string, + scan: ScanResult, + hiddenSourceCount: number, +): void { + if (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.`, + ) + } + + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? cwd + const lockedResult = readIntentLockfile(join(root, 'intent.lock')) + if (lockedResult.status === 'missing') { + throw new IntentCoreError( + 'package-not-listed', + 'Frozen mode requires intent.lock. Run `intent skills approve --all` outside frozen mode first.', + ) + } + + 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 +344,17 @@ function resolveIntentSkillInCwd( const scanOptions = toScanOptions(options) const scope = getScanScope(scanOptions) - const fastPathResolved = resolveSkillUseFastPath( - parsedUse, - options, - projectContext, - cwd, - fsCache, - ) + const frozen = isFrozenMode() + const fastPathResolved = frozen + ? 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 +375,11 @@ function resolveIntentSkillInCwd( ) } - const { scan: scanResult, droppedNames } = scanForPolicedIntents({ + const { + scan: scanResult, + droppedNames, + hiddenSourceCount, + } = scanForPolicedIntents({ cwd, scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, @@ -348,6 +401,10 @@ function resolveIntentSkillInCwd( throw err } + if (frozen) { + assertFrozenLoadLockfile(cwd, scanResult, hiddenSourceCount) + } + return toResolvedIntentSkill( cwd, use, diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index b69dfc6..8c1e0e9 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/baseline-drift.ts b/packages/intent/src/core/lockfile/baseline-drift.ts new file mode 100644 index 0000000..5427a8d --- /dev/null +++ b/packages/intent/src/core/lockfile/baseline-drift.ts @@ -0,0 +1,219 @@ +// Staleness Layer 2 — git blob SHA drift against the baseline recorded in +// intent.lock (or an explicit override). Pure candidate detection: a source +// touched since baseline is fed back for human/agent impact classification, +// never a hard "stale" verdict on its own — staleness is a signal, not a gate. +import { isAbsolute, relative } from 'node:path' +import { realpathSync } from 'node:fs' +import { + blobShaAtCommit, + currentBlobSha, + nearestReachableTag, + repoRoot, + resolveCommit, +} from '../git-adapter.js' +import { + assertCanonicalPackageRelativePath, + resolveCanonicalPackagePath, +} from '../skill-path.js' +import type { IntentLockfile, IntentLockfileSource } from './lockfile.js' + +export interface BaselineResolution { + ref: string + commit: string +} + +export type BaselineResolutionOutcome = + | { ok: true; baseline: BaselineResolution } + | { ok: false; reason: string } + +// Resolution order: explicit --baseline flag, then the lockfile's recorded +// baseline, then the nearest reachable local tag. No implicit HEAD~1 — +// callers who want that must pass --baseline HEAD~1 explicitly. +export function resolveBaseline( + cwd: string, + explicitRef: string | undefined, + lockfile: IntentLockfile | null, +): BaselineResolutionOutcome { + const candidateRef = explicitRef ?? lockfile?.staleness?.baseline.ref + + if (candidateRef) { + const commit = resolveCommit(cwd, candidateRef) + if (!commit.ok) { + return { + ok: false, + reason: `baseline ref "${candidateRef}" could not be resolved: ${commit.reason}`, + } + } + return { ok: true, baseline: { ref: candidateRef, commit: commit.value } } + } + + const tag = nearestReachableTag(cwd) + if (!tag.ok) { + return { + ok: false, + reason: + 'no local reachable tag found and no baseline is recorded in intent.lock.', + } + } + const commit = resolveCommit(cwd, tag.value) + if (!commit.ok) { + return { + ok: false, + reason: `nearest tag "${tag.value}" could not be resolved to a commit: ${commit.reason}`, + } + } + return { ok: true, baseline: { ref: tag.value, commit: commit.value } } +} + +export interface BaselineDriftCandidate { + id: string + kind: 'npm' | 'workspace' + path: string + reason: 'added-since-baseline' | 'changed-since-baseline' +} + +export interface BaselineDriftOutcome { + ok: true + candidates: Array +} + +export interface BaselineDriftFailure { + ok: false + reason: string +} + +function isWithinDir(candidate: string, dir: string): boolean { + const rel = relative(dir, candidate) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} + +// Compares each source's tracked skill files (already package-relative in +// the lockfile) against the baseline commit's tree. `packageRoots` maps a +// source identity key (`kind:id`) to its on-disk package root, so +// package-relative lockfile paths can be resolved to repo-relative paths +// git understands. +export function computeBaselineDrift( + cwd: string, + baseline: BaselineResolution, + sources: ReadonlyArray, + packageRoots: ReadonlyMap, + fileFilter?: ReadonlySet, +): BaselineDriftOutcome | BaselineDriftFailure { + try { + for (const source of sources) { + for (const skillPath of source.skills) { + assertCanonicalPackageRelativePath(skillPath, 'source.skills path') + } + } + } catch (err) { + return { + ok: false, + reason: err instanceof Error ? err.message : String(err), + } + } + + const root = repoRoot(cwd) + if (!root.ok) { + return { ok: false, reason: `not a git repository: ${root.reason}` } + } + // realpath: git rev-parse --show-toplevel resolves symlinks (e.g. macOS + // /tmp -> /private/tmp), but packageRoots may carry the unresolved path. + // realpath the package root only (a directory, always exists) rather than + // each skill file, since a file removed since baseline may not exist now. + const realRoot = realpathSync(root.value) + + const candidates: Array = [] + + for (const source of sources) { + const packageRoot = packageRoots.get(`${source.kind}:${source.id}`) + if (!packageRoot) continue + + const realPackageRoot = realpathSync(packageRoot) + if (!isWithinDir(realPackageRoot, realRoot)) continue + + for (const skillPath of source.skills) { + let resolvedSkillPath: string + try { + resolvedSkillPath = resolveCanonicalPackagePath( + realPackageRoot, + skillPath, + 'source.skills path', + ) + } catch (err) { + return { + ok: false, + reason: err instanceof Error ? err.message : String(err), + } + } + let gitPath = resolvedSkillPath + try { + const realSkillPath = realpathSync(resolvedSkillPath) + if (!isWithinDir(realSkillPath, realPackageRoot)) { + return { + ok: false, + reason: `source.skills path escapes the package root via a symlink: "${skillPath}".`, + } + } + gitPath = realSkillPath + } catch (err) { + if ( + !(err instanceof Error) || + (err as NodeJS.ErrnoException).code !== 'ENOENT' + ) { + return { + ok: false, + reason: `failed to resolve source.skills path "${skillPath}": ${err instanceof Error ? err.message : String(err)}`, + } + } + } + const repoRelativePath = relative(realRoot, gitPath) + + if (fileFilter && !fileFilter.has(repoRelativePath)) continue + + const baselineSha = blobShaAtCommit( + cwd, + baseline.commit, + repoRelativePath, + ) + if (!baselineSha.ok) { + return { + ok: false, + reason: `failed to read baseline blob for "${repoRelativePath}": ${baselineSha.reason}`, + } + } + + const current = currentBlobSha(cwd, repoRelativePath) + if (!current.ok) { + return { + ok: false, + reason: `failed to read current blob for "${repoRelativePath}": ${current.reason}`, + } + } + + if (baselineSha.value === null && current.value !== null) { + candidates.push({ + id: source.id, + kind: source.kind, + path: skillPath, + reason: 'added-since-baseline', + }) + continue + } + + if ( + baselineSha.value !== null && + current.value !== null && + baselineSha.value !== current.value + ) { + candidates.push({ + id: source.id, + kind: source.kind, + path: skillPath, + reason: 'changed-since-baseline', + }) + } + } + } + + return { ok: true, candidates } +} diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts new file mode 100644 index 0000000..8b069c5 --- /dev/null +++ b/packages/intent/src/core/lockfile/hash.ts @@ -0,0 +1,476 @@ +import { createHash } from 'node:crypto' +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 +} + +export 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 +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +// Full-buffer scan, not a fixed prefix: a partial scan can miss a NUL byte +// in a large binary asset, letting normalizeLineEndings corrupt real bytes. +function isBinaryContent(content: Buffer): boolean { + return content.indexOf(0) !== -1 +} + +// '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}".`, + ) + } + return { + content: isBinaryContent(raw) ? raw : normalizeLineEndings(raw), + bytesRead: raw.byteLength, + } +} + +function readHashEntries( + entries: ReadonlyArray, + fs: ReadFs, + realPackageRoot: string, +): Array<{ key: string; value: Buffer }> { + let totalBytes = 0 + return entries.map((entry) => { + const { content, bytesRead } = 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 } + }) +} + +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: hashEntries(hashed), + } +} + +function toPosixRelative(baseDir: string, absolutePath: string): string { + const rel = relative(baseDir, absolutePath) + return rel.split('\\').join('/') +} + +// Manifest per-skill hash scope: the whole skill folder (SKILL.md plus any +// references/, assets/, scripts/), unlike the lockfile's per-package +// aggregate which is SKILL.md-only. Same canonical hashing rules (LF text +// normalization, byte-exact binary), different scope. +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, + })), + ) +} + +export 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 0000000..a2aa32f --- /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 0000000..80c52fe --- /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 0000000..bdb4b29 --- /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 0000000..30b17a3 --- /dev/null +++ b/packages/intent/src/core/manifest.ts @@ -0,0 +1,496 @@ +// 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 { existsSync, readdirSync, writeFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { createHash } from 'node:crypto' +import { nodeReadFs } from '../shared/utils.js' +import { + computeSkillFolderHash, + readSkillFolderContents, +} from './lockfile/hash.js' +import { detectCapabilityHeuristics, findSecretMatches } from './secrets.js' +import { assertCanonicalPackageRelativePath } from './skill-path.js' +import type { SkillEntry } from '../shared/types.js' +import type { ReadFs } from '../shared/utils.js' + +const MANIFEST_VERSION = 1 +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', +]) + +export interface IntentManifestSkill { + name: string + path: string + contentHash: string + capabilities: Array + declaredSecrets: Array + mcpTools: Array +} + +export 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('/') +} + +function hasNonEmptyScriptsDir(skillDir: string): boolean { + const scriptsDir = join(skillDir, 'scripts') + if (!existsSync(scriptsDir)) return false + try { + return readdirSync(scriptsDir).length > 0 + } catch { + return false + } +} + +interface SecretFinding { + skillPath: string + patternName: string +} + +export type GenerateManifestOutcome = + | { ok: true; manifest: IntentManifest } + | { ok: false; secretFindings: Array } + +// Walks each skill's own folder, computes its content hash, and runs static +// heuristics to pre-fill capabilities. The maintainer reviews and edits the +// resulting file before committing — heuristics inform, they don't decide. +// Hard-fails (no partial manifest) if any hash-included file contains a +// literal secret value; a declared secret NAME belongs in declaredSecrets, +// never a value in skill content. +export function generateManifest( + packageRoot: string, + packageName: string, + packageVersion: string, + skills: ReadonlyArray, +): GenerateManifestOutcome { + const secretFindings: Array = [] + const manifestSkills: Array = [] + + for (const skill of skills) { + const skillDir = dirname(skill.path) + const relativePath = toPosixPath(relative(packageRoot, skill.path)) + const folderContents = readSkillFolderContents(skillDir, packageRoot) + const skillContent = folderContents.find( + (entry) => entry.relativePath === 'SKILL.md', + ) + if (!skillContent) { + throw new Error(`Missing SKILL.md in "${relativePath}".`) + } + + let hasSecret = false + for (const entry of folderContents) { + const matches = findSecretMatches(entry.content.toString('utf8')) + if (matches.length > 0) { + hasSecret = true + const entryPath = toPosixPath( + relative(packageRoot, join(skillDir, entry.relativePath)), + ) + for (const match of matches) { + secretFindings.push({ + skillPath: entryPath, + patternName: match.name, + }) + } + } + } + if (hasSecret) { + continue + } + + const heuristics = detectCapabilityHeuristics( + skillContent.content.toString('utf8'), + ) + const capabilities: Array = [] + if (heuristics.usesNetwork) capabilities.push('uses_network') + if (heuristics.runsInstallCommand) capabilities.push('runs_install_command') + if (hasNonEmptyScriptsDir(skillDir)) capabilities.push('ships_scripts') + + manifestSkills.push({ + name: skill.name, + path: relativePath, + contentHash: computeSkillFolderHash(skillDir, packageRoot), + capabilities: capabilities.toSorted(compareStrings), + declaredSecrets: [], + mcpTools: [], + }) + } + + if (secretFindings.length > 0) { + return { ok: false, secretFindings } + } + + return { + ok: true, + manifest: { + manifestVersion: MANIFEST_VERSION, + package: packageName, + packageVersion, + skills: manifestSkills.toSorted((a, b) => compareStrings(a.path, b.path)), + }, + } +} + +// Deterministic: stable entry order (by path, already sorted by +// generateManifest) and stable key order, no generated timestamps — a +// manifest regenerated from unchanged inputs serializes byte-identical. +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/secrets.ts b/packages/intent/src/core/secrets.ts new file mode 100644 index 0000000..a10d797 --- /dev/null +++ b/packages/intent/src/core/secrets.ts @@ -0,0 +1,76 @@ +// Shared literal-secret detection, used by the manifest generator (this +// file) and, in future, the validator and security doctor. Static and +// regex-based: it can only catch obvious literal values, never encoded or +// indirect ones — its job is defense-in-depth, not a security boundary. +// +// A maintainer may declare a secret's NAME (e.g. GITHUB_TOKEN) but must +// never embed its VALUE in skill content. These patterns look for the +// shape of common literal secret values. +const SECRET_PATTERNS: ReadonlyArray<{ + name: string + pattern: RegExp +}> = [ + { name: 'github-token', pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ }, + { name: 'aws-access-key-id', pattern: /\bAKIA[0-9A-Z]{16}\b/ }, + { + name: 'generic-api-key-assignment', + pattern: + /\b(?:api[_-]?key|secret|token|password)\b\s*[:=]\s*["'][A-Za-z0-9_\-.]{16,}["']/i, + }, + { + name: 'private-key-block', + pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, + }, + { + name: 'slack-token', + pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, + }, +] + +export interface SecretMatch { + name: string + index: number +} + +// Returns every distinct pattern name that matches somewhere in `content`. +// Does not return the matched value itself — callers report the finding by +// name/location, never the literal secret text, even in error output. +export function findSecretMatches(content: string): Array { + const matches: Array = [] + for (const { name, pattern } of SECRET_PATTERNS) { + const match = pattern.exec(content) + if (match) { + matches.push({ name, index: match.index }) + } + } + return matches +} + +export function containsSecretLiteral(content: string): boolean { + return SECRET_PATTERNS.some(({ pattern }) => pattern.test(content)) +} + +// Heuristic capability signals (M3's static-heuristics pass). These only +// ever *suggest* a capability for the maintainer to confirm — never +// auto-declare it as final, and disagreement with a maintainer's declared +// capabilities is a warning, never a hard error (see manifest.ts). +const NETWORK_PATTERN = /\b(?:curl|wget|fetch\s*\()\b/i +const INSTALL_COMMAND_PATTERN = + /\b(?:npm|pnpm|yarn|bun|pip)\s+(?:i|install|add)\b/i +const SUBPROCESS_PATTERN = /\b(?:child_process|spawn|exec[FS]?\w*)\s*\(/ + +export interface CapabilityHeuristics { + usesNetwork: boolean + runsInstallCommand: boolean + shellsOut: boolean +} + +export function detectCapabilityHeuristics( + content: string, +): CapabilityHeuristics { + return { + usesNetwork: NETWORK_PATTERN.test(content), + runsInstallCommand: INSTALL_COMMAND_PATTERN.test(content), + shellsOut: SUBPROCESS_PATTERN.test(content), + } +} diff --git a/packages/intent/src/core/skill-path.ts b/packages/intent/src/core/skill-path.ts new file mode 100644 index 0000000..f02359c --- /dev/null +++ b/packages/intent/src/core/skill-path.ts @@ -0,0 +1,62 @@ +import { isAbsolute, relative, resolve, 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) + } +} + +export function resolveCanonicalPackagePath( + packageRoot: string, + path: string, + label: string, +): string { + assertCanonicalPackageRelativePath(path, label) + const resolvedPath = resolve(packageRoot, path) + const packageRelativePath = relative(packageRoot, resolvedPath) + if (packageRelativePath.startsWith('..') || isAbsolute(packageRelativePath)) { + throw new Error( + `Invalid ${label}: path escapes the package root, got "${path}".`, + ) + } + return resolvedPath +} diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index e9525f8..3f67966 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -48,7 +48,7 @@ export interface LoadRefusal { message: string } -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,7 @@ function formatUnlistedNotice( hiddenSources: Array, audience: IntentAudience, ): string { - const sorted = [...hiddenSources].sort((a, b) => a.name.localeCompare(b.name)) + const sorted = hiddenSources.toSorted((a, b) => a.name.localeCompare(b.name)) const sourceCount = sorted.length const skillCount = sorted.reduce((sum, source) => sum + source.skillCount, 0) @@ -129,7 +130,17 @@ 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.join(' -> ')) + .join('; ') + return provenance + ? `${source.name} (via ${provenance})` + : `${source.name} (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 +169,29 @@ 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({ + 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 +279,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 cf89703..31ea529 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -20,6 +20,7 @@ export type IntentAudience = 'agent' | 'human' export interface IntentHiddenSourceSummary { name: string skillCount: number + provenance?: Array> } export interface IntentSkillSummary { @@ -117,6 +118,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 90516a0..a93ee47 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 622113c..6e5993a 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 ac233cf..bf4b73a 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 dc71567..448136e 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -48,3 +48,19 @@ 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' +export type { + IntentManifest, + IntentManifestCapability, + IntentManifestMcpTool, + IntentManifestSkill, +} from './core/manifest.js' diff --git a/packages/intent/src/shared/cli-output.ts b/packages/intent/src/shared/cli-output.ts index f281d88..6c0d608 100644 --- a/packages/intent/src/shared/cli-output.ts +++ b/packages/intent/src/shared/cli-output.ts @@ -1,3 +1,5 @@ +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 = @@ -16,11 +18,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 0000000..f89b2ea --- /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 0000000..0ea748d --- /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 994ed53..e4f0312 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 f3d6930..10405bc 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 cc23672..69157bc 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 93f0142..8f3ea65 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 561b0b3..9d58a9d 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/baseline-drift.test.ts b/packages/intent/tests/baseline-drift.test.ts new file mode 100644 index 0000000..dfa62d2 --- /dev/null +++ b/packages/intent/tests/baseline-drift.test.ts @@ -0,0 +1,378 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + computeBaselineDrift, + resolveBaseline, +} from '../src/core/lockfile/baseline-drift.js' +import type { + IntentLockfile, + IntentLockfileSource, +} from '../src/core/lockfile/lockfile.js' + +let repoDir: string +const externalDirs: Array = [] + +function git(args: Array): string { + return execFileSync('git', args, { cwd: repoDir, encoding: 'utf8' }) +} + +function baseLockfile(overrides: Partial = {}): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: [], + policy: { ignores: [] }, + ...overrides, + } +} + +function source( + overrides: Partial, +): IntentLockfileSource { + return { + id: '@acme/pkg', + kind: 'npm', + version: '1.0.0', + resolution: null, + skills: ['skills/core/SKILL.md'], + contentHash: 'sha256-x', + manifestHash: null, + capabilities: null, + ...overrides, + } +} + +beforeEach(() => { + repoDir = mkdtempSync(join(tmpdir(), 'baseline-drift-test-')) + git(['init', '--quiet']) + git(['config', 'user.email', 'test@example.com']) + git(['config', 'user.name', 'Test']) +}) + +afterEach(() => { + rmSync(repoDir, { recursive: true, force: true }) + for (const dir of externalDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('resolveBaseline', () => { + it('prefers the explicit ref over the lockfile baseline', () => { + mkdirSync(join(repoDir, 'pkg'), { recursive: true }) + writeFileSync(join(repoDir, 'pkg', 'a.txt'), 'one') + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + writeFileSync(join(repoDir, 'pkg', 'a.txt'), 'two') + git(['commit', '--quiet', '-am', 'second']) + git(['tag', 'v2.0.0']) + + const lockfile = baseLockfile({ + staleness: { + baseline: { kind: 'tag', ref: 'v2.0.0', commit: 'ignored' }, + }, + }) + + const result = resolveBaseline(repoDir, 'v1.0.0', lockfile) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.baseline.ref).toBe('v1.0.0') + } + }) + + it('falls back to the lockfile-recorded baseline when no explicit ref is given', () => { + writeFileSync(join(repoDir, 'a.txt'), 'one') + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + const lockfile = baseLockfile({ + staleness: { + baseline: { kind: 'tag', ref: 'v1.0.0', commit: 'ignored' }, + }, + }) + + const result = resolveBaseline(repoDir, undefined, lockfile) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.baseline.ref).toBe('v1.0.0') + } + }) + + it('falls back to the nearest reachable tag with no explicit ref or lockfile baseline', () => { + writeFileSync(join(repoDir, 'a.txt'), 'one') + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v3.0.0']) + + const result = resolveBaseline(repoDir, undefined, baseLockfile()) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.baseline.ref).toBe('v3.0.0') + } + }) + + it('fails when nothing resolves', () => { + writeFileSync(join(repoDir, 'a.txt'), 'one') + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + + const result = resolveBaseline(repoDir, undefined, baseLockfile()) + expect(result.ok).toBe(false) + }) +}) + +describe('computeBaselineDrift', () => { + it('skips installed dependency paths outside the consumer repository', () => { + const installedRoot = mkdtempSync( + join(tmpdir(), 'installed-skill-package-'), + ) + externalDirs.push(installedRoot) + const skillDir = join(installedRoot, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, 'SKILL.md'), 'installed dependency content') + git(['commit', '--allow-empty', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + [source({ skills: ['skills/core/SKILL.md'] })], + new Map([['npm:@acme/pkg', installedRoot]]), + ) + + expect(result).toEqual({ ok: true, candidates: [] }) + }) + + it('does not report an unchanged in-bounds symlink as drift', () => { + const skillDir = join(repoDir, 'pkg', 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, 'content.md'), 'linked content') + symlinkSync('content.md', join(skillDir, 'SKILL.md')) + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + [source({ skills: ['skills/core/SKILL.md'] })], + new Map([['npm:@acme/pkg', join(repoDir, 'pkg')]]), + ) + + expect(result).toEqual({ ok: true, candidates: [] }) + }) + + it('fails before Git access when a tracked skill path escapes its package root', () => { + mkdirSync(join(repoDir, 'pkg'), { recursive: true }) + writeFileSync(join(repoDir, 'outside.md'), 'outside package') + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + [source({ skills: ['../outside.md'] })], + new Map([['npm:@acme/pkg', join(repoDir, 'pkg')]]), + ) + + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('source.skills path'), + }) + }) + + it('fails before Git access when a tracked skill symlink escapes its package root', () => { + const skillDir = join(repoDir, 'pkg', 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(repoDir, 'outside.md'), 'outside package') + symlinkSync('../../../outside.md', join(skillDir, 'SKILL.md')) + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + [source({ skills: ['skills/core/SKILL.md'] })], + new Map([['npm:@acme/pkg', join(repoDir, 'pkg')]]), + ) + + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('escapes the package root'), + }) + }) + + it('reports no candidates when nothing changed since baseline', () => { + mkdirSync(join(repoDir, 'pkg', 'skills', 'core'), { recursive: true }) + writeFileSync(join(repoDir, 'pkg', 'skills', 'core', 'SKILL.md'), 'content') + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const sources = [source({ skills: ['skills/core/SKILL.md'] })] + const packageRoots = new Map([['npm:@acme/pkg', join(repoDir, 'pkg')]]) + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + sources, + packageRoots, + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.candidates).toEqual([]) + } + }) + + it('reports a changed-since-baseline candidate when the file was edited', () => { + mkdirSync(join(repoDir, 'pkg', 'skills', 'core'), { recursive: true }) + writeFileSync( + join(repoDir, 'pkg', 'skills', 'core', 'SKILL.md'), + 'original', + ) + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + writeFileSync( + join(repoDir, 'pkg', 'skills', 'core', 'SKILL.md'), + 'edited after baseline', + ) + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const sources = [source({ skills: ['skills/core/SKILL.md'] })] + const packageRoots = new Map([['npm:@acme/pkg', join(repoDir, 'pkg')]]) + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + sources, + packageRoots, + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.candidates).toEqual([ + { + id: '@acme/pkg', + kind: 'npm', + path: 'skills/core/SKILL.md', + reason: 'changed-since-baseline', + }, + ]) + } + }) + + it('reports an added-since-baseline candidate for a new skill file', () => { + mkdirSync(join(repoDir, 'pkg', 'skills', 'core'), { recursive: true }) + writeFileSync( + join(repoDir, 'pkg', 'skills', 'core', 'SKILL.md'), + 'original', + ) + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + mkdirSync(join(repoDir, 'pkg', 'skills', 'new'), { recursive: true }) + writeFileSync(join(repoDir, 'pkg', 'skills', 'new', 'SKILL.md'), 'new one') + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const sources = [ + source({ + skills: ['skills/core/SKILL.md', 'skills/new/SKILL.md'], + }), + ] + const packageRoots = new Map([['npm:@acme/pkg', join(repoDir, 'pkg')]]) + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + sources, + packageRoots, + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.candidates).toEqual([ + { + id: '@acme/pkg', + kind: 'npm', + path: 'skills/new/SKILL.md', + reason: 'added-since-baseline', + }, + ]) + } + }) + + it('respects the file filter, skipping paths not in the set', () => { + mkdirSync(join(repoDir, 'pkg', 'skills', 'core'), { recursive: true }) + writeFileSync( + join(repoDir, 'pkg', 'skills', 'core', 'SKILL.md'), + 'original', + ) + git(['add', '.']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + + writeFileSync( + join(repoDir, 'pkg', 'skills', 'core', 'SKILL.md'), + 'edited after baseline', + ) + + const baseline = resolveBaseline(repoDir, 'v1.0.0', baseLockfile()) + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + const sources = [source({ skills: ['skills/core/SKILL.md'] })] + const packageRoots = new Map([['npm:@acme/pkg', join(repoDir, 'pkg')]]) + const fileFilter = new Set(['some/other/path.md']) + + const result = computeBaselineDrift( + repoDir, + baseline.baseline, + sources, + packageRoots, + fileFilter, + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.candidates).toEqual([]) + } + }) +}) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index aedcbe4..ee80ecc 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -3541,6 +3541,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/core.test.ts b/packages/intent/tests/core.test.ts index 0989b1f..b40c13a 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()) @@ -271,7 +273,7 @@ describe('listIntentSkills', () => { { 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: @tanstack/unlisted (provenance unknown). Add to opt in.', ]) }) @@ -469,6 +471,62 @@ 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('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 8ff007b..6d23781 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/git-adapter-failure.test.ts b/packages/intent/tests/git-adapter-failure.test.ts new file mode 100644 index 0000000..26e2e54 --- /dev/null +++ b/packages/intent/tests/git-adapter-failure.test.ts @@ -0,0 +1,53 @@ +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' + +const execFileSyncMock = vi.fn() + +vi.mock('node:child_process', () => ({ + execFileSync: (...args: Array) => execFileSyncMock(...args), +})) + +const { currentBlobSha } = await import('../src/core/git-adapter.js') + +const roots: Array = [] + +function createRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'git-adapter-failure-test-')) + roots.push(root) + writeFileSync(join(root, 'file.txt'), 'content') + return root +} + +afterEach(() => { + execFileSyncMock.mockReset() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('currentBlobSha failures', () => { + it('propagates an unexpected Git failure for an existing file', () => { + execFileSyncMock.mockImplementation(() => { + throw new Error('git failed unexpectedly') + }) + + const result = currentBlobSha(createRoot(), 'file.txt') + + expect(result).toEqual({ ok: false, reason: 'git failed unexpectedly' }) + }) + + it('runs Git with bounded output and a timeout', () => { + execFileSyncMock.mockReturnValue('0123456789abcdef\n') + + const result = currentBlobSha(createRoot(), 'file.txt') + + expect(result).toEqual({ ok: true, value: '0123456789abcdef' }) + expect(execFileSyncMock).toHaveBeenCalledWith( + 'git', + ['hash-object', '--', 'file.txt'], + expect.objectContaining({ maxBuffer: 1024 * 1024, timeout: 10_000 }), + ) + }) +}) diff --git a/packages/intent/tests/git-adapter.test.ts b/packages/intent/tests/git-adapter.test.ts new file mode 100644 index 0000000..a79b18f --- /dev/null +++ b/packages/intent/tests/git-adapter.test.ts @@ -0,0 +1,204 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + blobShaAtCommit, + currentBlobSha, + nearestReachableTag, + repoRoot, + resolveCommit, +} from '../src/core/git-adapter.js' + +let repoDir: string + +function git(args: Array): string { + return execFileSync('git', args, { cwd: repoDir, encoding: 'utf8' }) +} + +beforeEach(() => { + repoDir = mkdtempSync(join(tmpdir(), 'git-adapter-test-')) + git(['init', '--quiet']) + git(['config', 'user.email', 'test@example.com']) + git(['config', 'user.name', 'Test']) +}) + +afterEach(() => { + rmSync(repoDir, { recursive: true, force: true }) +}) + +describe('resolveCommit', () => { + it('resolves HEAD to a full commit sha', () => { + writeFileSync(join(repoDir, 'a.txt'), 'one') + git(['add', 'a.txt']) + git(['commit', '--quiet', '-m', 'first']) + + const result = resolveCommit(repoDir, 'HEAD') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toMatch(/^[0-9a-f]{40}$/) + } + }) + + it('fails for a ref that does not exist', () => { + const result = resolveCommit(repoDir, 'does-not-exist') + expect(result.ok).toBe(false) + }) + + it('fails when cwd is not a git repository', () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'not-a-repo-')) + try { + const result = resolveCommit(outsideDir, 'HEAD') + expect(result.ok).toBe(false) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) +}) + +describe('nearestReachableTag', () => { + it('fails when there are no tags', () => { + writeFileSync(join(repoDir, 'a.txt'), 'one') + git(['add', 'a.txt']) + git(['commit', '--quiet', '-m', 'first']) + + const result = nearestReachableTag(repoDir) + expect(result.ok).toBe(false) + }) + + it('finds the nearest tag reachable from HEAD', () => { + writeFileSync(join(repoDir, 'a.txt'), 'one') + git(['add', 'a.txt']) + git(['commit', '--quiet', '-m', 'first']) + git(['tag', 'v1.0.0']) + writeFileSync(join(repoDir, 'a.txt'), 'two') + git(['commit', '--quiet', '-am', 'second']) + + const result = nearestReachableTag(repoDir) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toBe('v1.0.0') + } + }) +}) + +describe('blobShaAtCommit', () => { + it('returns the blob sha for a path that exists at the given commit', () => { + writeFileSync(join(repoDir, 'a.txt'), 'hello') + git(['add', 'a.txt']) + git(['commit', '--quiet', '-m', 'first']) + const commit = resolveCommit(repoDir, 'HEAD') + expect(commit.ok).toBe(true) + if (!commit.ok) return + + const expectedSha = git(['hash-object', 'a.txt']).trim() + const result = blobShaAtCommit(repoDir, commit.value, 'a.txt') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toBe(expectedSha) + } + }) + + it('returns null for a path that did not exist at the given commit', () => { + writeFileSync(join(repoDir, 'a.txt'), 'hello') + git(['add', 'a.txt']) + git(['commit', '--quiet', '-m', 'first']) + const commit = resolveCommit(repoDir, 'HEAD') + expect(commit.ok).toBe(true) + if (!commit.ok) return + + const result = blobShaAtCommit(repoDir, commit.value, 'missing.txt') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toBeNull() + } + }) +}) + +describe('currentBlobSha', () => { + it('matches the sha git hash-object would produce for the file on disk', () => { + writeFileSync(join(repoDir, 'a.txt'), 'current content') + const expectedSha = git(['hash-object', 'a.txt']).trim() + + const result = currentBlobSha(repoDir, 'a.txt') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toBe(expectedSha) + } + }) + + it('returns null when the file does not exist', () => { + const result = currentBlobSha(repoDir, 'missing.txt') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toBeNull() + } + }) + + it('fails for an existing directory instead of treating it as missing', () => { + mkdirSync(join(repoDir, 'directory')) + + const result = currentBlobSha(repoDir, 'directory') + + expect(result.ok).toBe(false) + }) + + it('detects drift: current content hashes differently than the baseline blob', () => { + writeFileSync(join(repoDir, 'a.txt'), 'baseline content') + git(['add', 'a.txt']) + git(['commit', '--quiet', '-m', 'first']) + const commit = resolveCommit(repoDir, 'HEAD') + expect(commit.ok).toBe(true) + if (!commit.ok) return + const baseline = blobShaAtCommit(repoDir, commit.value, 'a.txt') + expect(baseline.ok).toBe(true) + if (!baseline.ok) return + + writeFileSync(join(repoDir, 'a.txt'), 'drifted content') + const current = currentBlobSha(repoDir, 'a.txt') + expect(current.ok).toBe(true) + if (!current.ok) return + + expect(current.value).not.toBe(baseline.value) + }) +}) + +describe('repoRoot', () => { + it('resolves the working tree root', () => { + const result = repoRoot(repoDir) + expect(result.ok).toBe(true) + if (result.ok) { + // realpath both sides: tmpdir on macOS is a symlink (/tmp -> /private/tmp) + expect(realpathSync(result.value)).toBe(realpathSync(repoDir)) + } + }) + + it('fails outside a git repository', () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'not-a-repo-')) + try { + const result = repoRoot(outsideDir) + expect(result.ok).toBe(false) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) +}) + +describe('forbidden flags', () => { + it('refuses to run if a forbidden flag were ever passed through', () => { + // The adapter's public functions never construct forbidden flags + // themselves; this test exercises the internal guard indirectly by + // confirming a ref value containing a flag-shaped string is still + // treated as a literal ref (via `--`) rather than a flag, i.e. it + // fails as "unknown ref", not as a forbidden-flag execution. + const result = resolveCommit(repoDir, '-c') + expect(result.ok).toBe(false) + }) +}) 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 0000000..49e58c3 --- /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 0000000..f113db5 --- /dev/null +++ b/packages/intent/tests/hash.test.ts @@ -0,0 +1,456 @@ +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('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 193cc64..c1130fe 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 0000000..016cf19 --- /dev/null +++ b/packages/intent/tests/integration/skills-frozen-cli.test.ts @@ -0,0 +1,185 @@ +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 { + generateManifest, + writeIntentManifest, +} from '../../src/core/manifest.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 outcome = generateManifest(packageRoot, name, '1.0.0', [ + { + name: 'core', + path: join(packageRoot, 'skills', 'core', 'SKILL.md'), + description: `${name} skill`, + }, + ]) + if (!outcome.ok) { + throw new Error('Fixture manifest unexpectedly contains a secret.') + } + outcome.manifest.skills[0]!.capabilities = capabilities + writeIntentManifest( + join(packageRoot, 'skills', 'intent.manifest.json'), + outcome.manifest, + ) +} + +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 6241736..be381d6 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 0000000..ccceeeb --- /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 0000000..4503936 --- /dev/null +++ b/packages/intent/tests/lockfile-state.test.ts @@ -0,0 +1,519 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { computeLockfileState } from '../src/commands/skills/support.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { generateManifest, writeIntentManifest } from '../src/core/manifest.js' +import { nodeReadFs } from '../src/shared/utils.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, + } +} + +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' }], + }) + + const outcome = generateManifest(root, '@acme/pkg', '1.0.0', pkg.skills) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + writeIntentManifest( + join(root, 'skills', 'intent.manifest.json'), + outcome.manifest, + ) + + 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' }], + }) + const outcome = generateManifest(root, '@acme/pkg', '1.0.0', pkg.skills) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + writeIntentManifest( + join(root, 'skills', 'intent.manifest.json'), + outcome.manifest, + ) + + 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 outcome = generateManifest(root, '@acme/pkg', '1.0.0', pkg.skills) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + writeFileSync( + join(root, 'skills', 'intent.manifest.json'), + JSON.stringify({ ...outcome.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 outcome = generateManifest(root, '@acme/pkg', '1.0.0', pkg.skills) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + const baseManifest = outcome.manifest + 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 0000000..57bf945 --- /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 0000000..ea7b4dc --- /dev/null +++ b/packages/intent/tests/manifest.test.ts @@ -0,0 +1,460 @@ +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, + generateManifest, + parseManifest, + readIntentManifest, + serializeManifest, + writeIntentManifest, +} from '../src/core/manifest.js' +import type { SkillEntry } from '../src/shared/types.js' + +let packageRoot: string + +beforeEach(() => { + packageRoot = mkdtempSync(join(tmpdir(), 'manifest-test-')) +}) + +afterEach(() => { + rmSync(packageRoot, { recursive: true, force: true }) +}) + +function writeSkill(relDir: string, content: string): SkillEntry { + const skillDir = join(packageRoot, relDir) + mkdirSync(skillDir, { recursive: true }) + const filePath = join(skillDir, 'SKILL.md') + writeFileSync(filePath, content) + return { + name: relDir.split('/').pop() ?? relDir, + path: filePath, + description: '', + } +} + +describe('generateManifest', () => { + it('generates a manifest with no capabilities for plain content', () => { + const skill = writeSkill('skills/core', '# Core\n\nJust guidance text.') + + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + + expect(outcome.manifest.package).toBe('@acme/pkg') + expect(outcome.manifest.packageVersion).toBe('1.0.0') + expect(outcome.manifest.skills).toHaveLength(1) + expect(outcome.manifest.skills[0]).toMatchObject({ + name: 'core', + path: 'skills/core/SKILL.md', + capabilities: [], + declaredSecrets: [], + }) + expect(outcome.manifest.skills[0]?.contentHash).toMatch(/^sha256-/) + }) + + it('pre-fills uses_network from a curl/fetch reference', () => { + const skill = writeSkill( + 'skills/net', + 'Run `curl https://example.com/api`.', + ) + + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + expect(outcome.manifest.skills[0]?.capabilities).toContain('uses_network') + }) + + it('pre-fills runs_install_command from an install command reference', () => { + const skill = writeSkill('skills/install', 'Run `npm install foo` first.') + + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + expect(outcome.manifest.skills[0]?.capabilities).toContain( + 'runs_install_command', + ) + }) + + it('pre-fills ships_scripts when a non-empty scripts/ dir exists', () => { + const skill = writeSkill('skills/scripted', 'Guidance text.') + const scriptsDir = join(packageRoot, 'skills/scripted/scripts') + mkdirSync(scriptsDir, { recursive: true }) + writeFileSync(join(scriptsDir, 'run.sh'), '#!/bin/sh\necho hi') + + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + expect(outcome.manifest.skills[0]?.capabilities).toContain('ships_scripts') + }) + + it('changes the content hash when a reference file changes, not just SKILL.md', () => { + const skill = writeSkill('skills/withref', 'See references/notes.md.') + const refDir = join(packageRoot, 'skills/withref/references') + mkdirSync(refDir, { recursive: true }) + writeFileSync(join(refDir, 'notes.md'), 'original notes') + + const first = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(first.ok).toBe(true) + if (!first.ok) return + + writeFileSync(join(refDir, 'notes.md'), 'changed notes') + const second = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(second.ok).toBe(true) + if (!second.ok) return + + expect(second.manifest.skills[0]?.contentHash).not.toBe( + first.manifest.skills[0]?.contentHash, + ) + }) + + it('hard-fails generation when a skill body contains a literal secret value', () => { + const skill = writeSkill( + 'skills/leaky', + 'export GITHUB_TOKEN=ghp_1234567890abcdef1234567890abcdef', + ) + + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(false) + if (outcome.ok) return + expect(outcome.secretFindings).toEqual([ + { skillPath: 'skills/leaky/SKILL.md', patternName: 'github-token' }, + ]) + }) + + it.each([ + ['references', 'notes.md'], + ['assets', 'config.txt'], + ['scripts', 'run.mjs'], + ])( + 'hard-fails generation when %s/%s contains a literal secret value', + (directory, fileName) => { + const skill = writeSkill('skills/leaky', 'See supporting material.') + const supportPath = join(packageRoot, 'skills/leaky', directory, fileName) + mkdirSync(dirname(supportPath), { recursive: true }) + writeFileSync( + supportPath, + 'export GITHUB_TOKEN=ghp_1234567890abcdef1234567890abcdef', + ) + + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [ + skill, + ]) + + expect(outcome).toEqual({ + ok: false, + secretFindings: [ + { + skillPath: `skills/leaky/${directory}/${fileName}`, + patternName: 'github-token', + }, + ], + }) + }, + ) +}) + +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/) + }) +}) + +describe('serializeManifest / parseManifest round-trip', () => { + it('round-trips a generated manifest', () => { + const skill = writeSkill('skills/core', '# Core\n\nGuidance.') + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + + const serialized = serializeManifest(outcome.manifest) + const parsed = parseManifest(JSON.parse(serialized)) + expect(parsed).toEqual(outcome.manifest) + }) + + it('is deterministic: regenerating unchanged inputs serializes byte-identical', () => { + const skill = writeSkill('skills/core', '# Core\n\nGuidance.') + const first = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + const second = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(first.ok && second.ok).toBe(true) + if (!first.ok || !second.ok) return + + expect(serializeManifest(first.manifest)).toBe( + serializeManifest(second.manifest), + ) + }) + + it('rejects a manifest with a duplicate skill path', () => { + expect(() => + parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { name: 'a', path: 'skills/a/SKILL.md', contentHash: 'sha256-1' }, + { name: 'a2', path: 'skills/a/SKILL.md', contentHash: 'sha256-2' }, + ], + }), + ).toThrow(/duplicate skill path/) + }) + + it('rejects a manifest with a path escape', () => { + 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 an unknown capability', () => { + 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('rejects MCP tools with undeclared fields', () => { + expect(() => + parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + mcpTools: [{ name: 'fetch', command: 'curl' }], + }, + ], + }), + ).toThrow(/undeclared field/) + }) +}) + +describe('writeIntentManifest / readIntentManifest', () => { + it('writes and reads back a manifest file', () => { + const skill = writeSkill('skills/core', '# Core\n\nGuidance.') + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + + const manifestPath = join(packageRoot, 'skills', 'intent.manifest.json') + writeIntentManifest(manifestPath, outcome.manifest) + + const readBack = readIntentManifest(manifestPath) + expect(readBack).toEqual(outcome.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 the same manifest content', () => { + const skill = writeSkill('skills/core', '# Core\n\nGuidance.') + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + + expect(computeManifestHash(outcome.manifest)).toBe( + computeManifestHash(outcome.manifest), + ) + }) + + it('changes when a skill capability changes', () => { + const skill = writeSkill('skills/core', '# Core\n\nGuidance.') + const outcome = generateManifest(packageRoot, '@acme/pkg', '1.0.0', [skill]) + expect(outcome.ok).toBe(true) + if (!outcome.ok) return + + const before = computeManifestHash(outcome.manifest) + const mutated: typeof outcome.manifest = { + ...outcome.manifest, + skills: [ + { + ...outcome.manifest.skills[0]!, + capabilities: ['uses_network'], + }, + ], + } + expect(computeManifestHash(mutated)).not.toBe(before) + }) + + it('canonicalizes declared 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)) + }) + + it.each([ + [ + 'declared secret', + (manifest: ReturnType) => { + manifest.skills[0]!.declaredSecrets = ['API_TOKEN'] + }, + ], + [ + 'MCP tool name', + (manifest: ReturnType) => { + manifest.skills[0]!.mcpTools = [{ name: 'fetch' }] + }, + ], + [ + 'MCP tool description', + (manifest: ReturnType) => { + manifest.skills[0]!.mcpTools = [ + { name: 'fetch', description: 'Fetch a resource.' }, + ] + }, + ], + [ + 'MCP tool schema', + (manifest: ReturnType) => { + manifest.skills[0]!.mcpTools = [ + { name: 'fetch', inputSchema: { type: 'object', required: ['url'] } }, + ] + }, + ], + ])('changes when a %s changes', (_, mutate) => { + const manifest = parseManifest({ + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + }, + ], + }) + const before = computeManifestHash(manifest) + const mutated = structuredClone(manifest) + + mutate(mutated) + + expect(computeManifestHash(mutated)).not.toBe(before) + }) + + it('rejects MCP tools without a valid structural shape', () => { + for (const mcpTools of [ + [{}], + [{ name: 1 }], + [{ name: 'fetch', description: 1 }], + [{ name: 'fetch', inputSchema: [] }], + [{ name: 'fetch', inputSchema: { type: undefined } }], + [{ name: 'fetch' }, { name: 'fetch' }], + ]) { + 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/) + } + }) +}) diff --git a/packages/intent/tests/mode.test.ts b/packages/intent/tests/mode.test.ts new file mode 100644 index 0000000..b59b97c --- /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 0000000..9b01acc --- /dev/null +++ b/packages/intent/tests/public-lockfile-types.test.ts @@ -0,0 +1,80 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { + IntentLockfile, + IntentLockfilePolicy, + IntentLockfilePolicyIgnore, + IntentLockfileSource, + IntentLockfileStaleness, + IntentLockfileStalenessBaseline, + IntentManifest, + IntentManifestCapability, + IntentManifestMcpTool, + IntentManifestSkill, + 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() + }) + + it('imports manifest metadata types from the package root', () => { + const tool: IntentManifestMcpTool = { + name: 'fetch', + inputSchema: { type: 'object' }, + } + const capability: IntentManifestCapability = 'uses_network' + const skill: IntentManifestSkill = { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: [capability], + declaredSecrets: [], + mcpTools: [tool], + } + const manifest: IntentManifest = { + manifestVersion: 1, + package: 'foo', + packageVersion: '1.0.0', + skills: [skill], + } + + expectTypeOf(manifest).toMatchTypeOf() + }) +}) diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 1ffb28e..926adf7 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 970016c..e3caa29 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/secrets.test.ts b/packages/intent/tests/secrets.test.ts new file mode 100644 index 0000000..da5b0ab --- /dev/null +++ b/packages/intent/tests/secrets.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + containsSecretLiteral, + detectCapabilityHeuristics, + findSecretMatches, +} from '../src/core/secrets.js' + +describe('findSecretMatches / containsSecretLiteral', () => { + it('finds no matches in ordinary skill content', () => { + const content = '# My Skill\n\nUse `intent load` to fetch guidance.' + expect(findSecretMatches(content)).toEqual([]) + expect(containsSecretLiteral(content)).toBe(false) + }) + + it('detects a GitHub token literal', () => { + const content = 'export GITHUB_TOKEN=ghp_1234567890abcdef1234567890abcdef' + expect(containsSecretLiteral(content)).toBe(true) + expect(findSecretMatches(content).map((m) => m.name)).toContain( + 'github-token', + ) + }) + + it('detects an AWS access key id literal', () => { + const content = 'AKIAABCDEFGHIJKLMNOP' + expect(containsSecretLiteral(content)).toBe(true) + }) + + it('detects a generic api-key assignment', () => { + const content = 'const apiKey = "sk_live_abcdefghijklmnop1234"' + expect(containsSecretLiteral(content)).toBe(true) + }) + + it('detects a PEM private key block', () => { + const content = '-----BEGIN RSA PRIVATE KEY-----\nMIIB...' + expect(containsSecretLiteral(content)).toBe(true) + }) + + it('does not flag a secret NAME by itself (no value)', () => { + const content = + 'This skill requires the `GITHUB_TOKEN` environment variable.' + expect(containsSecretLiteral(content)).toBe(false) + }) +}) + +describe('detectCapabilityHeuristics', () => { + it('detects network usage from curl/wget/fetch', () => { + expect( + detectCapabilityHeuristics('run `curl https://example.com`').usesNetwork, + ).toBe(true) + expect(detectCapabilityHeuristics('await fetch(url)').usesNetwork).toBe( + true, + ) + expect(detectCapabilityHeuristics('no network here').usesNetwork).toBe( + false, + ) + }) + + it('detects install commands', () => { + expect( + detectCapabilityHeuristics('run `npm install foo`').runsInstallCommand, + ).toBe(true) + expect( + detectCapabilityHeuristics('run `pnpm add foo`').runsInstallCommand, + ).toBe(true) + expect(detectCapabilityHeuristics('nothing here').runsInstallCommand).toBe( + false, + ) + }) + + it('detects subprocess/child_process usage', () => { + expect( + detectCapabilityHeuristics('child_process.exec(cmd)').shellsOut, + ).toBe(true) + expect(detectCapabilityHeuristics('spawn("ls")').shellsOut).toBe(true) + expect(detectCapabilityHeuristics('nothing here').shellsOut).toBe(false) + }) +}) diff --git a/packages/intent/tests/skills-approve.test.ts b/packages/intent/tests/skills-approve.test.ts new file mode 100644 index 0000000..83ee57d --- /dev/null +++ b/packages/intent/tests/skills-approve.test.ts @@ -0,0 +1,578 @@ +import { 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 + } + + 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 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 0000000..3e82368 --- /dev/null +++ b/packages/intent/tests/skills-diff.test.ts @@ -0,0 +1,178 @@ +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 { 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 { 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('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 + } + + 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') + }) + + 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: 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('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-generate-manifest.test.ts b/packages/intent/tests/skills-generate-manifest.test.ts new file mode 100644 index 0000000..36620bd --- /dev/null +++ b/packages/intent/tests/skills-generate-manifest.test.ts @@ -0,0 +1,248 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runSkillsGenerateManifestCommand } from '../src/commands/skills/generate-manifest.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(packages: Array): PolicedScan { + return { + scan: emptyScanResult(packages), + hiddenSourceCount: 0, + hiddenSources: [], + excludePatterns: [], + droppedNames: [], + } +} + +describe('runSkillsGenerateManifestCommand', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const tempDirs: Array = [] + + afterEach(() => { + logSpy.mockClear() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function makePackage(): IntentPackage { + const packageRoot = mkdtempSync(join(tmpdir(), 'generate-manifest-')) + tempDirs.push(packageRoot) + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ name: '@acme/pkg', version: '1.0.0' }), + ) + const skillDir = join(packageRoot, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + const skillPath = join(skillDir, 'SKILL.md') + writeFileSync(skillPath, '# Core\n\nGuidance text.') + + return { + name: '@acme/pkg', + version: '1.0.0', + intent: { version: 1, repo: '', docs: '' }, + skills: [{ name: 'core', path: skillPath, description: '' }], + packageRoot, + kind: 'npm', + source: 'local', + } + } + + function makeConsumerWithInstalledPackage(): { + consumerRoot: string + installed: IntentPackage + } { + const consumerRoot = mkdtempSync(join(tmpdir(), 'manifest-consumer-')) + tempDirs.push(consumerRoot) + writeFileSync( + join(consumerRoot, 'package.json'), + JSON.stringify({ name: 'consumer', private: true }), + ) + const packageRoot = join(consumerRoot, 'node_modules', '@acme', 'pkg') + const skillDir = join(packageRoot, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + const skillPath = join(skillDir, 'SKILL.md') + writeFileSync(skillPath, '# Core\n\nGuidance text.') + + return { + consumerRoot, + installed: { + name: '@acme/pkg', + version: '1.0.0', + intent: { version: 1, repo: '', docs: '' }, + skills: [{ name: 'core', path: skillPath, description: '' }], + packageRoot, + kind: 'npm', + source: 'local', + }, + } + } + + it('writes a manifest file for a discovered package', async () => { + const pkg = makePackage() + + await runSkillsGenerateManifestCommand( + {}, + () => Promise.resolve(policedScan([pkg])), + pkg.packageRoot, + ) + + const manifestPath = join(pkg.packageRoot, 'skills', 'intent.manifest.json') + expect(existsSync(manifestPath)).toBe(true) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + expect(manifest.package).toBe('@acme/pkg') + expect(manifest.skills).toHaveLength(1) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Wrote') + }) + + it('refuses to write a manifest into an installed dependency', async () => { + const { consumerRoot, installed } = makeConsumerWithInstalledPackage() + + await expect( + runSkillsGenerateManifestCommand( + {}, + () => Promise.resolve(policedScan([installed])), + consumerRoot, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('current package or workspace member'), + }) + + expect( + existsSync(join(installed.packageRoot, 'skills', 'intent.manifest.json')), + ).toBe(false) + }) + + it('writes manifests for workspace members', async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), 'manifest-workspace-')) + tempDirs.push(workspaceRoot) + writeFileSync( + join(workspaceRoot, 'package.json'), + JSON.stringify({ + name: 'workspace', + private: true, + workspaces: ['packages/*'], + }), + ) + const packageRoot = join(workspaceRoot, 'packages', 'pkg') + mkdirSync(packageRoot, { recursive: true }) + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ name: '@acme/pkg', version: '1.0.0' }), + ) + const skillPath = join(packageRoot, 'skills', 'core', 'SKILL.md') + mkdirSync(dirname(skillPath), { recursive: true }) + writeFileSync(skillPath, '# Core\n\nGuidance text.') + const pkg: IntentPackage = { + name: '@acme/pkg', + version: '1.0.0', + intent: { version: 1, repo: '', docs: '' }, + skills: [{ name: 'core', path: skillPath, description: '' }], + packageRoot, + kind: 'workspace', + source: 'local', + } + + await runSkillsGenerateManifestCommand( + {}, + () => Promise.resolve(policedScan([pkg])), + workspaceRoot, + ) + + expect( + existsSync(join(packageRoot, 'skills', 'intent.manifest.json')), + ).toBe(true) + }) + + it('refuses to generate manifests in frozen mode', async () => { + const pkg = makePackage() + + await expect( + runSkillsGenerateManifestCommand( + { frozen: true }, + () => Promise.resolve(policedScan([pkg])), + pkg.packageRoot, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('cannot run in frozen mode'), + exitCode: 5, + }) + }) + + it('reports no packages found when discovery is empty', async () => { + await runSkillsGenerateManifestCommand({}, () => + Promise.resolve(policedScan([])), + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('No intent-enabled packages found.') + }) + + it('fails (and does not write) when a skill body contains a literal secret', async () => { + const pkg = makePackage() + writeFileSync( + pkg.skills[0]!.path, + 'export GITHUB_TOKEN=ghp_1234567890abcdef1234567890abcdef', + ) + + await expect( + runSkillsGenerateManifestCommand( + {}, + () => Promise.resolve(policedScan([pkg])), + pkg.packageRoot, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('literal secret value'), + }) + + const manifestPath = join(pkg.packageRoot, 'skills', 'intent.manifest.json') + expect(existsSync(manifestPath)).toBe(false) + }) + + it('outputs JSON with per-package results when --json is passed', async () => { + const pkg = makePackage() + + await runSkillsGenerateManifestCommand( + { json: true }, + () => Promise.resolve(policedScan([pkg])), + pkg.packageRoot, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + const parsed = JSON.parse(output) + expect(parsed).toEqual([ + { + id: '@acme/pkg', + kind: 'npm', + status: 'written', + path: join(pkg.packageRoot, 'skills', 'intent.manifest.json'), + }, + ]) + }) +}) 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 0000000..a52b641 --- /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 0000000..8a6845a --- /dev/null +++ b/packages/intent/tests/skills-scan.test.ts @@ -0,0 +1,246 @@ +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 })), + 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('intent.lock is up to date') + }) + + 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: [ + { + name: 'leaf', + skillCount: 1, + provenance: [['app', 'parent', 'leaf']], + }, + ], + }), + ), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('unlisted skill-bearing source'), + 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-stale.test.ts b/packages/intent/tests/skills-stale.test.ts new file mode 100644 index 0000000..dd9548b --- /dev/null +++ b/packages/intent/tests/skills-stale.test.ts @@ -0,0 +1,269 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { runSkillsStaleCommand } from '../src/commands/skills/stale.js' +import type { + IntentLockfile, + IntentLockfileSource, +} 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(overrides: Partial = {}): IntentLockfile { + return { + lockfileVersion: 1, + intentVersion: '0.0.0', + sources: [], + policy: { ignores: [] }, + ...overrides, + } +} + +function source( + overrides: Partial, +): IntentLockfileSource { + return { + id: '@acme/pkg', + kind: 'npm', + version: '1.0.0', + resolution: null, + skills: ['skills/core/SKILL.md'], + contentHash: 'sha256-x', + manifestHash: null, + capabilities: null, + ...overrides, + } +} + +describe('runSkillsStaleCommand', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const tempDirs: Array = [] + const externalDirs: Array = [] + + afterEach(() => { + logSpy.mockClear() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + for (const dir of externalDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function git(cwd: string, args: Array): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) + } + + function makeTempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'intent-skills-stale-')) + tempDirs.push(dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x' })) + git(dir, ['init', '--quiet']) + git(dir, ['config', 'user.email', 'test@example.com']) + git(dir, ['config', 'user.name', 'Test']) + return dir + } + + it('reports no lockfile when intent.lock is missing', async () => { + const cwd = makeTempProject() + + await runSkillsStaleCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain( + 'No intent.lock found. Run `intent skills approve --all` to create one.', + ) + }) + + it('throws in frozen mode when intent.lock is missing', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsStaleCommand( + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Frozen mode requires intent.lock'), + }) + }) + + it('throws in frozen mode when discovery finds an unlisted skill-bearing source', async () => { + const cwd = makeTempProject() + git(cwd, ['add', '.']) + git(cwd, ['commit', '--quiet', '-m', 'first']) + git(cwd, ['tag', 'v1.0.0']) + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsStaleCommand( + { frozen: true }, + () => Promise.resolve(policedScan({ hiddenSourceCount: 1 })), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('unlisted skill-bearing source'), + exitCode: 3, + }) + }) + + it('reports no candidates when nothing changed since baseline and lockfile is clean', async () => { + const cwd = makeTempProject() + git(cwd, ['add', '.']) + git(cwd, ['commit', '--quiet', '-m', 'first']) + git(cwd, ['tag', 'v1.0.0']) + + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsStaleCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('No staleness candidates found') + }) + + it('does not treat an approved installed dependency as baseline drift in frozen mode', async () => { + const cwd = makeTempProject() + git(cwd, ['add', '.']) + git(cwd, ['commit', '--quiet', '-m', 'first']) + git(cwd, ['tag', 'v1.0.0']) + const installedRoot = mkdtempSync( + join(tmpdir(), 'installed-stale-package-'), + ) + externalDirs.push(installedRoot) + const skillPath = join(installedRoot, 'skills', 'core', 'SKILL.md') + mkdirSync(join(installedRoot, 'skills', 'core'), { recursive: true }) + writeFileSync(skillPath, 'installed guidance') + const pkg: IntentPackage = { + name: '@acme/pkg', + version: '1.0.0', + intent: { version: 1, repo: '', docs: '' }, + skills: [{ name: 'core', path: skillPath, description: '' }], + packageRoot: installedRoot, + kind: 'npm', + source: 'local', + } + writeIntentLockfile( + join(cwd, 'intent.lock'), + baseLockfile({ sources: buildCurrentLockfileSources([pkg]) }), + ) + + await expect( + runSkillsStaleCommand( + { frozen: true }, + () => Promise.resolve(policedScan({ scan: emptyScanResult([pkg]) })), + cwd, + ), + ).resolves.toBeUndefined() + }) + + it('reports layer 2 drift when a tracked skill file changed since the baseline tag', async () => { + const cwd = makeTempProject() + writeFileSync(join(cwd, 'skills-core-SKILL.md'), 'original') + git(cwd, ['add', '.']) + git(cwd, ['commit', '--quiet', '-m', 'first']) + git(cwd, ['tag', 'v1.0.0']) + writeFileSync(join(cwd, 'skills-core-SKILL.md'), 'edited after baseline') + + writeIntentLockfile( + join(cwd, 'intent.lock'), + baseLockfile({ + sources: [ + source({ + id: '@acme/pkg', + skills: ['skills-core-SKILL.md'], + // Matches the on-disk content (via buildCurrentLockfileSources + // in a real scan); irrelevant here since we bypass that path by + // supplying an empty current scan — the diff engine reports it + // as "removed", which is layer01, while Layer 2 checks the + // git blob directly against the package root below. + }), + ], + }), + ) + + const pkg: IntentPackage = { + name: '@acme/pkg', + version: '1.0.0', + intent: { version: 1, repo: '', docs: '' }, + skills: [], + packageRoot: cwd, + kind: 'npm', + source: 'local', + } + + await runSkillsStaleCommand( + {}, + () => Promise.resolve(policedScan({ scan: emptyScanResult([pkg]) })), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('changed-since-baseline') + }) + + it('fails closed in frozen mode when no baseline can be resolved', async () => { + const cwd = makeTempProject() + git(cwd, ['add', '.']) + git(cwd, ['commit', '--quiet', '-m', 'first']) + // no tag: nearestReachableTag will fail, and there is no lockfile baseline + + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsStaleCommand( + { frozen: true }, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('resolvable staleness baseline'), + }) + }) + + it('skips layer 2 (without failing) in interactive mode when no baseline resolves', async () => { + const cwd = makeTempProject() + git(cwd, ['add', '.']) + git(cwd, ['commit', '--quiet', '-m', 'first']) + + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsStaleCommand({}, () => Promise.resolve(policedScan()), cwd) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + expect(output).toContain('Layer 2 (baseline drift) skipped') + }) +}) diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts new file mode 100644 index 0000000..8c318e2 --- /dev/null +++ b/packages/intent/tests/skills-update.test.ts @@ -0,0 +1,686 @@ +import { 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 + } + + 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 122aef9..ba7b9aa 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -59,14 +59,33 @@ 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: @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: @scope/b (via app -> @scope/parent -> @scope/b). Add to opt in.', ]) }) @@ -82,7 +101,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: @scope/b (provenance unknown), @scope/c (provenance unknown). Add to opt in.', ]) }) @@ -104,7 +123,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: foo (provenance unknown). Add to opt in.', '"workspace:foo" is declared in intent.skills but was not discovered.', ]) }) @@ -125,7 +144,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: @scope/dep (provenance unknown). Add to opt in.', ]) }) @@ -138,7 +157,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: @scope/unlisted (provenance unknown). Add to opt in.', '"@scope/missing" is declared in intent.skills but was not discovered.', ]) }) @@ -157,6 +176,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 +227,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 fa26657..8aad420 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 9134737..0eb1bca 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"] },