From 68ee85bbd619ee7d9349fc9c4c4a5355a81c1891 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 14:19:25 -0700 Subject: [PATCH 01/50] feat(lockfile): implement intent lockfile structure and serialization functions --- packages/intent/src/core/lockfile.ts | 314 +++++++++++++++++++++++++ packages/intent/tests/lockfile.test.ts | 218 +++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 packages/intent/src/core/lockfile.ts create mode 100644 packages/intent/tests/lockfile.test.ts diff --git a/packages/intent/src/core/lockfile.ts b/packages/intent/src/core/lockfile.ts new file mode 100644 index 0000000..b6abafc --- /dev/null +++ b/packages/intent/src/core/lockfile.ts @@ -0,0 +1,314 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export 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 + resolution: string | null + manifestHash: string | null + contentHash: string + capabilities: Array + 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 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 parseSource(value: unknown): IntentLockfileSource { + const source = assertRecord(value, 'source') + const kind = source.kind + if (kind !== 'npm' && kind !== 'workspace') { + throw new Error( + 'Invalid intent.lock: source.kind must be npm or workspace.', + ) + } + + return { + id: assertString(source.id, 'source.id'), + kind, + version: assertString(source.version, 'source.version'), + resolution: assertNullableString(source.resolution, 'source.resolution'), + manifestHash: assertNullableString( + source.manifestHash, + 'source.manifestHash', + ), + contentHash: assertString(source.contentHash, 'source.contentHash'), + capabilities: assertStringArray(source.capabilities, 'source.capabilities'), + declaredSecrets: assertStringArray( + source.declaredSecrets, + 'source.declaredSecrets', + ), + mcpTools: assertStringArray(source.mcpTools, 'source.mcpTools'), + mcpPolicy: assertRecord(source.mcpPolicy, 'source.mcpPolicy'), + } +} + +function parsePolicyIgnore(value: unknown): IntentLockfilePolicyIgnore { + const ignore = assertRecord(value, 'policy.ignore') + const scope = assertRecord(ignore.scope, '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') + 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') + 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].sort(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.`) +} + +function canonicalSource(source: IntentLockfileSource): IntentLockfileSource { + return { + id: source.id, + kind: source.kind, + version: source.version, + resolution: source.resolution, + manifestHash: source.manifestHash, + contentHash: source.contentHash, + capabilities: sortedStrings(source.capabilities), + declaredSecrets: sortedStrings(source.declaredSecrets), + mcpTools: sortedStrings(source.mcpTools), + 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(`${a.kind}\u0000${a.id}`, `${b.kind}\u0000${b.id}`), + ) + .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') + 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.') + } + + return canonicalLockfile({ + lockfileVersion: INTENT_LOCKFILE_VERSION, + intentVersion: assertString(lockfile.intentVersion, 'intentVersion'), + ...(lockfile.staleness !== undefined + ? { staleness: parseStaleness(lockfile.staleness) } + : {}), + sources: lockfile.sources.map(parseSource), + 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/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts new file mode 100644 index 0000000..5f4acae --- /dev/null +++ b/packages/intent/tests/lockfile.test.ts @@ -0,0 +1,218 @@ +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.js' +import type { IntentLockfile } from '../src/core/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, + manifestHash: null, + contentHash: 'sha256-workspace-router', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + }, + { + id: '@tanstack/router', + kind: 'npm', + version: '1.42.0', + resolution: 'npm:@tanstack/router@1.42.0', + manifestHash: null, + contentHash: 'sha256-npm-router', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + }, + ], + 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('rejects an unsupported lockfile version', () => { + expect(() => + parseIntentLockfile( + JSON.stringify({ ...createLockfile(), lockfileVersion: 2 }), + ), + ).toThrow('Unsupported intent.lock version: 2') + }) +}) + +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()), + ) + }) +}) From e6a1b4c2d3d970adde12912cac33c82abea16ff6 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 15:02:42 -0700 Subject: [PATCH 02/50] feat(hash): add hashing functions and tests for skill folder and source content --- packages/intent/src/core/hash.ts | 248 +++++++++++++++++++++++ packages/intent/tests/hash.test.ts | 312 +++++++++++++++++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 packages/intent/src/core/hash.ts create mode 100644 packages/intent/tests/hash.test.ts diff --git a/packages/intent/src/core/hash.ts b/packages/intent/src/core/hash.ts new file mode 100644 index 0000000..1e1dab1 --- /dev/null +++ b/packages/intent/src/core/hash.ts @@ -0,0 +1,248 @@ +import { createHash } from 'node:crypto' +import { isAbsolute, join, relative } from 'node:path' +import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs' + +export interface SkillFile { + relativePath: string + content: Buffer +} + +export interface SkillFolderHash { + skillPath: string + hash: string +} + +const RECORD_SEPARATOR = Buffer.from([0]) + +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)) +} + +interface FileEntry { + physicalPath: string + logicalRelativePath: string +} + +function readDirEntries(dir: string, logicalRelativePath: string) { + try { + return readdirSync(dir, { withFileTypes: true }) + } catch (err) { + throw new Error( + `Failed to list skill folder "${logicalRelativePath || '.'}": ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +// Recurses through the resolved real path once validated, not the original +// symlink, to avoid a TOCTOU window between the check and the read. +function collectFileEntries( + physicalDir: string, + logicalPrefix: string, + realRoot: string, + ancestors: Set, +): Array { + const entries = readDirEntries(physicalDir, logicalPrefix) + const files: Array = [] + + for (const entry of entries) { + const physicalEntryPath = join(physicalDir, entry.name) + const logicalRelativePath = logicalPrefix + ? `${logicalPrefix}/${entry.name}` + : entry.name + + if (entry.isSymbolicLink()) { + let realEntryPath: string + try { + realEntryPath = realpathSync(physicalEntryPath) + } catch (err) { + throw new Error( + `Failed to resolve skill folder symlink "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + if (!isWithinDir(realEntryPath, realRoot)) { + throw new Error( + `Refusing to hash skill folder: "${logicalRelativePath}" escapes the skill folder via a symlink.`, + ) + } + + const stats = statSync(realEntryPath) + if (stats.isDirectory()) { + if (ancestors.has(realEntryPath)) { + throw new Error( + `Refusing to hash skill folder: "${logicalRelativePath}" is a symlink cycle.`, + ) + } + ancestors.add(realEntryPath) + files.push( + ...collectFileEntries( + realEntryPath, + logicalRelativePath, + realRoot, + ancestors, + ), + ) + ancestors.delete(realEntryPath) + } else if (stats.isFile()) { + files.push({ physicalPath: realEntryPath, logicalRelativePath }) + } + continue + } + + if (entry.isDirectory()) { + files.push( + ...collectFileEntries( + physicalEntryPath, + logicalRelativePath, + realRoot, + ancestors, + ), + ) + } else if (entry.isFile()) { + files.push({ physicalPath: physicalEntryPath, logicalRelativePath }) + } + } + + return files +} + +export function readSkillFolderFiles(skillDir: string): Array { + const realRoot = realpathSync(skillDir) + const entries = collectFileEntries( + realRoot, + '', + realRoot, + new Set([realRoot]), + ) + + const files = entries.map( + ({ physicalPath, logicalRelativePath }): SkillFile => { + let raw: Buffer + try { + raw = readFileSync(physicalPath) + } catch (err) { + throw new Error( + `Failed to read skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + return { + relativePath: logicalRelativePath, + content: isBinaryContent(raw) ? raw : normalizeLineEndings(raw), + } + }, + ) + + return files.sort((a, b) => compareStrings(a.relativePath, b.relativePath)) +} + +function assertValidRelativePath(path: string, label: string): void { + if (path.length === 0) { + throw new Error(`Invalid ${label}: path must not be empty.`) + } + if (isAbsolute(path)) { + throw new Error(`Invalid ${label}: path must be relative, got "${path}".`) + } + if (path.includes('\\')) { + throw new Error( + `Invalid ${label}: path must use "/" separators, got "${path}".`, + ) + } + if ( + path + .split('/') + .some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error( + `Invalid ${label}: path must not contain "." or ".." segments, got "${path}".`, + ) + } +} + +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) + } +} + +// Values are length-prefixed because content can contain NUL bytes. Keys +// (real filesystem 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].sort((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')}` +} + +export function hashSkillFolderFiles(files: ReadonlyArray): string { + for (const file of files) { + assertValidRelativePath(file.relativePath, 'skill file path') + } + assertNoDuplicateKeys( + files.map((file) => file.relativePath), + 'skill file path', + ) + + return hashEntries( + files.map((file) => ({ key: file.relativePath, value: file.content })), + ) +} + +export function hashSkillFolder(skillDir: string): string { + return hashSkillFolderFiles(readSkillFolderFiles(skillDir)) +} + +export function hashSourceContent( + skillHashes: ReadonlyArray, +): string { + assertNoDuplicateKeys( + skillHashes.map((entry) => entry.skillPath), + 'source skill path', + ) + + return hashEntries( + skillHashes.map((entry) => ({ + key: entry.skillPath, + value: Buffer.from(entry.hash, 'utf8'), + })), + ) +} diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts new file mode 100644 index 0000000..d8a7010 --- /dev/null +++ b/packages/intent/tests/hash.test.ts @@ -0,0 +1,312 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + hashSkillFolder, + hashSkillFolderFiles, + hashSourceContent, + readSkillFolderFiles, +} from '../src/core/hash.js' + +const roots: Array = [] + +function createRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'intent-hash-test-')) + roots.push(root) + return root +} + +function writeSkillFolder( + dir: string, + files: Record, +): void { + for (const [relativePath, content] of Object.entries(files)) { + const filePath = join(dir, relativePath) + mkdirSync(join(filePath, '..'), { recursive: true }) + writeFileSync(filePath, content) + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('hashSkillFolderFiles', () => { + it('is deterministic for the same file set', () => { + const files = [ + { relativePath: 'SKILL.md', content: Buffer.from('hello') }, + { relativePath: 'references/a.md', content: Buffer.from('world') }, + ] + + expect(hashSkillFolderFiles(files)).toBe(hashSkillFolderFiles(files)) + }) + + it('is independent of input array order', () => { + const a = { relativePath: 'SKILL.md', content: Buffer.from('hello') } + const b = { relativePath: 'references/a.md', content: Buffer.from('world') } + + expect(hashSkillFolderFiles([a, b])).toBe(hashSkillFolderFiles([b, a])) + }) + + it('changes when file content changes', () => { + const base = [{ relativePath: 'SKILL.md', content: Buffer.from('hello') }] + const changed = [ + { relativePath: 'SKILL.md', content: Buffer.from('hello!') }, + ] + + expect(hashSkillFolderFiles(base)).not.toBe(hashSkillFolderFiles(changed)) + }) + + it('changes when a file is renamed with the same content', () => { + const original = [ + { relativePath: 'SKILL.md', content: Buffer.from('hello') }, + ] + const renamed = [ + { relativePath: 'SKILL2.md', content: Buffer.from('hello') }, + ] + + expect(hashSkillFolderFiles(original)).not.toBe( + hashSkillFolderFiles(renamed), + ) + }) + + it('does not collide across a path/content boundary shift', () => { + const a = [{ relativePath: 'a', content: Buffer.from('bc') }] + const b = [{ relativePath: 'ab', content: Buffer.from('c') }] + + expect(hashSkillFolderFiles(a)).not.toBe(hashSkillFolderFiles(b)) + }) + + it('returns a sha256- prefixed digest', () => { + expect(hashSkillFolderFiles([])).toMatch(/^sha256-[0-9a-f]{64}$/) + }) + + it('rejects duplicate relative paths', () => { + const files = [ + { relativePath: 'SKILL.md', content: Buffer.from('a') }, + { relativePath: 'SKILL.md', content: Buffer.from('b') }, + ] + + expect(() => hashSkillFolderFiles(files)).toThrow(/duplicate path/) + }) + + it('rejects an absolute relative path', () => { + const files = [{ relativePath: '/etc/passwd', content: Buffer.from('a') }] + + expect(() => hashSkillFolderFiles(files)).toThrow(/must be relative/) + }) + + it("rejects a path containing a '..' segment", () => { + const files = [{ relativePath: '../outside.md', content: Buffer.from('a') }] + + expect(() => hashSkillFolderFiles(files)).toThrow(/segments/) + }) + + it('classifies a large buffer with a NUL byte past the first 8000 bytes as binary', () => { + const content = Buffer.concat([ + Buffer.alloc(9000, 0x41), + Buffer.from([0x00]), + Buffer.from('\r\n'), + ]) + const files = [{ relativePath: 'assets/large.bin', content }] + + expect(hashSkillFolderFiles(files)).toMatch(/^sha256-[0-9a-f]{64}$/) + }) + + it('rejects a relative path containing an embedded NUL byte', () => { + const files = [ + { relativePath: 'assets/a\0b.md', content: Buffer.from('a') }, + ] + + expect(() => hashSkillFolderFiles(files)).toThrow(/NUL byte/) + }) +}) + +describe('hashSourceContent', () => { + it('is deterministic regardless of input order', () => { + const a = { skillPath: 'skills/a', hash: 'sha256-aaa' } + const b = { skillPath: 'skills/b', hash: 'sha256-bbb' } + + expect(hashSourceContent([a, b])).toBe(hashSourceContent([b, a])) + }) + + it('changes when any per-skill hash changes', () => { + const before = [{ skillPath: 'skills/a', hash: 'sha256-aaa' }] + const after = [{ skillPath: 'skills/a', hash: 'sha256-zzz' }] + + expect(hashSourceContent(before)).not.toBe(hashSourceContent(after)) + }) + + it('rejects duplicate skill paths', () => { + const duplicates = [ + { skillPath: 'skills/a', hash: 'sha256-aaa' }, + { skillPath: 'skills/a', hash: 'sha256-bbb' }, + ] + + expect(() => hashSourceContent(duplicates)).toThrow(/duplicate path/) + }) +}) + +describe('readSkillFolderFiles', () => { + it('reads SKILL.md plus nested references/assets/scripts files', () => { + const root = createRoot() + writeSkillFolder(root, { + 'SKILL.md': 'body', + 'references/deep-dive.md': 'reference', + 'assets/notes.txt': 'asset', + 'scripts/setup.sh': 'echo hi', + }) + + const files = readSkillFolderFiles(root) + + expect(files.map((file) => file.relativePath).sort()).toEqual([ + 'SKILL.md', + 'assets/notes.txt', + 'references/deep-dive.md', + 'scripts/setup.sh', + ]) + }) + + it('normalizes CRLF and lone CR to LF in text files', () => { + const root = createRoot() + writeSkillFolder(root, { + 'SKILL.md': Buffer.from('line1\r\nline2\rline3\n'), + }) + + const [file] = readSkillFolderFiles(root) + + expect(file!.content.toString('utf8')).toBe('line1\nline2\nline3\n') + }) + + it('leaves binary content byte-exact', () => { + const root = createRoot() + const binary = Buffer.from([0x00, 0x0d, 0x0a, 0xff, 0x01]) + writeSkillFolder(root, { 'assets/image.bin': binary }) + + const [file] = readSkillFolderFiles(root) + + expect(file!.content.equals(binary)).toBe(true) + }) + + it('uses forward-slash relative paths regardless of platform separator', () => { + const root = createRoot() + writeSkillFolder(root, { 'references/nested/deep.md': 'content' }) + + const files = readSkillFolderFiles(root) + + expect(files.some((file) => file.relativePath.includes('\\'))).toBe(false) + expect( + files.some((file) => file.relativePath === 'references/nested/deep.md'), + ).toBe(true) + }) + + it('fails closed when a symlink escapes the skill folder', () => { + 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, 'references'), { recursive: true }) + symlinkSync( + join(outside, 'secret.md'), + join(root, 'references', 'linked.md'), + ) + + expect(() => readSkillFolderFiles(root)).toThrow(/escapes the skill folder/) + + rmSync(outside, { recursive: true, force: true }) + }) + + it('fails closed when a symlinked directory escapes the skill folder', () => { + const root = createRoot() + const outside = join( + root, + '..', + 'outside-dir-' + Math.random().toString(36).slice(2), + ) + mkdirSync(outside, { recursive: true }) + writeFileSync(join(outside, 'secret.md'), 'leaked') + symlinkSync(outside, join(root, 'linked-dir'), 'dir') + + expect(() => readSkillFolderFiles(root)).toThrow(/escapes the skill folder/) + + rmSync(outside, { recursive: true, force: true }) + }) + + it('fails closed on a dangling symlink', () => { + const root = createRoot() + symlinkSync(join(root, 'missing-target.md'), join(root, 'broken.md')) + + expect(() => readSkillFolderFiles(root)).toThrow( + /Failed to resolve skill folder symlink/, + ) + }) + + it('fails closed on a symlink cycle', () => { + const root = createRoot() + mkdirSync(join(root, 'a')) + symlinkSync(root, join(root, 'a', 'back-to-root'), 'dir') + + expect(() => readSkillFolderFiles(root)).toThrow(/symlink cycle/) + }) + + it('follows an in-bounds symlink and hashes its target content', () => { + const root = createRoot() + writeFileSync(join(root, 'canonical.md'), 'shared content') + symlinkSync(join(root, 'canonical.md'), join(root, 'link.md')) + + const files = readSkillFolderFiles(root) + + expect(files).toEqual([ + { relativePath: 'canonical.md', content: Buffer.from('shared content') }, + { relativePath: 'link.md', content: Buffer.from('shared content') }, + ]) + }) +}) + +describe('hashSkillFolder', () => { + it('is stable across two calls', () => { + const root = createRoot() + writeSkillFolder(root, { 'SKILL.md': 'body' }) + + expect(hashSkillFolder(root)).toBe(hashSkillFolder(root)) + }) + + it('changes when a nested reference file changes', () => { + const root = createRoot() + writeSkillFolder(root, { + 'SKILL.md': 'body', + 'references/a.md': 'version 1', + }) + const before = hashSkillFolder(root) + + writeFileSync(join(root, 'references', 'a.md'), 'version 2') + + expect(hashSkillFolder(root)).not.toBe(before) + }) + + it('is identical across different physical roots for identical relative structure and bytes', () => { + const rootA = createRoot() + const rootB = createRoot() + const files = { + 'SKILL.md': 'shared body', + 'references/a.md': 'shared reference', + } + writeSkillFolder(rootA, files) + writeSkillFolder(rootB, files) + + expect(hashSkillFolder(rootA)).toBe(hashSkillFolder(rootB)) + }) +}) From 45600e456d960a0ad681e8faeddc00f834ad93bf Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 15:07:09 -0700 Subject: [PATCH 03/50] feat(hash): optimize file reading with file descriptor management --- packages/intent/src/core/hash.ts | 47 ++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/intent/src/core/hash.ts b/packages/intent/src/core/hash.ts index 1e1dab1..57b8199 100644 --- a/packages/intent/src/core/hash.ts +++ b/packages/intent/src/core/hash.ts @@ -1,6 +1,14 @@ import { createHash } from 'node:crypto' import { isAbsolute, join, relative } from 'node:path' -import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { + closeSync, + fstatSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + statSync, +} from 'node:fs' export interface SkillFile { relativePath: string @@ -124,6 +132,34 @@ function collectFileEntries( return files } +// Opens once and reads/verifies-type from that same fd rather than +// stat-by-path-then-open-by-path: the fd is bound to a specific inode, so a +// path swap after this call can't produce a torn read mixing old/new bytes. +function readRegularFile( + physicalPath: string, + logicalRelativePath: string, +): Buffer { + let fd: number + try { + fd = openSync(physicalPath, 'r') + } catch (err) { + throw new Error( + `Failed to read skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + + try { + if (!fstatSync(fd).isFile()) { + throw new Error( + `Failed to read skill file "${logicalRelativePath}": not a regular file.`, + ) + } + return readFileSync(fd) + } finally { + closeSync(fd) + } +} + export function readSkillFolderFiles(skillDir: string): Array { const realRoot = realpathSync(skillDir) const entries = collectFileEntries( @@ -135,14 +171,7 @@ export function readSkillFolderFiles(skillDir: string): Array { const files = entries.map( ({ physicalPath, logicalRelativePath }): SkillFile => { - let raw: Buffer - try { - raw = readFileSync(physicalPath) - } catch (err) { - throw new Error( - `Failed to read skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, - ) - } + const raw = readRegularFile(physicalPath, logicalRelativePath) return { relativePath: logicalRelativePath, From a1f13090042cbf8a0d4c3848f22a363c998f2c05 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 15:41:59 -0700 Subject: [PATCH 04/50] feat(lockfile): implement lockfile diffing and state management --- .../intent/src/core/{ => lockfile}/hash.ts | 29 +- .../intent/src/core/lockfile/lockfile-diff.ts | 130 +++++++ .../src/core/lockfile/lockfile-state.ts | 74 ++++ .../src/core/{ => lockfile}/lockfile.ts | 7 +- packages/intent/tests/hash.test.ts | 38 ++- packages/intent/tests/lockfile-diff.test.ts | 270 +++++++++++++++ packages/intent/tests/lockfile-state.test.ts | 323 ++++++++++++++++++ packages/intent/tests/lockfile.test.ts | 4 +- 8 files changed, 867 insertions(+), 8 deletions(-) rename packages/intent/src/core/{ => lockfile}/hash.ts (90%) create mode 100644 packages/intent/src/core/lockfile/lockfile-diff.ts create mode 100644 packages/intent/src/core/lockfile/lockfile-state.ts rename packages/intent/src/core/{ => lockfile}/lockfile.ts (97%) create mode 100644 packages/intent/tests/lockfile-diff.test.ts create mode 100644 packages/intent/tests/lockfile-state.test.ts diff --git a/packages/intent/src/core/hash.ts b/packages/intent/src/core/lockfile/hash.ts similarity index 90% rename from packages/intent/src/core/hash.ts rename to packages/intent/src/core/lockfile/hash.ts index 57b8199..a9a3fdf 100644 --- a/packages/intent/src/core/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -66,6 +66,7 @@ function collectFileEntries( logicalPrefix: string, realRoot: string, ancestors: Set, + excludeDirs: ReadonlySet, ): Array { const entries = readDirEntries(physicalDir, logicalPrefix) const files: Array = [] @@ -94,6 +95,7 @@ function collectFileEntries( const stats = statSync(realEntryPath) if (stats.isDirectory()) { + if (excludeDirs.has(realEntryPath)) continue if (ancestors.has(realEntryPath)) { throw new Error( `Refusing to hash skill folder: "${logicalRelativePath}" is a symlink cycle.`, @@ -106,6 +108,7 @@ function collectFileEntries( logicalRelativePath, realRoot, ancestors, + excludeDirs, ), ) ancestors.delete(realEntryPath) @@ -116,12 +119,14 @@ function collectFileEntries( } if (entry.isDirectory()) { + if (excludeDirs.has(physicalEntryPath)) continue files.push( ...collectFileEntries( physicalEntryPath, logicalRelativePath, realRoot, ancestors, + excludeDirs, ), ) } else if (entry.isFile()) { @@ -160,13 +165,28 @@ function readRegularFile( } } -export function readSkillFolderFiles(skillDir: string): Array { +export function readSkillFolderFiles( + skillDir: string, + excludeDirs: ReadonlyArray = [], +): Array { const realRoot = realpathSync(skillDir) + const realExcludeDirs = new Set( + excludeDirs + .map((dir) => { + try { + return realpathSync(dir) + } catch { + return null + } + }) + .filter((dir): dir is string => dir !== null), + ) const entries = collectFileEntries( realRoot, '', realRoot, new Set([realRoot]), + realExcludeDirs, ) const files = entries.map( @@ -256,8 +276,11 @@ export function hashSkillFolderFiles(files: ReadonlyArray): string { ) } -export function hashSkillFolder(skillDir: string): string { - return hashSkillFolderFiles(readSkillFolderFiles(skillDir)) +export function hashSkillFolder( + skillDir: string, + excludeDirs: ReadonlyArray = [], +): string { + return hashSkillFolderFiles(readSkillFolderFiles(skillDir, excludeDirs)) } export function hashSourceContent( 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..76aa92d --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -0,0 +1,130 @@ +import { sourceIdentityKey } from '../types.js' +import { canonicalSource } from './lockfile.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from './lockfile.js' +import type { SourceIdentity } from '../types.js' + +export type LockfileChangeField = + | 'version' + | 'resolution' + | '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].sort((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 compareField = (field: LockfileChangeField): void => { + const from = lockedCanonical[field] + const to = currentCanonical[field] + if (JSON.stringify(from) !== JSON.stringify(to)) { + changes.push({ field, from, to }) + } + } + + compareField('version') + compareField('resolution') + compareField('contentHash') + compareField('manifestHash') + compareField('capabilities') + compareField('declaredSecrets') + compareField('mcpTools') + compareField('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..8038c9d --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -0,0 +1,74 @@ +import { dirname, relative, sep } from 'node:path' +import { sourceIdentityKey } from '../types.js' +import { hashSkillFolder, hashSourceContent } from './hash.js' +import type { IntentLockfileSource } from './lockfile.js' +import type { IntentPackage } from '../../shared/types.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 +} + +// A nested SKILL.md is its own skill root, not part of the parent's content, +// so the parent's hash must exclude it or double-count its bytes. +function buildSourceContentHash(pkg: IntentPackage): string { + const skillDirs = pkg.skills.map((skill) => dirname(skill.path)) + + const skillHashes = skillDirs.map((skillDir, index) => { + const otherSkillDirs = skillDirs.filter((_, i) => i !== index) + + return { + skillPath: toPosixPath(relative(pkg.packageRoot, skillDir)), + hash: hashSkillFolder(skillDir, otherSkillDirs), + } + }) + + return hashSourceContent(skillHashes) +} + +function buildResolution(pkg: IntentPackage): string | null { + return pkg.kind === 'npm' ? `npm:${pkg.name}@${pkg.version}` : null +} + +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, +): Array { + const sources = packages + .map( + (pkg): IntentLockfileSource => ({ + id: pkg.name, + kind: pkg.kind, + version: pkg.version, + resolution: buildResolution(pkg), + manifestHash: null, + contentHash: buildSourceContentHash(pkg), + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + }), + ) + .sort((a, b) => compareStrings(sourceIdentityKey(a), sourceIdentityKey(b))) + + assertUniqueIdentities(sources) + + return sources +} diff --git a/packages/intent/src/core/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts similarity index 97% rename from packages/intent/src/core/lockfile.ts rename to packages/intent/src/core/lockfile/lockfile.ts index b6abafc..6d8a1ca 100644 --- a/packages/intent/src/core/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -1,5 +1,6 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' +import { sourceIdentityKey } from '../types.js' export const INTENT_LOCKFILE_VERSION = 1 @@ -198,7 +199,9 @@ function canonicalJsonValue(value: unknown, label: string): JsonValue { throw new Error(`Invalid intent.lock: ${label} must be JSON-serializable.`) } -function canonicalSource(source: IntentLockfileSource): IntentLockfileSource { +export function canonicalSource( + source: IntentLockfileSource, +): IntentLockfileSource { return { id: source.id, kind: source.kind, @@ -238,7 +241,7 @@ function canonicalLockfile(lockfile: IntentLockfile): IntentLockfile { ...(lockfile.staleness ? { staleness: lockfile.staleness } : {}), sources: [...lockfile.sources] .sort((a, b) => - compareStrings(`${a.kind}\u0000${a.id}`, `${b.kind}\u0000${b.id}`), + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), ) .map(canonicalSource), policy: { diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index d8a7010..b587aea 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -13,7 +13,7 @@ import { hashSkillFolderFiles, hashSourceContent, readSkillFolderFiles, -} from '../src/core/hash.js' +} from '../src/core/lockfile/hash.js' const roots: Array = [] @@ -309,4 +309,40 @@ describe('hashSkillFolder', () => { expect(hashSkillFolder(rootA)).toBe(hashSkillFolder(rootB)) }) + + it('includes a nested skill folder by default (no exclusion)', () => { + const root = createRoot() + const nestedDir = join(root, 'nested-skill') + writeSkillFolder(root, { 'SKILL.md': 'parent body' }) + writeSkillFolder(nestedDir, { 'SKILL.md': 'nested body' }) + + const before = hashSkillFolder(root) + writeFileSync(join(nestedDir, 'SKILL.md'), 'nested body changed') + + expect(hashSkillFolder(root)).not.toBe(before) + }) + + it('excludes a nested skill folder when passed as excludeDirs', () => { + const root = createRoot() + const nestedDir = join(root, 'nested-skill') + writeSkillFolder(root, { 'SKILL.md': 'parent body' }) + writeSkillFolder(nestedDir, { 'SKILL.md': 'nested body' }) + + const before = hashSkillFolder(root, [nestedDir]) + writeFileSync(join(nestedDir, 'SKILL.md'), 'nested body changed') + + expect(hashSkillFolder(root, [nestedDir])).toBe(before) + }) + + it("still changes when the parent's own content changes despite an exclusion", () => { + const root = createRoot() + const nestedDir = join(root, 'nested-skill') + writeSkillFolder(root, { 'SKILL.md': 'parent body' }) + writeSkillFolder(nestedDir, { 'SKILL.md': 'nested body' }) + + const before = hashSkillFolder(root, [nestedDir]) + writeFileSync(join(root, 'SKILL.md'), 'parent body changed') + + expect(hashSkillFolder(root, [nestedDir])).not.toBe(before) + }) }) diff --git a/packages/intent/tests/lockfile-diff.test.ts b/packages/intent/tests/lockfile-diff.test.ts new file mode 100644 index 0000000..c034e07 --- /dev/null +++ b/packages/intent/tests/lockfile-diff.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest' +import { diffLockfileSources } from '../src/core/lockfile/lockfile-diff.js' +import type { + IntentLockfile, + IntentLockfileSource, +} from '../src/core/lockfile/lockfile.js' + +function createSource( + overrides: Partial & + Pick, +): IntentLockfileSource { + return { + version: '1.0.0', + resolution: null, + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + ...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('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..0874f32 --- /dev/null +++ b/packages/intent/tests/lockfile-state.test.ts @@ -0,0 +1,323 @@ +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 { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { + hashSkillFolder, + hashSourceContent, +} from '../src/core/lockfile/hash.js' +import type { IntentPackage } from '../src/shared/types.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: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + }) + expect(entry!.contentHash).toMatch(/^sha256-[0-9a-f]{64}$/) + }) + + 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('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('excludes a nested skill folder from its parent skill hash', () => { + 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 expectedHash = hashSourceContent([ + { + skillPath: 'skills/parent', + hash: hashSkillFolder(parentDir, [nestedDir]), + }, + { + skillPath: 'skills/parent/nested', + hash: hashSkillFolder(nestedDir), + }, + ]) + + expect(buildCurrentLockfileSources([pkg])[0]!.contentHash).toBe( + expectedHash, + ) + }) + + 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 index 5f4acae..dec0225 100644 --- a/packages/intent/tests/lockfile.test.ts +++ b/packages/intent/tests/lockfile.test.ts @@ -7,8 +7,8 @@ import { readIntentLockfile, serializeIntentLockfile, writeIntentLockfile, -} from '../src/core/lockfile.js' -import type { IntentLockfile } from '../src/core/lockfile.js' +} from '../src/core/lockfile/lockfile.js' +import type { IntentLockfile } from '../src/core/lockfile/lockfile.js' const roots: Array = [] From 56ff8af808840705ded65cd24ba4e958720246c9 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 16:14:18 -0700 Subject: [PATCH 05/50] feat: optimize sorting and comparison functions across multiple files --- .../intent/src/commands/install/guidance.ts | 4 +- packages/intent/src/core/lockfile/hash.ts | 38 +++++++++---------- .../intent/src/core/lockfile/lockfile-diff.ts | 32 +++++++++++----- packages/intent/src/core/lockfile/lockfile.ts | 2 +- packages/intent/src/core/source-policy.ts | 2 +- tsconfig.json | 4 +- 6 files changed, 47 insertions(+), 35 deletions(-) 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/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index a9a3fdf..a62cb38 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -102,15 +102,15 @@ function collectFileEntries( ) } ancestors.add(realEntryPath) - files.push( - ...collectFileEntries( - realEntryPath, - logicalRelativePath, - realRoot, - ancestors, - excludeDirs, - ), - ) + for (const nested of collectFileEntries( + realEntryPath, + logicalRelativePath, + realRoot, + ancestors, + excludeDirs, + )) { + files.push(nested) + } ancestors.delete(realEntryPath) } else if (stats.isFile()) { files.push({ physicalPath: realEntryPath, logicalRelativePath }) @@ -120,15 +120,15 @@ function collectFileEntries( if (entry.isDirectory()) { if (excludeDirs.has(physicalEntryPath)) continue - files.push( - ...collectFileEntries( - physicalEntryPath, - logicalRelativePath, - realRoot, - ancestors, - excludeDirs, - ), - ) + for (const nested of collectFileEntries( + physicalEntryPath, + logicalRelativePath, + realRoot, + ancestors, + excludeDirs, + )) { + files.push(nested) + } } else if (entry.isFile()) { files.push({ physicalPath: physicalEntryPath, logicalRelativePath }) } @@ -243,7 +243,7 @@ function hashEntries( entries: ReadonlyArray<{ key: string; value: Buffer }>, ): string { const hash = createHash('sha256') - const sorted = [...entries].sort((a, b) => compareStrings(a.key, b.key)) + const sorted = entries.toSorted((a, b) => compareStrings(a.key, b.key)) for (const entry of sorted) { if (entry.key.includes('\0')) { diff --git a/packages/intent/src/core/lockfile/lockfile-diff.ts b/packages/intent/src/core/lockfile/lockfile-diff.ts index 76aa92d..3629b1b 100644 --- a/packages/intent/src/core/lockfile/lockfile-diff.ts +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -43,7 +43,7 @@ function compareStrings(a: string, b: string): number { function sortBySourceIdentity( items: Array, ): Array { - return [...items].sort((a, b) => + return items.toSorted((a, b) => compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), ) } @@ -56,7 +56,19 @@ function diffFields( const currentCanonical = canonicalSource(current) const changes: Array = [] - const compareField = (field: LockfileChangeField): void => { + 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: 'capabilities' | 'declaredSecrets' | 'mcpTools' | 'mcpPolicy', + ): void => { const from = lockedCanonical[field] const to = currentCanonical[field] if (JSON.stringify(from) !== JSON.stringify(to)) { @@ -64,14 +76,14 @@ function diffFields( } } - compareField('version') - compareField('resolution') - compareField('contentHash') - compareField('manifestHash') - compareField('capabilities') - compareField('declaredSecrets') - compareField('mcpTools') - compareField('mcpPolicy') + comparePrimitiveField('version') + comparePrimitiveField('resolution') + comparePrimitiveField('contentHash') + comparePrimitiveField('manifestHash') + compareStructuredField('capabilities') + compareStructuredField('declaredSecrets') + compareStructuredField('mcpTools') + compareStructuredField('mcpPolicy') return changes } diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index 6d8a1ca..d31f2b1 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -166,7 +166,7 @@ function compareStrings(a: string, b: string): number { } function sortedStrings(values: Array): Array { - return [...values].sort(compareStrings) + return values.toSorted(compareStrings) } function canonicalJsonValue(value: unknown, label: string): JsonValue { diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index e9525f8..e47730a 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -120,7 +120,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) 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"] }, From 915b1a85c2aac732db441da3d48c4001398855b3 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 16:40:20 -0700 Subject: [PATCH 06/50] feat(skills): implement scan and diff commands for skills management --- packages/intent/src/cli.ts | 43 ++++ packages/intent/src/commands/skills/diff.ts | 85 ++++++++ packages/intent/src/commands/skills/scan.ts | 52 +++++ .../intent/src/commands/skills/support.ts | 43 ++++ packages/intent/src/commands/support.ts | 14 ++ packages/intent/src/shared/cli-output.ts | 7 +- packages/intent/src/shared/env-flag.ts | 6 + packages/intent/src/shared/mode.ts | 29 +++ packages/intent/tests/cli.test.ts | 105 ++++++++++ packages/intent/tests/mode.test.ts | 118 +++++++++++ packages/intent/tests/skills-diff.test.ts | 142 ++++++++++++++ packages/intent/tests/skills-scan.test.ts | 184 ++++++++++++++++++ 12 files changed, 824 insertions(+), 4 deletions(-) create mode 100644 packages/intent/src/commands/skills/diff.ts create mode 100644 packages/intent/src/commands/skills/scan.ts create mode 100644 packages/intent/src/commands/skills/support.ts create mode 100644 packages/intent/src/shared/env-flag.ts create mode 100644 packages/intent/src/shared/mode.ts create mode 100644 packages/intent/tests/mode.test.ts create mode 100644 packages/intent/tests/skills-diff.test.ts create mode 100644 packages/intent/tests/skills-scan.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index d317b9d..611f3f7 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -10,6 +10,7 @@ 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 { SkillsScanCommandOptions } from './commands/skills/scan.js' import type { StaleCommandOptions } from './commands/stale.js' import type { ValidateCommandOptions } from './commands/validate.js' @@ -208,6 +209,48 @@ function createCli(): CAC { }, ) + cli + .command('skills [action]', 'Scan or diff skills against intent.lock') + .usage('skills [--json] [--frozen] [--no-frozen]') + .option('--json', 'Output JSON') + .option( + '--frozen', + 'Force frozen mode (fail if intent.lock is missing or stale)', + ) + .option('--no-frozen', 'Disable CI auto-detection of frozen mode') + .example('skills scan') + .example('skills diff') + .example('skills scan --json') + .action( + async (action: string | undefined, options: SkillsScanCommandOptions) => { + const { scanIntentsOrFail, frozenOptionsFromGlobalFlags } = + await import('./commands/support.js') + const frozenOptions = frozenOptionsFromGlobalFlags(cli.rawArgs) + + if (action === 'scan') { + const { runSkillsScanCommand } = + await import('./commands/skills/scan.js') + await runSkillsScanCommand( + { ...options, ...frozenOptions }, + scanIntentsOrFail, + ) + return + } + + if (action === 'diff') { + const { runSkillsDiffCommand } = + await import('./commands/skills/diff.js') + await runSkillsDiffCommand( + { ...options, ...frozenOptions }, + scanIntentsOrFail, + ) + return + } + + fail('Unknown skills action: expected scan or diff.') + }, + ) + cli .command( 'edit-package-json', diff --git a/packages/intent/src/commands/skills/diff.ts b/packages/intent/src/commands/skills/diff.ts new file mode 100644 index 0000000..b70f92a --- /dev/null +++ b/packages/intent/src/commands/skills/diff.ts @@ -0,0 +1,85 @@ +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 { ScanResult } from '../../shared/types.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): void { + 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, + scanIntents: () => Promise, + cwd: string = process.cwd(), +): Promise { + const frozen = isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + const scan = await scanIntents() + const diff = buildSkillsDiff(scan, cwd) + + if (options.json) { + console.log(JSON.stringify({ frozen, ...diff }, null, 2)) + } else { + printDiffDetails(diff) + } + + enforceFrozenMode(diff, frozen) +} diff --git a/packages/intent/src/commands/skills/scan.ts b/packages/intent/src/commands/skills/scan.ts new file mode 100644 index 0000000..4c6832f --- /dev/null +++ b/packages/intent/src/commands/skills/scan.ts @@ -0,0 +1,52 @@ +import { isFrozenMode } from '../../shared/mode.js' +import { buildSkillsDiff, enforceFrozenMode } from './support.js' +import type { LockfileDiffResult } from '../../core/lockfile/lockfile-diff.js' +import type { ScanResult } from '../../shared/types.js' + +export interface SkillsScanCommandOptions { + json?: boolean + frozen?: boolean + noFrozen?: boolean +} + +function printScanSummary(diff: LockfileDiffResult): void { + 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, + scanIntents: () => Promise, + cwd: string = process.cwd(), +): Promise { + const frozen = isFrozenMode({ + frozen: options.frozen, + noFrozen: options.noFrozen, + }) + const scan = await scanIntents() + const diff = buildSkillsDiff(scan, cwd) + + if (options.json) { + console.log(JSON.stringify({ frozen, ...diff }, null, 2)) + } else { + printScanSummary(diff) + } + + enforceFrozenMode(diff, frozen) +} diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts new file mode 100644 index 0000000..834e462 --- /dev/null +++ b/packages/intent/src/commands/skills/support.ts @@ -0,0 +1,43 @@ +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 { 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') +} + +export function buildSkillsDiff( + scan: ScanResult, + cwd: string, +): LockfileDiffResult { + const current = buildCurrentLockfileSources(scan.packages) + const lockedResult = readIntentLockfile(resolveLockfilePath(cwd)) + return diffLockfileSources(current, lockedResult) +} + +// Frozen mode never mutates intent.lock — a missing or stale lockfile is a +// hard failure so CI can't silently drift from what was approved. +export function enforceFrozenMode( + diff: LockfileDiffResult, + frozen: boolean, +): void { + if (!frozen) return + + if (!diff.hasLockfile) { + fail( + 'Frozen mode requires intent.lock. Run `intent skills scan` outside frozen mode first.', + ) + } + if (!diff.isClean) { + fail( + 'intent.lock is out of date. Run `intent skills diff` outside frozen mode, then `intent skills approve`.', + ) + } +} diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index d6ea381..758df21 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -133,6 +133,20 @@ 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(argv: ReadonlyArray): { + frozen: boolean + noFrozen: boolean +} { + return { + frozen: argv.includes('--frozen'), + 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/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..fb4e566 --- /dev/null +++ b/packages/intent/src/shared/mode.ts @@ -0,0 +1,29 @@ +import { isEnvFlagSet } from './env-flag.js' + +export interface FrozenModeOptions { + frozen?: boolean + noFrozen?: boolean +} + +export interface FrozenModeContext { + isTTY?: boolean +} + +// --no-frozen only overrides the CI auto-detect, never an explicit --frozen +// or INTENT_FROZEN — the RFC's "(overridable with --no-frozen)" clause reads +// as scoped to the auto-detect condition it directly follows. +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 (isEnvFlagSet('INTENT_FROZEN')) return true + if (options.noFrozen) return false + + const isTTY = context.isTTY ?? process.stdin.isTTY + return isEnvFlagSet('CI') && isTTY !== true +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index aedcbe4..7a6c485 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -3541,6 +3541,111 @@ 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') + }) + + 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/mode.test.ts b/packages/intent/tests/mode.test.ts new file mode 100644 index 0000000..2b3836f --- /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 does not override an explicit INTENT_FROZEN=1', () => { + vi.stubEnv('CI', undefined) + vi.stubEnv('INTENT_FROZEN', '1') + + expect(isFrozenMode({ noFrozen: true }, { isTTY: true })).toBe(true) + }) + + it('--no-frozen does not override CI+INTENT_FROZEN stacked together', () => { + vi.stubEnv('CI', 'true') + vi.stubEnv('INTENT_FROZEN', '1') + + expect(isFrozenMode({ noFrozen: true }, { isTTY: undefined })).toBe(true) + }) + + 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/skills-diff.test.ts b/packages/intent/tests/skills-diff.test.ts new file mode 100644 index 0000000..d5c311c --- /dev/null +++ b/packages/intent/tests/skills-diff.test.ts @@ -0,0 +1,142 @@ +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 { 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 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', + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + }, + ], + }) + + await runSkillsDiffCommand( + {}, + () => Promise.resolve(emptyScanResult()), + 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 up to date when nothing has changed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsDiffCommand( + {}, + () => Promise.resolve(emptyScanResult()), + 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 a frozen field when --json is passed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsDiffCommand( + { json: true }, + () => Promise.resolve(emptyScanResult()), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + const parsed = JSON.parse(output) + expect(parsed).toMatchObject({ frozen: false, isClean: true }) + }) + + it('throws in frozen mode when intent.lock is missing', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsDiffCommand( + { frozen: true }, + () => Promise.resolve(emptyScanResult()), + 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(emptyScanResult()), + cwd, + ), + ).resolves.toBeUndefined() + }) +}) diff --git a/packages/intent/tests/skills-scan.test.ts b/packages/intent/tests/skills-scan.test.ts new file mode 100644 index 0000000..2e91839 --- /dev/null +++ b/packages/intent/tests/skills-scan.test.ts @@ -0,0 +1,184 @@ +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 { 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 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(emptyScanResult()), + 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(emptyScanResult()), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + 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', + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + }, + ], + }) + + await runSkillsScanCommand( + {}, + () => Promise.resolve(emptyScanResult()), + 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 a frozen field when --json is passed', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await runSkillsScanCommand( + { json: true }, + () => Promise.resolve(emptyScanResult()), + cwd, + ) + + const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') + const parsed = JSON.parse(output) + expect(parsed).toMatchObject({ frozen: false, isClean: true }) + }) + + it('throws in frozen mode when intent.lock is missing', async () => { + const cwd = makeTempProject() + + await expect( + runSkillsScanCommand( + { frozen: true }, + () => Promise.resolve(emptyScanResult()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Frozen mode requires intent.lock'), + }) + }) + + 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', + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + }, + ], + }) + + await expect( + runSkillsScanCommand( + { frozen: true }, + () => Promise.resolve(emptyScanResult()), + cwd, + ), + ).rejects.toMatchObject({ message: expect.stringContaining('out of date') }) + }) + + it('does not throw in frozen mode when intent.lock is clean', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) + + await expect( + runSkillsScanCommand( + { frozen: true }, + () => Promise.resolve(emptyScanResult()), + cwd, + ), + ).resolves.toBeUndefined() + }) +}) From b982fa04f544f5c27c3b094fd47510507f7bbebc Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 17:01:45 -0700 Subject: [PATCH 07/50] feat(skills): enhance scan and diff commands to handle unlisted skill-bearing sources --- packages/intent/src/cli.ts | 6 +- packages/intent/src/commands/skills/diff.ts | 25 +++++-- packages/intent/src/commands/skills/scan.ts | 23 ++++-- .../intent/src/commands/skills/support.ts | 9 ++- packages/intent/src/commands/support.ts | 14 +++- packages/intent/tests/cli.test.ts | 33 +++++++++ packages/intent/tests/skills-diff.test.ts | 62 ++++++++++++---- packages/intent/tests/skills-scan.test.ts | 70 ++++++++++++++----- 8 files changed, 191 insertions(+), 51 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 611f3f7..01d9454 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -223,7 +223,7 @@ function createCli(): CAC { .example('skills scan --json') .action( async (action: string | undefined, options: SkillsScanCommandOptions) => { - const { scanIntentsOrFail, frozenOptionsFromGlobalFlags } = + const { scanPolicedIntentsOrFail, frozenOptionsFromGlobalFlags } = await import('./commands/support.js') const frozenOptions = frozenOptionsFromGlobalFlags(cli.rawArgs) @@ -232,7 +232,7 @@ function createCli(): CAC { await import('./commands/skills/scan.js') await runSkillsScanCommand( { ...options, ...frozenOptions }, - scanIntentsOrFail, + scanPolicedIntentsOrFail, ) return } @@ -242,7 +242,7 @@ function createCli(): CAC { await import('./commands/skills/diff.js') await runSkillsDiffCommand( { ...options, ...frozenOptions }, - scanIntentsOrFail, + scanPolicedIntentsOrFail, ) return } diff --git a/packages/intent/src/commands/skills/diff.ts b/packages/intent/src/commands/skills/diff.ts index b70f92a..f375169 100644 --- a/packages/intent/src/commands/skills/diff.ts +++ b/packages/intent/src/commands/skills/diff.ts @@ -5,7 +5,7 @@ import type { LockfileSourceChange, } from '../../core/lockfile/lockfile-diff.js' import type { IntentLockfileSource } from '../../core/lockfile/lockfile.js' -import type { ScanResult } from '../../shared/types.js' +import type { PolicedScan } from '../../core/source-policy.js' export interface SkillsDiffCommandOptions { json?: boolean @@ -21,7 +21,16 @@ function formatChangeLabel(change: LockfileSourceChange): string { return `${change.kind}:${change.id}` } -function printDiffDetails(diff: LockfileDiffResult): void { +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.', @@ -65,21 +74,23 @@ function printDiffDetails(diff: LockfileDiffResult): void { export async function runSkillsDiffCommand( options: SkillsDiffCommandOptions, - scanIntents: () => Promise, + scanPolicedIntents: () => Promise, cwd: string = process.cwd(), ): Promise { const frozen = isFrozenMode({ frozen: options.frozen, noFrozen: options.noFrozen, }) - const scan = await scanIntents() + const { scan, hiddenSourceCount } = await scanPolicedIntents() const diff = buildSkillsDiff(scan, cwd) if (options.json) { - console.log(JSON.stringify({ frozen, ...diff }, null, 2)) + console.log( + JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2), + ) } else { - printDiffDetails(diff) + printDiffDetails(diff, hiddenSourceCount) } - enforceFrozenMode(diff, frozen) + enforceFrozenMode(diff, frozen, hiddenSourceCount) } diff --git a/packages/intent/src/commands/skills/scan.ts b/packages/intent/src/commands/skills/scan.ts index 4c6832f..384aa9c 100644 --- a/packages/intent/src/commands/skills/scan.ts +++ b/packages/intent/src/commands/skills/scan.ts @@ -1,7 +1,7 @@ import { isFrozenMode } from '../../shared/mode.js' import { buildSkillsDiff, enforceFrozenMode } from './support.js' import type { LockfileDiffResult } from '../../core/lockfile/lockfile-diff.js' -import type { ScanResult } from '../../shared/types.js' +import type { PolicedScan } from '../../core/source-policy.js' export interface SkillsScanCommandOptions { json?: boolean @@ -9,7 +9,16 @@ export interface SkillsScanCommandOptions { noFrozen?: boolean } -function printScanSummary(diff: LockfileDiffResult): void { +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.', @@ -32,21 +41,21 @@ function printScanSummary(diff: LockfileDiffResult): void { export async function runSkillsScanCommand( options: SkillsScanCommandOptions, - scanIntents: () => Promise, + scanPolicedIntents: () => Promise, cwd: string = process.cwd(), ): Promise { const frozen = isFrozenMode({ frozen: options.frozen, noFrozen: options.noFrozen, }) - const scan = await scanIntents() + const { scan, hiddenSourceCount } = await scanPolicedIntents() const diff = buildSkillsDiff(scan, cwd) if (options.json) { - console.log(JSON.stringify({ frozen, ...diff }, null, 2)) + console.log(JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2)) } else { - printScanSummary(diff) + printScanSummary(diff, hiddenSourceCount) } - enforceFrozenMode(diff, frozen) + enforceFrozenMode(diff, frozen, hiddenSourceCount) } diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts index 834e462..d1ea88a 100644 --- a/packages/intent/src/commands/skills/support.ts +++ b/packages/intent/src/commands/skills/support.ts @@ -22,14 +22,19 @@ export function buildSkillsDiff( return diffLockfileSources(current, lockedResult) } -// Frozen mode never mutates intent.lock — a missing or stale lockfile is a -// hard failure so CI can't silently drift from what was approved. 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.`, + ) + } + if (!diff.hasLockfile) { fail( 'Frozen mode requires intent.lock. Run `intent skills scan` outside frozen mode first.', diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index 758df21..c17c78d 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, @@ -83,15 +84,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)) } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 7a6c485..0430f39 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -3633,6 +3633,39 @@ describe('intent skills', () => { 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('fails on an unknown skills action', async () => { const root = makeSkillsProject() process.chdir(root) diff --git a/packages/intent/tests/skills-diff.test.ts b/packages/intent/tests/skills-diff.test.ts index d5c311c..1c2d60f 100644 --- a/packages/intent/tests/skills-diff.test.ts +++ b/packages/intent/tests/skills-diff.test.ts @@ -5,6 +5,7 @@ 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 { @@ -25,6 +26,17 @@ function emptyScanResult(): ScanResult { } as unknown as ScanResult } +function policedScan(overrides: Partial = {}): PolicedScan { + return { + scan: emptyScanResult(), + hiddenSourceCount: 0, + hiddenSources: [], + excludePatterns: [], + droppedNames: [], + ...overrides, + } +} + function baseLockfile(): IntentLockfile { return { lockfileVersion: 1, @@ -73,44 +85,55 @@ describe('runSkillsDiffCommand', () => { ], }) - await runSkillsDiffCommand( - {}, - () => Promise.resolve(emptyScanResult()), - cwd, - ) + 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 up to date when nothing has changed', async () => { + 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(emptyScanResult()), + () => 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('outputs JSON with a frozen field when --json is passed', async () => { + 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(emptyScanResult()), + () => 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, isClean: true }) + expect(parsed).toMatchObject({ + frozen: false, + hiddenSourceCount: 0, + isClean: true, + }) }) it('throws in frozen mode when intent.lock is missing', async () => { @@ -119,7 +142,7 @@ describe('runSkillsDiffCommand', () => { await expect( runSkillsDiffCommand( { frozen: true }, - () => Promise.resolve(emptyScanResult()), + () => Promise.resolve(policedScan()), cwd, ), ).rejects.toMatchObject({ @@ -134,9 +157,24 @@ describe('runSkillsDiffCommand', () => { await expect( runSkillsDiffCommand( { frozen: true }, - () => Promise.resolve(emptyScanResult()), + () => 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-scan.test.ts b/packages/intent/tests/skills-scan.test.ts index 2e91839..f21c449 100644 --- a/packages/intent/tests/skills-scan.test.ts +++ b/packages/intent/tests/skills-scan.test.ts @@ -5,6 +5,7 @@ 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 { @@ -25,6 +26,17 @@ function emptyScanResult(): ScanResult { } as unknown as ScanResult } +function policedScan(overrides: Partial = {}): PolicedScan { + return { + scan: emptyScanResult(), + hiddenSourceCount: 0, + hiddenSources: [], + excludePatterns: [], + droppedNames: [], + ...overrides, + } +} + function baseLockfile(): IntentLockfile { return { lockfileVersion: 1, @@ -56,11 +68,7 @@ describe('runSkillsScanCommand', () => { it('reports no lockfile when intent.lock is missing', async () => { const cwd = makeTempProject() - await runSkillsScanCommand( - {}, - () => Promise.resolve(emptyScanResult()), - cwd, - ) + 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') @@ -70,13 +78,24 @@ describe('runSkillsScanCommand', () => { 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(emptyScanResult()), + () => 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') }) @@ -100,30 +119,30 @@ describe('runSkillsScanCommand', () => { ], }) - await runSkillsScanCommand( - {}, - () => Promise.resolve(emptyScanResult()), - cwd, - ) + 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 a frozen field when --json is passed', async () => { + 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(emptyScanResult()), + () => 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, isClean: true }) + expect(parsed).toMatchObject({ + frozen: false, + hiddenSourceCount: 0, + isClean: true, + }) }) it('throws in frozen mode when intent.lock is missing', async () => { @@ -132,7 +151,7 @@ describe('runSkillsScanCommand', () => { await expect( runSkillsScanCommand( { frozen: true }, - () => Promise.resolve(emptyScanResult()), + () => Promise.resolve(policedScan()), cwd, ), ).rejects.toMatchObject({ @@ -163,20 +182,35 @@ describe('runSkillsScanCommand', () => { await expect( runSkillsScanCommand( { frozen: true }, - () => Promise.resolve(emptyScanResult()), + () => Promise.resolve(policedScan()), cwd, ), ).rejects.toMatchObject({ message: expect.stringContaining('out of date') }) }) - it('does not throw in frozen mode when intent.lock is clean', async () => { + 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 })), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('unlisted skill-bearing source'), + }) + }) + + 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(emptyScanResult()), + () => Promise.resolve(policedScan()), cwd, ), ).resolves.toBeUndefined() From 7959986bef3865ff730c619d27139235f1e98d1d Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 5 Jul 2026 17:22:10 -0700 Subject: [PATCH 08/50] feat(skills): add 'approve' action to skills command for managing intent.lock --- packages/intent/src/cli.ts | 35 +- .../intent/src/commands/skills/approve.ts | 237 ++++++++++ packages/intent/src/commands/skills/diff.ts | 4 +- .../intent/src/commands/skills/support.ts | 24 +- packages/intent/src/commands/support.ts | 8 + packages/intent/tests/cli.test.ts | 36 ++ packages/intent/tests/skills-approve.test.ts | 408 ++++++++++++++++++ 7 files changed, 742 insertions(+), 10 deletions(-) create mode 100644 packages/intent/src/commands/skills/approve.ts create mode 100644 packages/intent/tests/skills-approve.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 01d9454..114adc9 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -10,6 +10,7 @@ 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 { StaleCommandOptions } from './commands/stale.js' import type { ValidateCommandOptions } from './commands/validate.js' @@ -210,9 +211,18 @@ function createCli(): CAC { ) cli - .command('skills [action]', 'Scan or diff skills against intent.lock') - .usage('skills [--json] [--frozen] [--no-frozen]') + .command( + 'skills [action] [source]', + 'Scan, diff, or approve skills against intent.lock', + ) + .usage( + 'skills [source] [--json] [--all] [--frozen] [--no-frozen]', + ) .option('--json', 'Output JSON') + .option( + '--all', + 'With `approve`, accept all pending changes without prompting', + ) .option( '--frozen', 'Force frozen mode (fail if intent.lock is missing or stale)', @@ -221,8 +231,14 @@ function createCli(): CAC { .example('skills scan') .example('skills diff') .example('skills scan --json') + .example('skills approve --all') + .example('skills approve npm:@tanstack/query') .action( - async (action: string | undefined, options: SkillsScanCommandOptions) => { + async ( + action: string | undefined, + source: string | undefined, + options: SkillsScanCommandOptions & SkillsApproveCommandOptions, + ) => { const { scanPolicedIntentsOrFail, frozenOptionsFromGlobalFlags } = await import('./commands/support.js') const frozenOptions = frozenOptionsFromGlobalFlags(cli.rawArgs) @@ -247,7 +263,18 @@ function createCli(): CAC { return } - fail('Unknown skills action: expected scan or diff.') + if (action === 'approve') { + const { runSkillsApproveCommand } = + await import('./commands/skills/approve.js') + await runSkillsApproveCommand( + source, + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + fail('Unknown skills action: expected scan, diff, or approve.') }, ) diff --git a/packages/intent/src/commands/skills/approve.ts b/packages/intent/src/commands/skills/approve.ts new file mode 100644 index 0000000..d243150 --- /dev/null +++ b/packages/intent/src/commands/skills/approve.ts @@ -0,0 +1,237 @@ +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 } 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' +import type { SourceIdentity } from '../../core/types.js' + +export interface SkillsApproveCommandOptions { + all?: 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 parseSourceArg(arg: string): SourceIdentity { + const separatorIndex = arg.indexOf(':') + const kind = separatorIndex === -1 ? '' : arg.slice(0, separatorIndex) + let id = separatorIndex === -1 ? '' : 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 } +} + +function formatDisclosures(source: IntentLockfileSource): string { + const parts: Array = [] + if (source.capabilities.length > 0) { + parts.push(`capabilities: ${source.capabilities.join(', ')}`) + } + if (source.declaredSecrets.length > 0) { + parts.push(`declaredSecrets: ${source.declaredSecrets.join(', ')}`) + } + if (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})` + } + } +} + +export 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.') + } + + 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(parseSourceArg(sourceArg)) + 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) { + 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(), + 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 index f375169..807b74e 100644 --- a/packages/intent/src/commands/skills/diff.ts +++ b/packages/intent/src/commands/skills/diff.ts @@ -85,9 +85,7 @@ export async function runSkillsDiffCommand( const diff = buildSkillsDiff(scan, cwd) if (options.json) { - console.log( - JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2), - ) + console.log(JSON.stringify({ frozen, hiddenSourceCount, ...diff }, null, 2)) } else { printDiffDetails(diff, hiddenSourceCount) } diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts index d1ea88a..44c9132 100644 --- a/packages/intent/src/commands/skills/support.ts +++ b/packages/intent/src/commands/skills/support.ts @@ -5,6 +5,10 @@ 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 { ScanResult } from '../../shared/types.js' export function resolveLockfilePath(cwd: string): string { @@ -13,13 +17,27 @@ export function resolveLockfilePath(cwd: string): string { return join(root, 'intent.lock') } -export function buildSkillsDiff( +export interface LockfileState { + current: Array + lockedResult: ReadIntentLockfileResult + diff: LockfileDiffResult +} + +export function computeLockfileState( scan: ScanResult, cwd: string, -): LockfileDiffResult { +): LockfileState { const current = buildCurrentLockfileSources(scan.packages) const lockedResult = readIntentLockfile(resolveLockfilePath(cwd)) - return diffLockfileSources(current, lockedResult) + const diff = diffLockfileSources(current, lockedResult) + return { current, lockedResult, diff } +} + +export function buildSkillsDiff( + scan: ScanResult, + cwd: string, +): LockfileDiffResult { + return computeLockfileState(scan, cwd).diff } export function enforceFrozenMode( diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index c17c78d..d72a12c 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -32,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`. * diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 0430f39..e0dfc5e 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -3666,6 +3666,42 @@ describe('intent skills', () => { ) }) + 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('fails on an unknown skills action', async () => { const root = makeSkillsProject() process.chdir(root) diff --git a/packages/intent/tests/skills-approve.test.ts b/packages/intent/tests/skills-approve.test.ts new file mode 100644 index 0000000..f340938 --- /dev/null +++ b/packages/intent/tests/skills-approve.test.ts @@ -0,0 +1,408 @@ +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', + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + ...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'), + }) + }) + + 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('approves a single source id without touching unrelated pending changes', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + 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']) + } + }) + + 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( + 'not-a-valid-source', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Invalid source'), + }) + }) + + 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('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'), + }) + }) +}) From e7269ac31363a3fc8a380ee75f4095943c167114 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 5 Jul 2026 20:46:59 -0700 Subject: [PATCH 09/50] refactor(lockfile): change export to const and update interface visibility --- packages/intent/src/commands/skills/approve.ts | 2 +- packages/intent/src/core/lockfile/lockfile-diff.ts | 2 +- packages/intent/src/core/lockfile/lockfile.ts | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/intent/src/commands/skills/approve.ts b/packages/intent/src/commands/skills/approve.ts index d243150..fbbf94b 100644 --- a/packages/intent/src/commands/skills/approve.ts +++ b/packages/intent/src/commands/skills/approve.ts @@ -86,7 +86,7 @@ function describeChange(change: PendingChange): string { } } -export async function defaultConfirm(question: string): Promise { +async function defaultConfirm(question: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }) try { const answer = await rl.question(`${question} (y/N) `) diff --git a/packages/intent/src/core/lockfile/lockfile-diff.ts b/packages/intent/src/core/lockfile/lockfile-diff.ts index 3629b1b..8227bfa 100644 --- a/packages/intent/src/core/lockfile/lockfile-diff.ts +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -6,7 +6,7 @@ import type { } from './lockfile.js' import type { SourceIdentity } from '../types.js' -export type LockfileChangeField = +type LockfileChangeField = | 'version' | 'resolution' | 'contentHash' diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index d31f2b1..ddbf8ac 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { sourceIdentityKey } from '../types.js' -export const INTENT_LOCKFILE_VERSION = 1 +const INTENT_LOCKFILE_VERSION = 1 export interface IntentLockfile { lockfileVersion: 1 @@ -12,11 +12,11 @@ export interface IntentLockfile { policy: IntentLockfilePolicy } -export interface IntentLockfileStaleness { +interface IntentLockfileStaleness { baseline: IntentLockfileStalenessBaseline } -export interface IntentLockfileStalenessBaseline { +interface IntentLockfileStalenessBaseline { kind: 'tag' ref: string commit: string @@ -35,11 +35,11 @@ export interface IntentLockfileSource { mcpPolicy: Record } -export interface IntentLockfilePolicy { +interface IntentLockfilePolicy { ignores: Array } -export interface IntentLockfilePolicyIgnore { +interface IntentLockfilePolicyIgnore { id: string scope: { source: string From e330d7ca4b19fcbae964aaae939d4679041b4bf4 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 5 Jul 2026 21:12:40 -0700 Subject: [PATCH 10/50] feat: implement frozen mode checks to prevent network calls and subprocess execution --- packages/intent/src/shared/utils.ts | 31 ++---------- packages/intent/src/staleness/check.ts | 5 ++ .../intent/tests/global-node-modules.test.ts | 48 +++++++++++++++++++ packages/intent/tests/staleness.test.ts | 21 ++++++++ 4 files changed, 79 insertions(+), 26 deletions(-) create mode 100644 packages/intent/tests/global-node-modules.test.ts diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index f3d6930..9f5401a 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, @@ -270,6 +261,10 @@ export function detectGlobalNodeModules(packageManager: string): { } } + // Frozen mode forbids shelling out to a package manager; fail closed to + // "not found" rather than threading a frozen flag through every caller. + if (isFrozenMode()) return { path: null } + const commands: Array<{ command: string args: Array @@ -309,11 +304,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 +328,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 +348,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 +377,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 +397,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/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/global-node-modules.test.ts b/packages/intent/tests/global-node-modules.test.ts new file mode 100644 index 0000000..b803685 --- /dev/null +++ b/packages/intent/tests/global-node-modules.test.ts @@ -0,0 +1,48 @@ +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('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/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', From 4dba814044b67619ef9c33a5450452a545e8d7a2 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 5 Jul 2026 21:34:45 -0700 Subject: [PATCH 11/50] feat(skills): add 'update' action to skills command for managing intent.lock --- packages/intent/src/cli.ts | 25 +- .../intent/src/commands/skills/approve.ts | 28 +- .../intent/src/commands/skills/support.ts | 23 + packages/intent/src/commands/skills/update.ts | 136 ++++++ packages/intent/tests/cli.test.ts | 46 ++ packages/intent/tests/skills-update.test.ts | 406 ++++++++++++++++++ 6 files changed, 636 insertions(+), 28 deletions(-) create mode 100644 packages/intent/src/commands/skills/update.ts create mode 100644 packages/intent/tests/skills-update.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 114adc9..09b47fc 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -12,6 +12,7 @@ import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { SkillsApproveCommandOptions } from './commands/skills/approve.js' import type { SkillsScanCommandOptions } from './commands/skills/scan.js' +import type { SkillsUpdateCommandOptions } from './commands/skills/update.js' import type { StaleCommandOptions } from './commands/stale.js' import type { ValidateCommandOptions } from './commands/validate.js' @@ -213,15 +214,15 @@ function createCli(): CAC { cli .command( 'skills [action] [source]', - 'Scan, diff, or approve skills against intent.lock', + 'Scan, diff, approve, or update skills against intent.lock', ) .usage( - 'skills [source] [--json] [--all] [--frozen] [--no-frozen]', + 'skills [source] [--json] [--all] [--frozen] [--no-frozen]', ) .option('--json', 'Output JSON') .option( '--all', - 'With `approve`, accept all pending changes without prompting', + 'With `approve`/`update`, act on all pending changes without prompting', ) .option( '--frozen', @@ -233,11 +234,14 @@ function createCli(): CAC { .example('skills scan --json') .example('skills approve --all') .example('skills approve npm:@tanstack/query') + .example('skills update --all') .action( async ( action: string | undefined, source: string | undefined, - options: SkillsScanCommandOptions & SkillsApproveCommandOptions, + options: SkillsScanCommandOptions & + SkillsApproveCommandOptions & + SkillsUpdateCommandOptions, ) => { const { scanPolicedIntentsOrFail, frozenOptionsFromGlobalFlags } = await import('./commands/support.js') @@ -274,7 +278,18 @@ function createCli(): CAC { return } - fail('Unknown skills action: expected scan, diff, or approve.') + if (action === 'update') { + const { runSkillsUpdateCommand } = + await import('./commands/skills/update.js') + await runSkillsUpdateCommand( + source, + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + fail('Unknown skills action: expected scan, diff, approve, or update.') }, ) diff --git a/packages/intent/src/commands/skills/approve.ts b/packages/intent/src/commands/skills/approve.ts index fbbf94b..77b1eed 100644 --- a/packages/intent/src/commands/skills/approve.ts +++ b/packages/intent/src/commands/skills/approve.ts @@ -4,11 +4,14 @@ 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 } from './support.js' +import { + computeLockfileState, + parseSourceArg, + resolveLockfilePath, +} 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' -import type { SourceIdentity } from '../../core/types.js' export interface SkillsApproveCommandOptions { all?: boolean @@ -32,27 +35,6 @@ function compareStrings(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } -function parseSourceArg(arg: string): SourceIdentity { - const separatorIndex = arg.indexOf(':') - const kind = separatorIndex === -1 ? '' : arg.slice(0, separatorIndex) - let id = separatorIndex === -1 ? '' : 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 } -} - function formatDisclosures(source: IntentLockfileSource): string { const parts: Array = [] if (source.capabilities.length > 0) { diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts index 44c9132..60e0518 100644 --- a/packages/intent/src/commands/skills/support.ts +++ b/packages/intent/src/commands/skills/support.ts @@ -9,6 +9,7 @@ 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 { @@ -17,6 +18,28 @@ export function resolveLockfilePath(cwd: string): string { return join(root, 'intent.lock') } +// Shared by `approve` and `update`'s single-source argument form. +export function parseSourceArg(arg: string): SourceIdentity { + const separatorIndex = arg.indexOf(':') + const kind = separatorIndex === -1 ? '' : arg.slice(0, separatorIndex) + let id = separatorIndex === -1 ? '' : 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 } +} + export interface LockfileState { current: Array lockedResult: ReadIntentLockfileResult diff --git a/packages/intent/src/commands/skills/update.ts b/packages/intent/src/commands/skills/update.ts new file mode 100644 index 0000000..1b173ec --- /dev/null +++ b/packages/intent/src/commands/skills/update.ts @@ -0,0 +1,136 @@ +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, + parseSourceArg, + resolveLockfilePath, +} from './support.js' +import type { LockfileSourceChange } from '../../core/lockfile/lockfile-diff.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsUpdateCommandOptions { + all?: boolean + frozen?: boolean + noFrozen?: boolean +} + +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)}`, + ) + } + } +} + +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.') + } + + 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(parseSourceArg(sourceArg)) + 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.`, + ) + return + } + targets = [match] + } else { + targets = diff.changed + } + + if (targets.length === 0) { + console.log( + 'intent.lock already matches installed sources. Nothing to update.', + ) + return + } + + 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(), + sources: [...finalSources.values()], + policy: lockedResult.lockfile.policy, + }) + + printUpdated(targets) +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index e0dfc5e..ee80ecc 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -3702,6 +3702,52 @@ describe('intent skills', () => { ) }) + 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) diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts new file mode 100644 index 0000000..7898a8e --- /dev/null +++ b/packages/intent/tests/skills-update.test.ts @@ -0,0 +1,406 @@ +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', + manifestHash: null, + contentHash: 'sha256-aaa', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + mcpPolicy: {}, + ...overrides, + } +} + +describe('runSkillsUpdateCommand', () => { + 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-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'), + }) + }) + + 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() + globalThis.fetch = fetchSpy + + await runSkillsUpdateCommand( + undefined, + {}, + () => + 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('updates only the targeted source, leaving other drift untouched', async () => { + const cwd = makeTempProject() + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + sources: [ + lockedSource({ id: 'foo', version: '1.0.0' }), + lockedSource({ id: 'bar', version: '1.0.0' }), + ], + }) + + await runSkillsUpdateCommand( + 'npm:foo', + {}, + () => + 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') + } + }) + + 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 }, + () => + 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('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( + 'not-a-valid-source', + {}, + () => Promise.resolve(policedScan()), + cwd, + ), + ).rejects.toMatchObject({ + message: expect.stringContaining('Invalid source'), + }) + }) + + 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, + {}, + () => + 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('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) + }) +}) From 8120e0db7e138dd695d8d216f80d814c771080d5 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 5 Jul 2026 22:05:49 -0700 Subject: [PATCH 12/50] refactor(skills): replace parseSourceArg with resolveSourceArg for improved source resolution --- .../intent/src/commands/skills/approve.ts | 4 +- .../intent/src/commands/skills/support.ts | 51 ++++++--- packages/intent/src/commands/skills/update.ts | 4 +- packages/intent/tests/skills-approve.test.ts | 98 ++++++++++++++++- packages/intent/tests/skills-update.test.ts | 100 +++++++++++++++++- 5 files changed, 239 insertions(+), 18 deletions(-) diff --git a/packages/intent/src/commands/skills/approve.ts b/packages/intent/src/commands/skills/approve.ts index 77b1eed..2aa6c96 100644 --- a/packages/intent/src/commands/skills/approve.ts +++ b/packages/intent/src/commands/skills/approve.ts @@ -6,8 +6,8 @@ import { isFrozenMode } from '../../shared/mode.js' import { fail } from '../../shared/cli-error.js' import { computeLockfileState, - parseSourceArg, resolveLockfilePath, + resolveSourceArg, } from './support.js' import type { LockfileFieldChange } from '../../core/lockfile/lockfile-diff.js' import type { IntentLockfileSource } from '../../core/lockfile/lockfile.js' @@ -165,7 +165,7 @@ export async function runSkillsApproveCommand( let toApply: Array if (sourceArg) { - const identity = sourceIdentityKey(parseSourceArg(sourceArg)) + const identity = sourceIdentityKey(resolveSourceArg(sourceArg, current)) const match = changes.find((change) => change.identity === identity) if (!match) { fail( diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts index 60e0518..ef40202 100644 --- a/packages/intent/src/commands/skills/support.ts +++ b/packages/intent/src/commands/skills/support.ts @@ -19,25 +19,52 @@ export function resolveLockfilePath(cwd: string): string { } // Shared by `approve` and `update`'s single-source argument form. -export function parseSourceArg(arg: string): SourceIdentity { +export function resolveSourceArg( + arg: string, + discovered: ReadonlyArray, +): SourceIdentity { const separatorIndex = arg.indexOf(':') - const kind = separatorIndex === -1 ? '' : arg.slice(0, separatorIndex) - let id = separatorIndex === -1 ? '' : 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 (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 } } - if (kind !== 'npm' && kind !== 'workspace') { + // 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( - `Invalid source "${arg}". Expected the form kind:id, e.g. npm:@tanstack/query or workspace:my-package.`, + `No discovered source matches "${arg}". It may not be installed, or may not be listed in intent.skills.`, ) } - return { kind, id } + 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 { diff --git a/packages/intent/src/commands/skills/update.ts b/packages/intent/src/commands/skills/update.ts index 1b173ec..d54a1fa 100644 --- a/packages/intent/src/commands/skills/update.ts +++ b/packages/intent/src/commands/skills/update.ts @@ -5,8 +5,8 @@ import { isFrozenMode } from '../../shared/mode.js' import { fail } from '../../shared/cli-error.js' import { computeLockfileState, - parseSourceArg, resolveLockfilePath, + resolveSourceArg, } from './support.js' import type { LockfileSourceChange } from '../../core/lockfile/lockfile-diff.js' import type { PolicedScan } from '../../core/source-policy.js' @@ -63,7 +63,7 @@ export async function runSkillsUpdateCommand( let targets: Array if (sourceArg) { - const identity = sourceIdentityKey(parseSourceArg(sourceArg)) + const identity = sourceIdentityKey(resolveSourceArg(sourceArg, current)) const lockedByIdentity = new Set( lockedResult.lockfile.sources.map((source) => sourceIdentityKey(source)), ) diff --git a/packages/intent/tests/skills-approve.test.ts b/packages/intent/tests/skills-approve.test.ts index f340938..a6284e1 100644 --- a/packages/intent/tests/skills-approve.test.ts +++ b/packages/intent/tests/skills-approve.test.ts @@ -230,7 +230,7 @@ describe('runSkillsApproveCommand', () => { await expect( runSkillsApproveCommand( - 'not-a-valid-source', + 'git:not-a-supported-kind', {}, () => Promise.resolve(policedScan()), cwd, @@ -240,6 +240,102 @@ describe('runSkillsApproveCommand', () => { }) }) + 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'), { diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts index 7898a8e..503c2f4 100644 --- a/packages/intent/tests/skills-update.test.ts +++ b/packages/intent/tests/skills-update.test.ts @@ -336,7 +336,7 @@ describe('runSkillsUpdateCommand', () => { await expect( runSkillsUpdateCommand( - 'not-a-valid-source', + 'git:not-a-supported-kind', {}, () => Promise.resolve(policedScan()), cwd, @@ -346,6 +346,104 @@ describe('runSkillsUpdateCommand', () => { }) }) + 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', + {}, + () => + 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 = { From 4c5179c4481e4dce71e541ac27fa20817f1efdd9 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 5 Jul 2026 22:53:34 -0700 Subject: [PATCH 13/50] feat: add skills support to lockfile and related structures - Introduced 'skills' field in IntentLockfileSource and related types. - Updated lockfile diffing logic to compare skills. - Refactored source content hashing to handle skills independently. - Enhanced tests to cover new skills functionality and ensure backward compatibility. - Adjusted error handling and exit codes for various commands in frozen mode. --- packages/intent/src/cli.ts | 7 +- .../intent/src/commands/skills/approve.ts | 11 +- .../intent/src/commands/skills/support.ts | 16 +- packages/intent/src/commands/skills/update.ts | 2 +- packages/intent/src/core/lockfile/hash.ts | 274 +++-------- .../intent/src/core/lockfile/lockfile-diff.ts | 9 +- .../src/core/lockfile/lockfile-state.ts | 42 +- packages/intent/src/core/lockfile/lockfile.ts | 100 +++- packages/intent/src/discovery/scanner.ts | 2 +- packages/intent/src/shared/types.ts | 2 + packages/intent/src/shared/utils.ts | 10 +- .../intent/tests/global-node-modules.test.ts | 17 + packages/intent/tests/hash.test.ts | 460 +++++++++--------- packages/intent/tests/lockfile-diff.test.ts | 6 +- packages/intent/tests/lockfile-state.test.ts | 42 +- packages/intent/tests/lockfile.test.ts | 12 +- packages/intent/tests/skills-approve.test.ts | 7 +- packages/intent/tests/skills-diff.test.ts | 6 +- packages/intent/tests/skills-scan.test.ts | 34 +- packages/intent/tests/skills-update.test.ts | 7 +- 20 files changed, 520 insertions(+), 546 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 09b47fc..4d7a488 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -217,13 +217,17 @@ function createCli(): CAC { 'Scan, diff, approve, or update skills against intent.lock', ) .usage( - 'skills [source] [--json] [--all] [--frozen] [--no-frozen]', + 'skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen]', ) .option('--json', 'Output JSON') .option( '--all', 'With `approve`/`update`, act on all pending changes without prompting', ) + .option( + '--yes', + 'With `approve`, accept all pending changes non-interactively (scripted first-run)', + ) .option( '--frozen', 'Force frozen mode (fail if intent.lock is missing or stale)', @@ -233,6 +237,7 @@ function createCli(): CAC { .example('skills diff') .example('skills scan --json') .example('skills approve --all') + .example('skills approve --yes') .example('skills approve npm:@tanstack/query') .example('skills update --all') .action( diff --git a/packages/intent/src/commands/skills/approve.ts b/packages/intent/src/commands/skills/approve.ts index 2aa6c96..8b75e32 100644 --- a/packages/intent/src/commands/skills/approve.ts +++ b/packages/intent/src/commands/skills/approve.ts @@ -15,6 +15,7 @@ import type { PolicedScan } from '../../core/source-policy.js' export interface SkillsApproveCommandOptions { all?: boolean + yes?: boolean frozen?: boolean noFrozen?: boolean } @@ -37,13 +38,13 @@ function compareStrings(a: string, b: string): number { function formatDisclosures(source: IntentLockfileSource): string { const parts: Array = [] - if (source.capabilities.length > 0) { + if (source.capabilities && source.capabilities.length > 0) { parts.push(`capabilities: ${source.capabilities.join(', ')}`) } - if (source.declaredSecrets.length > 0) { + if (source.declaredSecrets && source.declaredSecrets.length > 0) { parts.push(`declaredSecrets: ${source.declaredSecrets.join(', ')}`) } - if (source.mcpTools.length > 0) { + if (source.mcpTools && source.mcpTools.length > 0) { parts.push(`mcpTools: ${source.mcpTools.join(', ')}`) } return parts.length > 0 ? ` [${parts.join('; ')}]` : '' @@ -90,7 +91,7 @@ export async function runSkillsApproveCommand( noFrozen: options.noFrozen, }) if (frozen) { - fail('`intent skills approve` cannot run in frozen mode.') + fail('`intent skills approve` cannot run in frozen mode.', 5) } if (sourceArg && options.all) { @@ -176,7 +177,7 @@ export async function runSkillsApproveCommand( } else if (changes.length === 0) { console.log('intent.lock is up to date. Nothing to approve.') return - } else if (options.all) { + } else if (options.all || options.yes) { toApply = changes } else { if (confirm === defaultConfirm && process.stdin.isTTY !== true) { diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts index ef40202..b2e02aa 100644 --- a/packages/intent/src/commands/skills/support.ts +++ b/packages/intent/src/commands/skills/support.ts @@ -78,11 +78,22 @@ export function computeLockfileState( cwd: string, ): LockfileState { const current = buildCurrentLockfileSources(scan.packages) - const lockedResult = readIntentLockfile(resolveLockfilePath(cwd)) + 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, @@ -100,17 +111,20 @@ export function enforceFrozenMode( 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 scan` 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 index d54a1fa..38a242b 100644 --- a/packages/intent/src/commands/skills/update.ts +++ b/packages/intent/src/commands/skills/update.ts @@ -44,7 +44,7 @@ export async function runSkillsUpdateCommand( noFrozen: options.noFrozen, }) if (frozen) { - fail('`intent skills update` cannot run in frozen mode.') + fail('`intent skills update` cannot run in frozen mode.', 5) } if (sourceArg && options.all) { diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index a62cb38..fc0dc5d 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,23 +1,21 @@ import { createHash } from 'node:crypto' -import { isAbsolute, join, relative } from 'node:path' +import { isAbsolute, relative } from 'node:path' import { closeSync, fstatSync, openSync, readFileSync, - readdirSync, realpathSync, - statSync, } from 'node:fs' -export interface SkillFile { +export interface SkillContentEntry { relativePath: string - content: Buffer + absolutePath: string } -export interface SkillFolderHash { - skillPath: string - hash: string +export interface SourceContentHash { + skills: Array + contentHash: string } const RECORD_SEPARATOR = Buffer.from([0]) @@ -44,165 +42,6 @@ function isWithinDir(candidate: string, dir: string): boolean { return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) } -interface FileEntry { - physicalPath: string - logicalRelativePath: string -} - -function readDirEntries(dir: string, logicalRelativePath: string) { - try { - return readdirSync(dir, { withFileTypes: true }) - } catch (err) { - throw new Error( - `Failed to list skill folder "${logicalRelativePath || '.'}": ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -// Recurses through the resolved real path once validated, not the original -// symlink, to avoid a TOCTOU window between the check and the read. -function collectFileEntries( - physicalDir: string, - logicalPrefix: string, - realRoot: string, - ancestors: Set, - excludeDirs: ReadonlySet, -): Array { - const entries = readDirEntries(physicalDir, logicalPrefix) - const files: Array = [] - - for (const entry of entries) { - const physicalEntryPath = join(physicalDir, entry.name) - const logicalRelativePath = logicalPrefix - ? `${logicalPrefix}/${entry.name}` - : entry.name - - if (entry.isSymbolicLink()) { - let realEntryPath: string - try { - realEntryPath = realpathSync(physicalEntryPath) - } catch (err) { - throw new Error( - `Failed to resolve skill folder symlink "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, - ) - } - - if (!isWithinDir(realEntryPath, realRoot)) { - throw new Error( - `Refusing to hash skill folder: "${logicalRelativePath}" escapes the skill folder via a symlink.`, - ) - } - - const stats = statSync(realEntryPath) - if (stats.isDirectory()) { - if (excludeDirs.has(realEntryPath)) continue - if (ancestors.has(realEntryPath)) { - throw new Error( - `Refusing to hash skill folder: "${logicalRelativePath}" is a symlink cycle.`, - ) - } - ancestors.add(realEntryPath) - for (const nested of collectFileEntries( - realEntryPath, - logicalRelativePath, - realRoot, - ancestors, - excludeDirs, - )) { - files.push(nested) - } - ancestors.delete(realEntryPath) - } else if (stats.isFile()) { - files.push({ physicalPath: realEntryPath, logicalRelativePath }) - } - continue - } - - if (entry.isDirectory()) { - if (excludeDirs.has(physicalEntryPath)) continue - for (const nested of collectFileEntries( - physicalEntryPath, - logicalRelativePath, - realRoot, - ancestors, - excludeDirs, - )) { - files.push(nested) - } - } else if (entry.isFile()) { - files.push({ physicalPath: physicalEntryPath, logicalRelativePath }) - } - } - - return files -} - -// Opens once and reads/verifies-type from that same fd rather than -// stat-by-path-then-open-by-path: the fd is bound to a specific inode, so a -// path swap after this call can't produce a torn read mixing old/new bytes. -function readRegularFile( - physicalPath: string, - logicalRelativePath: string, -): Buffer { - let fd: number - try { - fd = openSync(physicalPath, 'r') - } catch (err) { - throw new Error( - `Failed to read skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, - ) - } - - try { - if (!fstatSync(fd).isFile()) { - throw new Error( - `Failed to read skill file "${logicalRelativePath}": not a regular file.`, - ) - } - return readFileSync(fd) - } finally { - closeSync(fd) - } -} - -export function readSkillFolderFiles( - skillDir: string, - excludeDirs: ReadonlyArray = [], -): Array { - const realRoot = realpathSync(skillDir) - const realExcludeDirs = new Set( - excludeDirs - .map((dir) => { - try { - return realpathSync(dir) - } catch { - return null - } - }) - .filter((dir): dir is string => dir !== null), - ) - const entries = collectFileEntries( - realRoot, - '', - realRoot, - new Set([realRoot]), - realExcludeDirs, - ) - - const files = entries.map( - ({ physicalPath, logicalRelativePath }): SkillFile => { - const raw = readRegularFile(physicalPath, logicalRelativePath) - - return { - relativePath: logicalRelativePath, - content: isBinaryContent(raw) ? raw : normalizeLineEndings(raw), - } - }, - ) - - return files.sort((a, b) => compareStrings(a.relativePath, b.relativePath)) -} - function assertValidRelativePath(path: string, label: string): void { if (path.length === 0) { throw new Error(`Invalid ${label}: path must not be empty.`) @@ -237,7 +76,7 @@ function assertNoDuplicateKeys(keys: Array, label: string): void { } // Values are length-prefixed because content can contain NUL bytes. Keys -// (real filesystem paths) never can, but a JS string could, so that +// (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 }>, @@ -262,39 +101,86 @@ function hashEntries( return `sha256-${hash.digest('hex')}` } -export function hashSkillFolderFiles(files: ReadonlyArray): string { - for (const file of files) { - assertValidRelativePath(file.relativePath, 'skill file path') +// Opens once and reads/verifies-type from that same fd rather than +// stat-by-path-then-open-by-path: the fd is bound to a specific inode, so a +// path swap after this call can't produce a torn read mixing old/new bytes. +function readRegularFile( + physicalPath: string, + logicalRelativePath: string, +): Buffer { + let fd: number + try { + fd = openSync(physicalPath, 'r') + } catch (err) { + throw new Error( + `Failed to read skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, + ) } - assertNoDuplicateKeys( - files.map((file) => file.relativePath), - 'skill file path', - ) - return hashEntries( - files.map((file) => ({ key: file.relativePath, value: file.content })), - ) + try { + if (!fstatSync(fd).isFile()) { + throw new Error( + `Failed to read skill file "${logicalRelativePath}": not a regular file.`, + ) + } + return readFileSync(fd) + } finally { + closeSync(fd) + } } -export function hashSkillFolder( - skillDir: string, - excludeDirs: ReadonlyArray = [], -): string { - return hashSkillFolderFiles(readSkillFolderFiles(skillDir, excludeDirs)) +// Resolves through symlinks once, validated against the package root, not the +// original path, to avoid a TOCTOU window between the check and the read. +function readSkillMdContent( + absolutePath: string, + realPackageRoot: string, + logicalRelativePath: string, +): Buffer { + let realPath: string + try { + realPath = 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(realPath, logicalRelativePath) + return isBinaryContent(raw) ? raw : normalizeLineEndings(raw) } -export function hashSourceContent( - skillHashes: ReadonlyArray, -): string { +// M2 hashes only SKILL.md files (M2-SPEC §6.5); other files under skills/ +// are out of scope until a later milestone opts in. +export function computeSourceContentHash( + packageRoot: string, + entries: ReadonlyArray, +): SourceContentHash { + for (const entry of entries) { + assertValidRelativePath(entry.relativePath, 'skill path') + } assertNoDuplicateKeys( - skillHashes.map((entry) => entry.skillPath), - 'source skill path', + entries.map((entry) => entry.relativePath), + 'skill path', ) - return hashEntries( - skillHashes.map((entry) => ({ - key: entry.skillPath, - value: Buffer.from(entry.hash, 'utf8'), - })), - ) + const realPackageRoot = realpathSync(packageRoot) + const hashed = entries.map((entry) => ({ + key: entry.relativePath, + value: readSkillMdContent( + entry.absolutePath, + realPackageRoot, + entry.relativePath, + ), + })) + + return { + skills: entries.map((entry) => entry.relativePath).toSorted(compareStrings), + contentHash: hashEntries(hashed), + } } diff --git a/packages/intent/src/core/lockfile/lockfile-diff.ts b/packages/intent/src/core/lockfile/lockfile-diff.ts index 8227bfa..a2aa32f 100644 --- a/packages/intent/src/core/lockfile/lockfile-diff.ts +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -9,6 +9,7 @@ import type { SourceIdentity } from '../types.js' type LockfileChangeField = | 'version' | 'resolution' + | 'skills' | 'contentHash' | 'manifestHash' | 'capabilities' @@ -67,7 +68,12 @@ function diffFields( } const compareStructuredField = ( - field: 'capabilities' | 'declaredSecrets' | 'mcpTools' | 'mcpPolicy', + field: + | 'skills' + | 'capabilities' + | 'declaredSecrets' + | 'mcpTools' + | 'mcpPolicy', ): void => { const from = lockedCanonical[field] const to = currentCanonical[field] @@ -80,6 +86,7 @@ function diffFields( comparePrimitiveField('resolution') comparePrimitiveField('contentHash') comparePrimitiveField('manifestHash') + compareStructuredField('skills') compareStructuredField('capabilities') compareStructuredField('declaredSecrets') compareStructuredField('mcpTools') diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts index 8038c9d..da6181e 100644 --- a/packages/intent/src/core/lockfile/lockfile-state.ts +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -1,6 +1,7 @@ -import { dirname, relative, sep } from 'node:path' +import { relative, sep } from 'node:path' import { sourceIdentityKey } from '../types.js' -import { hashSkillFolder, hashSourceContent } from './hash.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' @@ -12,21 +13,13 @@ function compareStrings(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } -// A nested SKILL.md is its own skill root, not part of the parent's content, -// so the parent's hash must exclude it or double-count its bytes. -function buildSourceContentHash(pkg: IntentPackage): string { - const skillDirs = pkg.skills.map((skill) => dirname(skill.path)) +function buildSourceContent(pkg: IntentPackage): SourceContentHash { + const entries = pkg.skills.map((skill) => ({ + relativePath: toPosixPath(relative(pkg.packageRoot, skill.path)), + absolutePath: skill.path, + })) - const skillHashes = skillDirs.map((skillDir, index) => { - const otherSkillDirs = skillDirs.filter((_, i) => i !== index) - - return { - skillPath: toPosixPath(relative(pkg.packageRoot, skillDir)), - hash: hashSkillFolder(skillDir, otherSkillDirs), - } - }) - - return hashSourceContent(skillHashes) + return computeSourceContentHash(pkg.packageRoot, entries) } function buildResolution(pkg: IntentPackage): string | null { @@ -52,20 +45,19 @@ export function buildCurrentLockfileSources( packages: ReadonlyArray, ): Array { const sources = packages - .map( - (pkg): IntentLockfileSource => ({ + .map((pkg): IntentLockfileSource => { + const { skills, contentHash } = buildSourceContent(pkg) + return { id: pkg.name, kind: pkg.kind, version: pkg.version, resolution: buildResolution(pkg), + skills, + contentHash, manifestHash: null, - contentHash: buildSourceContentHash(pkg), - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, - }), - ) + capabilities: null, + } + }) .sort((a, b) => compareStrings(sourceIdentityKey(a), sourceIdentityKey(b))) assertUniqueIdentities(sources) diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index ddbf8ac..c8a2c41 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -25,14 +25,15 @@ interface IntentLockfileStalenessBaseline { export interface IntentLockfileSource { id: string kind: 'npm' | 'workspace' - version: string + version: string | null resolution: string | null - manifestHash: string | null + skills: Array contentHash: string - capabilities: Array - declaredSecrets: Array - mcpTools: Array - mcpPolicy: Record + manifestHash: string | null + capabilities: Array | null + declaredSecrets?: Array + mcpTools?: Array + mcpPolicy?: Record } interface IntentLockfilePolicy { @@ -90,6 +91,45 @@ function assertStringArray(value: unknown, label: string): Array { 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') const kind = source.kind @@ -102,20 +142,24 @@ function parseSource(value: unknown): IntentLockfileSource { return { id: assertString(source.id, 'source.id'), kind, - version: assertString(source.version, 'source.version'), + version: assertNullableString(source.version, 'source.version'), resolution: assertNullableString(source.resolution, 'source.resolution'), + skills: assertStringArray(source.skills, 'source.skills'), + contentHash: assertString(source.contentHash, 'source.contentHash'), manifestHash: assertNullableString( source.manifestHash, 'source.manifestHash', ), - contentHash: assertString(source.contentHash, 'source.contentHash'), - capabilities: assertStringArray(source.capabilities, 'source.capabilities'), - declaredSecrets: assertStringArray( + capabilities: assertNullableStringArray( + source.capabilities, + 'source.capabilities', + ), + declaredSecrets: assertOptionalStringArray( source.declaredSecrets, 'source.declaredSecrets', ), - mcpTools: assertStringArray(source.mcpTools, 'source.mcpTools'), - mcpPolicy: assertRecord(source.mcpPolicy, 'source.mcpPolicy'), + mcpTools: assertOptionalStringArray(source.mcpTools, 'source.mcpTools'), + mcpPolicy: assertOptionalRecord(source.mcpPolicy, 'source.mcpPolicy'), } } @@ -207,15 +251,26 @@ export function canonicalSource( kind: source.kind, version: source.version, resolution: source.resolution, - manifestHash: source.manifestHash, + skills: sortedStrings(source.skills), contentHash: source.contentHash, - capabilities: sortedStrings(source.capabilities), - declaredSecrets: sortedStrings(source.declaredSecrets), - mcpTools: sortedStrings(source.mcpTools), - mcpPolicy: canonicalJsonValue( - source.mcpPolicy, - 'source.mcpPolicy', - ) as Record, + 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, + } + : {}), } } @@ -276,13 +331,16 @@ export function parseIntentLockfile(content: string): IntentLockfile { 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: lockfile.sources.map(parseSource), + sources, policy: parsePolicy(lockfile.policy), }) } diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 622113c..5b95bc3 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -562,7 +562,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) diff --git a/packages/intent/src/shared/types.ts b/packages/intent/src/shared/types.ts index 994ed53..990ad21 100644 --- a/packages/intent/src/shared/types.ts +++ b/packages/intent/src/shared/types.ts @@ -33,6 +33,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 { diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index 9f5401a..10405bc 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -249,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 } { @@ -261,9 +264,8 @@ export function detectGlobalNodeModules(packageManager: string): { } } - // Frozen mode forbids shelling out to a package manager; fail closed to - // "not found" rather than threading a frozen flag through every caller. - if (isFrozenMode()) return { path: null } + // An explicit caller decision takes precedence over env-based detection. + if (frozen ?? isFrozenMode()) return { path: null } const commands: Array<{ command: string diff --git a/packages/intent/tests/global-node-modules.test.ts b/packages/intent/tests/global-node-modules.test.ts index b803685..49e58c3 100644 --- a/packages/intent/tests/global-node-modules.test.ts +++ b/packages/intent/tests/global-node-modules.test.ts @@ -33,6 +33,23 @@ describe('detectGlobalNodeModules', () => { 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' diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index b587aea..dc80280 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -8,12 +8,7 @@ import { import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' -import { - hashSkillFolder, - hashSkillFolderFiles, - hashSourceContent, - readSkillFolderFiles, -} from '../src/core/lockfile/hash.js' +import { computeSourceContentHash } from '../src/core/lockfile/hash.js' const roots: Array = [] @@ -23,15 +18,15 @@ function createRoot(): string { return root } -function writeSkillFolder( +function writeFile( dir: string, - files: Record, -): void { - for (const [relativePath, content] of Object.entries(files)) { - const filePath = join(dir, relativePath) - mkdirSync(join(filePath, '..'), { recursive: true }) - writeFileSync(filePath, content) - } + relativePath: string, + content: string | Buffer, +): string { + const filePath = join(dir, relativePath) + mkdirSync(join(filePath, '..'), { recursive: true }) + writeFileSync(filePath, content) + return filePath } afterEach(() => { @@ -40,176 +35,236 @@ afterEach(() => { } }) -describe('hashSkillFolderFiles', () => { +describe('computeSourceContentHash', () => { it('is deterministic for the same file set', () => { - const files = [ - { relativePath: 'SKILL.md', content: Buffer.from('hello') }, - { relativePath: 'references/a.md', content: Buffer.from('world') }, + 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(hashSkillFolderFiles(files)).toBe(hashSkillFolderFiles(files)) + expect(computeSourceContentHash(root, entries)).toEqual( + computeSourceContentHash(root, entries), + ) }) it('is independent of input array order', () => { - const a = { relativePath: 'SKILL.md', content: Buffer.from('hello') } - const b = { relativePath: 'references/a.md', content: Buffer.from('world') } + 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(hashSkillFolderFiles([a, b])).toBe(hashSkillFolderFiles([b, a])) + expect(computeSourceContentHash(root, [a, b]).contentHash).toBe( + computeSourceContentHash(root, [b, a]).contentHash, + ) }) - it('changes when file content changes', () => { - const base = [{ relativePath: 'SKILL.md', content: Buffer.from('hello') }] - const changed = [ - { relativePath: 'SKILL.md', content: Buffer.from('hello!') }, + 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(hashSkillFolderFiles(base)).not.toBe(hashSkillFolderFiles(changed)) + expect(computeSourceContentHash(root, entries).skills).toEqual([ + 'skills/a/SKILL.md', + 'skills/b/SKILL.md', + ]) }) - it('changes when a file is renamed with the same content', () => { - const original = [ - { relativePath: 'SKILL.md', content: Buffer.from('hello') }, - ] - const renamed = [ - { relativePath: 'SKILL2.md', content: Buffer.from('hello') }, + 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 - expect(hashSkillFolderFiles(original)).not.toBe( - hashSkillFolderFiles(renamed), - ) - }) - - it('does not collide across a path/content boundary shift', () => { - const a = [{ relativePath: 'a', content: Buffer.from('bc') }] - const b = [{ relativePath: 'ab', content: Buffer.from('c') }] + writeFileSync(filePath, 'hello!') - expect(hashSkillFolderFiles(a)).not.toBe(hashSkillFolderFiles(b)) + expect(computeSourceContentHash(root, entries).contentHash).not.toBe(before) }) - it('returns a sha256- prefixed digest', () => { - expect(hashSkillFolderFiles([])).toMatch(/^sha256-[0-9a-f]{64}$/) - }) + 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') - it('rejects duplicate relative paths', () => { - const files = [ - { relativePath: 'SKILL.md', content: Buffer.from('a') }, - { relativePath: 'SKILL.md', content: Buffer.from('b') }, - ] + const original = computeSourceContentHash(root, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: path1 }, + ]) + const renamed = computeSourceContentHash(root, [ + { relativePath: 'skills/b/SKILL.md', absolutePath: path2 }, + ]) - expect(() => hashSkillFolderFiles(files)).toThrow(/duplicate path/) + expect(original.contentHash).not.toBe(renamed.contentHash) }) - it('rejects an absolute relative path', () => { - const files = [{ relativePath: '/etc/passwd', content: Buffer.from('a') }] - - expect(() => hashSkillFolderFiles(files)).toThrow(/must be relative/) - }) + 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') - it("rejects a path containing a '..' segment", () => { - const files = [{ relativePath: '../outside.md', content: Buffer.from('a') }] + const hashA = computeSourceContentHash(root, [ + { relativePath: 'a', absolutePath: pathA }, + ]).contentHash + const hashB = computeSourceContentHash(root, [ + { relativePath: 'ab', absolutePath: pathB }, + ]).contentHash - expect(() => hashSkillFolderFiles(files)).toThrow(/segments/) + expect(hashA).not.toBe(hashB) }) - it('classifies a large buffer with a NUL byte past the first 8000 bytes as binary', () => { - const content = Buffer.concat([ - Buffer.alloc(9000, 0x41), - Buffer.from([0x00]), - Buffer.from('\r\n'), - ]) - const files = [{ relativePath: 'assets/large.bin', content }] - - expect(hashSkillFolderFiles(files)).toMatch(/^sha256-[0-9a-f]{64}$/) + 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 a relative path containing an embedded NUL byte', () => { - const files = [ - { relativePath: 'assets/a\0b.md', content: Buffer.from('a') }, - ] - - expect(() => hashSkillFolderFiles(files)).toThrow(/NUL byte/) + 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/) }) -}) -describe('hashSourceContent', () => { - it('is deterministic regardless of input order', () => { - const a = { skillPath: 'skills/a', hash: 'sha256-aaa' } - const b = { skillPath: 'skills/b', hash: 'sha256-bbb' } + it('rejects an absolute relative path', () => { + const root = createRoot() + const filePath = writeFile(root, 'SKILL.md', 'a') - expect(hashSourceContent([a, b])).toBe(hashSourceContent([b, a])) + expect(() => + computeSourceContentHash(root, [ + { relativePath: '/etc/passwd', absolutePath: filePath }, + ]), + ).toThrow(/must be relative/) }) - it('changes when any per-skill hash changes', () => { - const before = [{ skillPath: 'skills/a', hash: 'sha256-aaa' }] - const after = [{ skillPath: 'skills/a', hash: 'sha256-zzz' }] + it("rejects a path containing a '..' segment", () => { + const root = createRoot() + const filePath = writeFile(root, 'SKILL.md', 'a') - expect(hashSourceContent(before)).not.toBe(hashSourceContent(after)) + expect(() => + computeSourceContentHash(root, [ + { relativePath: '../outside.md', absolutePath: filePath }, + ]), + ).toThrow(/segments/) }) - it('rejects duplicate skill paths', () => { - const duplicates = [ - { skillPath: 'skills/a', hash: 'sha256-aaa' }, - { skillPath: 'skills/a', hash: 'sha256-bbb' }, - ] + it('rejects a relative path containing an embedded NUL byte', () => { + const root = createRoot() + const filePath = writeFile(root, 'SKILL.md', 'a') - expect(() => hashSourceContent(duplicates)).toThrow(/duplicate path/) + expect(() => + computeSourceContentHash(root, [ + { relativePath: 'assets/a\0b.md', absolutePath: filePath }, + ]), + ).toThrow(/NUL byte/) }) -}) -describe('readSkillFolderFiles', () => { - it('reads SKILL.md plus nested references/assets/scripts files', () => { + it('normalizes CRLF and lone CR to LF in text content', () => { const root = createRoot() - writeSkillFolder(root, { - 'SKILL.md': 'body', - 'references/deep-dive.md': 'reference', - 'assets/notes.txt': 'asset', - 'scripts/setup.sh': 'echo hi', - }) - - const files = readSkillFolderFiles(root) - - expect(files.map((file) => file.relativePath).sort()).toEqual([ + const filePath = writeFile( + root, 'SKILL.md', - 'assets/notes.txt', - 'references/deep-dive.md', - 'scripts/setup.sh', + 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('normalizes CRLF and lone CR to LF in text files', () => { + it('classifies a large buffer with a NUL byte past the first bytes as binary (no CRLF normalization)', () => { const root = createRoot() - writeSkillFolder(root, { - 'SKILL.md': Buffer.from('line1\r\nline2\rline3\n'), - }) - - const [file] = readSkillFolderFiles(root) + const content = Buffer.concat([ + Buffer.alloc(9000, 0x41), + Buffer.from([0x00]), + Buffer.from('\r\n'), + ]) + const filePath = writeFile(root, 'SKILL.md', content) - expect(file!.content.toString('utf8')).toBe('line1\nline2\nline3\n') + expect( + computeSourceContentHash(root, [ + { relativePath: 'SKILL.md', absolutePath: filePath }, + ]).contentHash, + ).toMatch(/^sha256-[0-9a-f]{64}$/) }) - it('leaves binary content byte-exact', () => { - const root = createRoot() - const binary = Buffer.from([0x00, 0x0d, 0x0a, 0xff, 0x01]) - writeSkillFolder(root, { 'assets/image.bin': binary }) + 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 [file] = readSkillFolderFiles(root) + 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(file!.content.equals(binary)).toBe(true) + expect(hashA).toBe(hashB) }) - it('uses forward-slash relative paths regardless of platform separator', () => { + it('does not hash a non-SKILL.md file even if present in the skill folder', () => { const root = createRoot() - writeSkillFolder(root, { 'references/nested/deep.md': 'content' }) + 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 - const files = readSkillFolderFiles(root) + writeFileSync(join(root, 'skills/a/references/deep-dive.md'), 'changed') - expect(files.some((file) => file.relativePath.includes('\\'))).toBe(false) expect( - files.some((file) => file.relativePath === 'references/nested/deep.md'), - ).toBe(true) + computeSourceContentHash(root, [ + { relativePath: 'skills/a/SKILL.md', absolutePath: skillPath }, + ]).contentHash, + ).toBe(before) }) - it('fails closed when a symlink escapes the skill folder', () => { + it('fails closed when a symlinked SKILL.md escapes the package root', () => { const root = createRoot() const outside = join( root, @@ -218,131 +273,62 @@ describe('readSkillFolderFiles', () => { ) mkdirSync(outside, { recursive: true }) writeFileSync(join(outside, 'secret.md'), 'leaked') - mkdirSync(join(root, 'references'), { recursive: true }) - symlinkSync( - join(outside, 'secret.md'), - join(root, 'references', 'linked.md'), - ) - - expect(() => readSkillFolderFiles(root)).toThrow(/escapes the skill folder/) - - rmSync(outside, { recursive: true, force: true }) - }) - - it('fails closed when a symlinked directory escapes the skill folder', () => { - const root = createRoot() - const outside = join( - root, - '..', - 'outside-dir-' + Math.random().toString(36).slice(2), - ) - mkdirSync(outside, { recursive: true }) - writeFileSync(join(outside, 'secret.md'), 'leaked') - symlinkSync(outside, join(root, 'linked-dir'), 'dir') - - expect(() => readSkillFolderFiles(root)).toThrow(/escapes the skill folder/) + 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() - symlinkSync(join(root, 'missing-target.md'), join(root, 'broken.md')) - - expect(() => readSkillFolderFiles(root)).toThrow( - /Failed to resolve skill folder symlink/, + mkdirSync(join(root, 'skills/a'), { recursive: true }) + symlinkSync( + join(root, 'skills/a', 'missing-target.md'), + join(root, 'skills/a', 'SKILL.md'), ) - }) - - it('fails closed on a symlink cycle', () => { - const root = createRoot() - mkdirSync(join(root, 'a')) - symlinkSync(root, join(root, 'a', 'back-to-root'), 'dir') - expect(() => readSkillFolderFiles(root)).toThrow(/symlink cycle/) + 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() - writeFileSync(join(root, 'canonical.md'), 'shared content') - symlinkSync(join(root, 'canonical.md'), join(root, 'link.md')) - - const files = readSkillFolderFiles(root) + writeFile(root, 'skills/a/canonical.md', 'shared content') + symlinkSync( + join(root, 'skills/a/canonical.md'), + join(root, 'skills/a/SKILL.md'), + ) - expect(files).toEqual([ - { relativePath: 'canonical.md', content: Buffer.from('shared content') }, - { relativePath: 'link.md', content: Buffer.from('shared content') }, + 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'), + }, ]) - }) -}) - -describe('hashSkillFolder', () => { - it('is stable across two calls', () => { - const root = createRoot() - writeSkillFolder(root, { 'SKILL.md': 'body' }) - - expect(hashSkillFolder(root)).toBe(hashSkillFolder(root)) - }) - - it('changes when a nested reference file changes', () => { - const root = createRoot() - writeSkillFolder(root, { - 'SKILL.md': 'body', - 'references/a.md': 'version 1', - }) - const before = hashSkillFolder(root) - - writeFileSync(join(root, 'references', 'a.md'), 'version 2') - - expect(hashSkillFolder(root)).not.toBe(before) - }) - - it('is identical across different physical roots for identical relative structure and bytes', () => { - const rootA = createRoot() - const rootB = createRoot() - const files = { - 'SKILL.md': 'shared body', - 'references/a.md': 'shared reference', - } - writeSkillFolder(rootA, files) - writeSkillFolder(rootB, files) - - expect(hashSkillFolder(rootA)).toBe(hashSkillFolder(rootB)) - }) - - it('includes a nested skill folder by default (no exclusion)', () => { - const root = createRoot() - const nestedDir = join(root, 'nested-skill') - writeSkillFolder(root, { 'SKILL.md': 'parent body' }) - writeSkillFolder(nestedDir, { 'SKILL.md': 'nested body' }) - - const before = hashSkillFolder(root) - writeFileSync(join(nestedDir, 'SKILL.md'), 'nested body changed') - - expect(hashSkillFolder(root)).not.toBe(before) - }) - - it('excludes a nested skill folder when passed as excludeDirs', () => { - const root = createRoot() - const nestedDir = join(root, 'nested-skill') - writeSkillFolder(root, { 'SKILL.md': 'parent body' }) - writeSkillFolder(nestedDir, { 'SKILL.md': 'nested body' }) - - const before = hashSkillFolder(root, [nestedDir]) - writeFileSync(join(nestedDir, 'SKILL.md'), 'nested body changed') - - expect(hashSkillFolder(root, [nestedDir])).toBe(before) - }) - - it("still changes when the parent's own content changes despite an exclusion", () => { - const root = createRoot() - const nestedDir = join(root, 'nested-skill') - writeSkillFolder(root, { 'SKILL.md': 'parent body' }) - writeSkillFolder(nestedDir, { 'SKILL.md': 'nested body' }) - - const before = hashSkillFolder(root, [nestedDir]) - writeFileSync(join(root, 'SKILL.md'), 'parent body changed') - expect(hashSkillFolder(root, [nestedDir])).not.toBe(before) + // 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/lockfile-diff.test.ts b/packages/intent/tests/lockfile-diff.test.ts index c034e07..ed92583 100644 --- a/packages/intent/tests/lockfile-diff.test.ts +++ b/packages/intent/tests/lockfile-diff.test.ts @@ -12,12 +12,10 @@ function createSource( return { version: '1.0.0', resolution: null, + skills: [], manifestHash: null, contentHash: 'sha256-aaa', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + capabilities: null, ...overrides, } } diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts index 0874f32..b14fc68 100644 --- a/packages/intent/tests/lockfile-state.test.ts +++ b/packages/intent/tests/lockfile-state.test.ts @@ -3,10 +3,6 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' -import { - hashSkillFolder, - hashSourceContent, -} from '../src/core/lockfile/hash.js' import type { IntentPackage } from '../src/shared/types.js' const roots: Array = [] @@ -67,11 +63,12 @@ describe('buildCurrentLockfileSources', () => { version: '5.0.0', resolution: 'npm:@tanstack/query@5.0.0', manifestHash: null, - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + 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}$/) }) @@ -201,7 +198,7 @@ describe('buildCurrentLockfileSources', () => { expect(hashA).toBe(hashB) }) - it('excludes a nested skill folder from its parent skill hash', () => { + 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') @@ -219,20 +216,21 @@ describe('buildCurrentLockfileSources', () => { ], }) - const expectedHash = hashSourceContent([ - { - skillPath: 'skills/parent', - hash: hashSkillFolder(parentDir, [nestedDir]), - }, - { - skillPath: 'skills/parent/nested', - hash: hashSkillFolder(nestedDir), - }, - ]) + const before = buildCurrentLockfileSources([pkg])[0]!.contentHash - expect(buildCurrentLockfileSources([pkg])[0]!.contentHash).toBe( - expectedHash, - ) + // 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', () => { diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts index dec0225..c80ae5a 100644 --- a/packages/intent/tests/lockfile.test.ts +++ b/packages/intent/tests/lockfile.test.ts @@ -35,24 +35,20 @@ function createLockfile(): IntentLockfile { kind: 'workspace', version: '1.42.0', resolution: null, + skills: [], manifestHash: null, contentHash: 'sha256-workspace-router', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + 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: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + capabilities: null, }, ], policy: { diff --git a/packages/intent/tests/skills-approve.test.ts b/packages/intent/tests/skills-approve.test.ts index a6284e1..782c593 100644 --- a/packages/intent/tests/skills-approve.test.ts +++ b/packages/intent/tests/skills-approve.test.ts @@ -57,12 +57,10 @@ function lockedSource( kind: 'npm', version: '1.0.0', resolution: 'npm:foo@1.0.0', + skills: [], manifestHash: null, contentHash: 'sha256-aaa', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + capabilities: null, ...overrides, } } @@ -98,6 +96,7 @@ describe('runSkillsApproveCommand', () => { ), ).rejects.toMatchObject({ message: expect.stringContaining('cannot run in frozen mode'), + exitCode: 5, }) }) diff --git a/packages/intent/tests/skills-diff.test.ts b/packages/intent/tests/skills-diff.test.ts index 1c2d60f..3e82368 100644 --- a/packages/intent/tests/skills-diff.test.ts +++ b/packages/intent/tests/skills-diff.test.ts @@ -75,12 +75,10 @@ describe('runSkillsDiffCommand', () => { kind: 'npm', version: '1.0.0', resolution: 'npm:foo@1.0.0', + skills: [], manifestHash: null, contentHash: 'sha256-aaa', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + capabilities: null, }, ], }) diff --git a/packages/intent/tests/skills-scan.test.ts b/packages/intent/tests/skills-scan.test.ts index f21c449..a512533 100644 --- a/packages/intent/tests/skills-scan.test.ts +++ b/packages/intent/tests/skills-scan.test.ts @@ -109,12 +109,10 @@ describe('runSkillsScanCommand', () => { kind: 'npm', version: '1.0.0', resolution: 'npm:foo@1.0.0', + skills: [], manifestHash: null, contentHash: 'sha256-aaa', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + capabilities: null, }, ], }) @@ -156,6 +154,7 @@ describe('runSkillsScanCommand', () => { ), ).rejects.toMatchObject({ message: expect.stringContaining('Frozen mode requires intent.lock'), + exitCode: 4, }) }) @@ -169,12 +168,10 @@ describe('runSkillsScanCommand', () => { kind: 'npm', version: '1.0.0', resolution: 'npm:foo@1.0.0', + skills: [], manifestHash: null, contentHash: 'sha256-aaa', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + capabilities: null, }, ], }) @@ -185,7 +182,10 @@ describe('runSkillsScanCommand', () => { () => Promise.resolve(policedScan()), cwd, ), - ).rejects.toMatchObject({ message: expect.stringContaining('out of date') }) + ).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 () => { @@ -200,6 +200,7 @@ describe('runSkillsScanCommand', () => { ), ).rejects.toMatchObject({ message: expect.stringContaining('unlisted skill-bearing source'), + exitCode: 3, }) }) @@ -215,4 +216,19 @@ describe('runSkillsScanCommand', () => { ), ).resolves.toBeUndefined() }) + + it('fails with exit code 6 when intent.lock is malformed', async () => { + const cwd = makeTempProject() + writeFileSync( + join(cwd, 'intent.lock'), + JSON.stringify({ lockfileVersion: 2 }), + ) + + await expect( + runSkillsScanCommand({}, () => Promise.resolve(policedScan()), cwd), + ).rejects.toMatchObject({ + message: expect.stringContaining('Malformed intent.lock'), + exitCode: 6, + }) + }) }) diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts index 503c2f4..cfafd3e 100644 --- a/packages/intent/tests/skills-update.test.ts +++ b/packages/intent/tests/skills-update.test.ts @@ -57,12 +57,10 @@ function lockedSource( kind: 'npm', version: '1.0.0', resolution: 'npm:foo@1.0.0', + skills: [], manifestHash: null, contentHash: 'sha256-aaa', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - mcpPolicy: {}, + capabilities: null, ...overrides, } } @@ -98,6 +96,7 @@ describe('runSkillsUpdateCommand', () => { ), ).rejects.toMatchObject({ message: expect.stringContaining('cannot run in frozen mode'), + exitCode: 5, }) }) From a3bb1f3aa99e566eba3574779add1cb8b9f9ab3e Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 8 Jul 2026 17:58:22 -0700 Subject: [PATCH 14/50] feat(skills): enhance update command to report pending added/removed sources --- packages/intent/package.json | 3 + packages/intent/src/commands/skills/update.ts | 18 +++- .../tests/skills-resolve-source-arg.test.ts | 86 +++++++++++++++++++ packages/intent/tests/skills-update.test.ts | 43 ++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/intent/tests/skills-resolve-source-arg.test.ts 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/commands/skills/update.ts b/packages/intent/src/commands/skills/update.ts index 38a242b..c76c012 100644 --- a/packages/intent/src/commands/skills/update.ts +++ b/packages/intent/src/commands/skills/update.ts @@ -8,7 +8,10 @@ import { resolveLockfilePath, resolveSourceArg, } from './support.js' -import type { LockfileSourceChange } from '../../core/lockfile/lockfile-diff.js' +import type { + LockfileDiffResult, + LockfileSourceChange, +} from '../../core/lockfile/lockfile-diff.js' import type { PolicedScan } from '../../core/source-policy.js' export interface SkillsUpdateCommandOptions { @@ -33,6 +36,16 @@ function printUpdated(changes: ReadonlyArray): void { } } +// 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, @@ -90,6 +103,7 @@ export async function runSkillsUpdateCommand( console.log( `intent.lock already matches the installed state for "${sourceArg}". Nothing to update.`, ) + printPendingAddRemove(diff) return } targets = [match] @@ -101,6 +115,7 @@ export async function runSkillsUpdateCommand( console.log( 'intent.lock already matches installed sources. Nothing to update.', ) + printPendingAddRemove(diff) return } @@ -133,4 +148,5 @@ export async function runSkillsUpdateCommand( }) printUpdated(targets) + printPendingAddRemove(diff) } 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-update.test.ts b/packages/intent/tests/skills-update.test.ts index cfafd3e..5e18f68 100644 --- a/packages/intent/tests/skills-update.test.ts +++ b/packages/intent/tests/skills-update.test.ts @@ -269,6 +269,49 @@ describe('runSkillsUpdateCommand', () => { } }) + 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 }, + () => + 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'), { From 50adc0e8001b53774f9bfc02f32eb94c2db4eba0 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 8 Jul 2026 18:31:13 -0700 Subject: [PATCH 15/50] fix: update --no-frozen option behavior to override INTENT_FROZEN and CI auto-detection --- packages/intent/src/cli.ts | 5 ++++- packages/intent/src/shared/mode.ts | 7 +++---- packages/intent/tests/mode.test.ts | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 4d7a488..4d9a80c 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -232,7 +232,10 @@ function createCli(): CAC { '--frozen', 'Force frozen mode (fail if intent.lock is missing or stale)', ) - .option('--no-frozen', 'Disable CI auto-detection of frozen mode') + .option( + '--no-frozen', + 'Force interactive mode, overriding INTENT_FROZEN/CI auto-detect', + ) .example('skills scan') .example('skills diff') .example('skills scan --json') diff --git a/packages/intent/src/shared/mode.ts b/packages/intent/src/shared/mode.ts index fb4e566..0ea748d 100644 --- a/packages/intent/src/shared/mode.ts +++ b/packages/intent/src/shared/mode.ts @@ -9,9 +9,8 @@ export interface FrozenModeContext { isTTY?: boolean } -// --no-frozen only overrides the CI auto-detect, never an explicit --frozen -// or INTENT_FROZEN — the RFC's "(overridable with --no-frozen)" clause reads -// as scoped to the auto-detect condition it directly follows. +// 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 = {}, @@ -21,8 +20,8 @@ export function isFrozenMode( } if (options.frozen) return true - if (isEnvFlagSet('INTENT_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/tests/mode.test.ts b/packages/intent/tests/mode.test.ts index 2b3836f..b59b97c 100644 --- a/packages/intent/tests/mode.test.ts +++ b/packages/intent/tests/mode.test.ts @@ -96,18 +96,18 @@ describe('isFrozenMode', () => { expect(isFrozenMode({ noFrozen: true }, { isTTY: undefined })).toBe(false) }) - it('--no-frozen does not override an explicit INTENT_FROZEN=1', () => { + 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(true) + expect(isFrozenMode({ noFrozen: true }, { isTTY: true })).toBe(false) }) - it('--no-frozen does not override CI+INTENT_FROZEN stacked together', () => { + 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(true) + expect(isFrozenMode({ noFrozen: true }, { isTTY: undefined })).toBe(false) }) it('throws when both --frozen and --no-frozen are passed', () => { From aee4218c621d16a4c87d34158ef49626463e3c26 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 8 Jul 2026 19:03:54 -0700 Subject: [PATCH 16/50] feat: add documentation for intent skills and lockfile management --- docs/cli/intent-skills.md | 95 +++++++++++++++++++++++++++++++++++++++ docs/config.json | 8 ++++ docs/security/lockfile.md | 75 +++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 docs/cli/intent-skills.md create mode 100644 docs/security/lockfile.md diff --git a/docs/cli/intent-skills.md b/docs/cli/intent-skills.md new file mode 100644 index 0000000..4f2f7d2 --- /dev/null +++ b/docs/cli/intent-skills.md @@ -0,0 +1,95 @@ +--- +title: intent skills +id: intent-skills +--- + +`intent skills` manages `intent.lock`, the committed record of which skill-bearing sources you've approved and what their content looked like when you approved it. Four subcommands: `scan`, `diff` (read-only), `approve`, `update` (mutating). + +```bash +npx @tanstack/intent@latest skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen] +``` + +See [Lockfile and frozen mode](../security/lockfile) for what `intent.lock` is and what frozen mode guarantees. + +## `intent skills scan` + +```bash +npx @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] +``` + +Writes `intent.lock`. This is the mechanical refresh, not a trust decision: it re-reads currently-installed sources and re-syncs the matching **already-locked** entries' `version`, `resolution`, `skills`, and `contentHash` to whatever's installed now. + +- Only touches sources present in **both** the lock and the current scan. It never adds a newly-discovered source (that's `approve`'s job) and never drops a source that's no longer discovered (also `approve`'s job — removing a source from the trust boundary is itself a trust decision). +- Reports pending added/removed drift it didn't touch: `N added, M removed source(s) still pending. Run \`intent skills approve\` to review.` +- Makes zero network calls and zero subprocess calls — it only reads what's already on disk. +- Refuses in frozen mode (exit `5`). + +## Options + +- `--json`: with `scan`/`diff`, print the structured diff instead of text +- `--all`: with `approve`/`update`, act on all pending changes without prompting +- `--yes`: with `approve`, accept all pending changes non-interactively (alias for `--all`'s non-interactive behavior, for scripted first-run) +- `--frozen`: force frozen mode, regardless of `INTENT_FROZEN`/`CI` auto-detection +- `--no-frozen`: force interactive mode — overrides `INTENT_FROZEN` and the `CI` auto-detect (highest-precedence explicit override) + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | ok | +| `1` | generic CLI usage/parse error | +| `2` | drift found under frozen mode | +| `3` | unapproved/unlisted skill-bearing source found under frozen mode | +| `4` | no `intent.lock` found under frozen mode | +| `5` | `approve`/`update` refused — frozen mode disallows mutation | +| `6` | `intent.lock` is malformed or from an unsupported (newer) `lockfileVersion` | + +## Related + +- [Lockfile and frozen mode](../security/lockfile) +- [Trust model](../concepts/trust-model) diff --git a/docs/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..609b759 --- /dev/null +++ b/docs/security/lockfile.md @@ -0,0 +1,75 @@ +--- +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 that fed `contentHash`. It's what lets `diff` show *which files* changed, not just an opaque hash flip. +- **`contentHash`** is a `sha256-` digest over each source's `SKILL.md` files (path + bytes, LF-normalized). Only `SKILL.md` files are hashed — scripts, assets, and other files under `skills/` are out of scope for now. A file rename with identical bytes changes the hash, because a path change is a real content-set change. +- **`manifestHash`** and **`capabilities`** are reserved, always `null` today — later milestones populate them. `declaredSecrets`, `mcpTools`, and `mcpPolicy` are similarly reserved and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. +- **`policy.ignores`** is a reserved block; nothing writes to it yet, but it's round-tripped verbatim if present. +- A `lockfileVersion` newer than this Intent version supports, a duplicate `(kind, id)` entry, or any other structural problem is a **malformed lockfile** — fails closed, never silently treated as an empty lock. + +## Commands + +`intent.lock` is managed entirely by the [`intent skills`](../cli/intent-skills) command group: `scan`/`diff` (read-only) and `approve`/`update` (mutating). + +## 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`). +- Makes zero network calls (skips the staleness-check registry lookup) and zero subprocess calls (skips shelling out to a package manager to detect a global install path). + +See [`intent skills`](../cli/intent-skills#exit-codes) for the full exit-code table. + +## 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. From 54321cd0d3f449f4f61847c4e6080a8349398c97 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 8 Jul 2026 20:16:56 -0700 Subject: [PATCH 17/50] feat: add skills stale command for checking staleness of skills against intent.lock --- packages/intent/src/cli.ts | 32 ++- packages/intent/src/commands/skills/stale.ts | 176 ++++++++++++ packages/intent/src/core/git-adapter.ts | 164 +++++++++++ .../src/core/lockfile/baseline-drift.ts | 165 +++++++++++ packages/intent/tests/baseline-drift.test.ts | 270 ++++++++++++++++++ packages/intent/tests/git-adapter.test.ts | 190 ++++++++++++ packages/intent/tests/skills-stale.test.ts | 208 ++++++++++++++ 7 files changed, 1201 insertions(+), 4 deletions(-) create mode 100644 packages/intent/src/commands/skills/stale.ts create mode 100644 packages/intent/src/core/git-adapter.ts create mode 100644 packages/intent/src/core/lockfile/baseline-drift.ts create mode 100644 packages/intent/tests/baseline-drift.test.ts create mode 100644 packages/intent/tests/git-adapter.test.ts create mode 100644 packages/intent/tests/skills-stale.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 4d9a80c..8ef36b9 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -12,6 +12,7 @@ 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 { 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' @@ -214,10 +215,10 @@ function createCli(): CAC { cli .command( 'skills [action] [source]', - 'Scan, diff, approve, or update skills against intent.lock', + 'Scan, diff, approve, update, or check staleness of skills against intent.lock', ) .usage( - 'skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen]', + 'skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen] [--baseline ] [--files ]', ) .option('--json', 'Output JSON') .option( @@ -236,6 +237,14 @@ function createCli(): CAC { '--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') @@ -243,13 +252,16 @@ function createCli(): CAC { .example('skills approve --yes') .example('skills approve npm:@tanstack/query') .example('skills update --all') + .example('skills stale') + .example('skills stale --baseline v1.42.0') .action( async ( action: string | undefined, source: string | undefined, options: SkillsScanCommandOptions & SkillsApproveCommandOptions & - SkillsUpdateCommandOptions, + SkillsUpdateCommandOptions & + SkillsStaleCommandOptions, ) => { const { scanPolicedIntentsOrFail, frozenOptionsFromGlobalFlags } = await import('./commands/support.js') @@ -297,7 +309,19 @@ function createCli(): CAC { return } - fail('Unknown skills action: expected scan, diff, approve, or update.') + if (action === 'stale') { + const { runSkillsStaleCommand } = + await import('./commands/skills/stale.js') + await runSkillsStaleCommand( + { ...options, ...frozenOptions }, + scanPolicedIntentsOrFail, + ) + return + } + + fail( + 'Unknown skills action: expected scan, diff, approve, update, or stale.', + ) }, ) diff --git a/packages/intent/src/commands/skills/stale.ts b/packages/intent/src/commands/skills/stale.ts new file mode 100644 index 0000000..e0302b4 --- /dev/null +++ b/packages/intent/src/commands/skills/stale.ts @@ -0,0 +1,176 @@ +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 } = await scanPolicedIntents() + 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/core/git-adapter.ts b/packages/intent/src/core/git-adapter.ts new file mode 100644 index 0000000..2c62225 --- /dev/null +++ b/packages/intent/src/core/git-adapter.ts @@ -0,0 +1,164 @@ +// 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' + +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', +] + +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', + }) + 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 { + const result = runGit(cwd, ['hash-object', '--', relPath]) + if (!result.ok) { + // hash-object fails with a non-zero exit when the path does not exist; + // treat that as "no current content" rather than an adapter failure. + return ok(null) + } + return ok(result.value.trim()) +} 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..b164b0b --- /dev/null +++ b/packages/intent/src/core/lockfile/baseline-drift.ts @@ -0,0 +1,165 @@ +// 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 { relative } from 'node:path' +import { realpathSync } from 'node:fs' +import { + blobShaAtCommit, + currentBlobSha, + nearestReachableTag, + repoRoot, + resolveCommit, +} from '../git-adapter.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 +} + +// 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 { + 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) + + for (const skillPath of source.skills) { + const repoRelativePath = relative( + realRoot, + `${realPackageRoot}/${skillPath}`, + ) + + 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/tests/baseline-drift.test.ts b/packages/intent/tests/baseline-drift.test.ts new file mode 100644 index 0000000..ab0f95b --- /dev/null +++ b/packages/intent/tests/baseline-drift.test.ts @@ -0,0 +1,270 @@ +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, 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 + +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 }) +}) + +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('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/git-adapter.test.ts b/packages/intent/tests/git-adapter.test.ts new file mode 100644 index 0000000..dfd61f1 --- /dev/null +++ b/packages/intent/tests/git-adapter.test.ts @@ -0,0 +1,190 @@ +import { 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('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/skills-stale.test.ts b/packages/intent/tests/skills-stale.test.ts new file mode 100644 index 0000000..64f113d --- /dev/null +++ b/packages/intent/tests/skills-stale.test.ts @@ -0,0 +1,208 @@ +import { 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 { 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 = [] + + afterEach(() => { + logSpy.mockClear() + for (const dir of tempDirs.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') + }) + + 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('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('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') + }) +}) From db658f18b2c24884b477a227bc757c73e3f9a563 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 8 Jul 2026 20:19:21 -0700 Subject: [PATCH 18/50] feat: implement skills manifest generation and enhance CLI commands --- packages/intent/src/cli.ts | 21 +- .../src/commands/skills/generate-manifest.ts | 75 +++++ packages/intent/src/core/lockfile/hash.ts | 55 +++- .../src/core/lockfile/lockfile-state.ts | 33 ++- packages/intent/src/core/manifest.ts | 257 ++++++++++++++++++ packages/intent/src/core/secrets.ts | 76 ++++++ packages/intent/tests/lockfile-state.test.ts | 25 ++ packages/intent/tests/manifest.test.ts | 226 +++++++++++++++ packages/intent/tests/secrets.test.ts | 72 +++++ .../tests/skills-generate-manifest.test.ts | 128 +++++++++ 10 files changed, 960 insertions(+), 8 deletions(-) create mode 100644 packages/intent/src/commands/skills/generate-manifest.ts create mode 100644 packages/intent/src/core/manifest.ts create mode 100644 packages/intent/src/core/secrets.ts create mode 100644 packages/intent/tests/manifest.test.ts create mode 100644 packages/intent/tests/secrets.test.ts create mode 100644 packages/intent/tests/skills-generate-manifest.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 8ef36b9..0ed7d66 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -12,6 +12,7 @@ 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' @@ -215,10 +216,10 @@ function createCli(): CAC { cli .command( 'skills [action] [source]', - 'Scan, diff, approve, update, or check staleness of skills against intent.lock', + 'Scan, diff, approve, update, check staleness, or generate a manifest for skills', ) .usage( - 'skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen] [--baseline ] [--files ]', + 'skills [source] [--json] [--all] [--yes] [--frozen] [--no-frozen] [--baseline ] [--files ]', ) .option('--json', 'Output JSON') .option( @@ -254,6 +255,7 @@ function createCli(): CAC { .example('skills update --all') .example('skills stale') .example('skills stale --baseline v1.42.0') + .example('skills generate-manifest') .action( async ( action: string | undefined, @@ -261,7 +263,8 @@ function createCli(): CAC { options: SkillsScanCommandOptions & SkillsApproveCommandOptions & SkillsUpdateCommandOptions & - SkillsStaleCommandOptions, + SkillsStaleCommandOptions & + SkillsGenerateManifestCommandOptions, ) => { const { scanPolicedIntentsOrFail, frozenOptionsFromGlobalFlags } = await import('./commands/support.js') @@ -319,8 +322,18 @@ function createCli(): CAC { return } + if (action === 'generate-manifest') { + const { runSkillsGenerateManifestCommand } = + await import('./commands/skills/generate-manifest.js') + await runSkillsGenerateManifestCommand( + options, + scanPolicedIntentsOrFail, + ) + return + } + fail( - 'Unknown skills action: expected scan, diff, approve, update, or stale.', + 'Unknown skills action: expected scan, diff, approve, update, stale, or generate-manifest.', ) }, ) 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..9985c80 --- /dev/null +++ b/packages/intent/src/commands/skills/generate-manifest.ts @@ -0,0 +1,75 @@ +import { join } from 'node:path' +import { generateManifest, writeIntentManifest } from '../../core/manifest.js' +import { fail } from '../../shared/cli-error.js' +import type { PolicedScan } from '../../core/source-policy.js' + +export interface SkillsGenerateManifestCommandOptions { + json?: boolean +} + +interface GenerateManifestResult { + id: string + kind: 'npm' | 'workspace' + status: 'written' | 'failed' + path?: string + reason?: string +} + +export async function runSkillsGenerateManifestCommand( + options: SkillsGenerateManifestCommandOptions, + scanPolicedIntents: () => Promise, +): Promise { + const { scan } = await scanPolicedIntents() + 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/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index fc0dc5d..1ef61bd 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,10 +1,11 @@ import { createHash } from 'node:crypto' -import { isAbsolute, relative } from 'node:path' +import { isAbsolute, join, relative } from 'node:path' import { closeSync, fstatSync, openSync, readFileSync, + readdirSync, realpathSync, } from 'node:fs' @@ -184,3 +185,55 @@ export function computeSourceContentHash( contentHash: hashEntries(hashed), } } + +function toPosixRelative(baseDir: string, absolutePath: string): string { + const rel = relative(baseDir, absolutePath) + return rel.split('\\').join('/') +} + +function collectFilesRecursive( + dir: string, + baseDir: string, +): Array { + const entries: Array = [] + for (const dirent of readdirSync(dir, { withFileTypes: true })) { + const absolutePath = join(dir, dirent.name) + if (dirent.isDirectory()) { + entries.push(...collectFilesRecursive(absolutePath, baseDir)) + } else if (dirent.isFile()) { + entries.push({ + relativePath: toPosixRelative(baseDir, absolutePath), + absolutePath, + }) + } + } + return entries +} + +// 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, +): string { + const realPackageRoot = realpathSync(packageRoot) + const entries = collectFilesRecursive(skillDir, skillDir) + + assertNoDuplicateKeys( + entries.map((entry) => entry.relativePath), + 'skill folder path', + ) + + const hashed = entries.map((entry) => ({ + key: entry.relativePath, + value: readSkillMdContent( + entry.absolutePath, + realPackageRoot, + entry.relativePath, + ), + })) + + return hashEntries(hashed) +} diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts index da6181e..4f1666b 100644 --- a/packages/intent/src/core/lockfile/lockfile-state.ts +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -1,5 +1,6 @@ -import { relative, sep } from 'node:path' +import { join, relative, sep } from 'node:path' import { sourceIdentityKey } from '../types.js' +import { computeManifestHash, readIntentManifest } from '../manifest.js' import { computeSourceContentHash } from './hash.js' import type { SourceContentHash } from './hash.js' import type { IntentLockfileSource } from './lockfile.js' @@ -26,6 +27,31 @@ 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): { + manifestHash: string | null + capabilities: Array | null +} { + const manifest = readIntentManifest( + join(pkg.packageRoot, 'skills', 'intent.manifest.json'), + ) + if (!manifest) { + return { manifestHash: null, capabilities: null } + } + + const capabilities = [ + ...new Set(manifest.skills.flatMap((skill) => skill.capabilities)), + ].sort(compareStrings) + + return { + manifestHash: computeManifestHash(manifest), + capabilities, + } +} + function assertUniqueIdentities( sources: ReadonlyArray, ): void { @@ -47,6 +73,7 @@ export function buildCurrentLockfileSources( const sources = packages .map((pkg): IntentLockfileSource => { const { skills, contentHash } = buildSourceContent(pkg) + const { manifestHash, capabilities } = readManifestFields(pkg) return { id: pkg.name, kind: pkg.kind, @@ -54,8 +81,8 @@ export function buildCurrentLockfileSources( resolution: buildResolution(pkg), skills, contentHash, - manifestHash: null, - capabilities: null, + manifestHash, + capabilities, } }) .sort((a, b) => compareStrings(sourceIdentityKey(a), sourceIdentityKey(b))) diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts new file mode 100644 index 0000000..a335760 --- /dev/null +++ b/packages/intent/src/core/manifest.ts @@ -0,0 +1,257 @@ +// 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, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { createHash } from 'node:crypto' +import { computeSkillFolderHash } from './lockfile/hash.js' +import { detectCapabilityHeuristics, findSecretMatches } from './secrets.js' +import type { SkillEntry } from '../shared/types.js' + +const MANIFEST_VERSION = 1 + +interface IntentManifestSkill { + name: string + path: string + contentHash: string + capabilities: Array + declaredSecrets: Array + mcpTools: Array +} + +interface ManifestMcpTool { + name: string + description?: string + inputSchema?: Record +} + +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 skill body contains a literal +// secret value; a declared secret NAME belongs in declaredSecrets, never a +// value in the body. +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 content = readFileSync(skill.path, 'utf8') + + const matches = findSecretMatches(content) + if (matches.length > 0) { + for (const match of matches) { + secretFindings.push({ skillPath: relativePath, patternName: match.name }) + } + continue + } + + const heuristics = detectCapabilityHeuristics(content) + 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 { + const canonical = { + 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: skill.mcpTools, + })), + } + return `${JSON.stringify(canonical, null, 2)}\n` +} + +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 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 +} + +export function parseManifest(raw: unknown): IntentManifest { + const record = assertRecord(raw, '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}]`) + const path = assertString(skillRecord.path, `skills[${index}].path`) + if (path.startsWith('/') || path.includes('..')) { + throw new Error( + `Invalid intent.manifest.json: skills[${index}].path must be package-relative without ".." segments.`, + ) + } + 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: assertStringArray( + skillRecord.capabilities ?? [], + `skills[${index}].capabilities`, + ), + declaredSecrets: assertStringArray( + skillRecord.declaredSecrets ?? [], + `skills[${index}].declaredSecrets`, + ), + mcpTools: Array.isArray(skillRecord.mcpTools) + ? (skillRecord.mcpTools as Array) + : [], + } + }) + + return { + manifestVersion: 1, + package: assertString(record.package, 'package'), + packageVersion: assertString(record.packageVersion, 'packageVersion'), + skills, + } +} + +export function readIntentManifest(filePath: string): IntentManifest | null { + if (!existsSync(filePath)) return null + try { + return parseManifest(JSON.parse(readFileSync(filePath, 'utf8'))) + } catch { + return null + } +} + +// 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') + for (const skill of manifest.skills.toSorted((a, b) => + compareStrings(a.path, b.path), + )) { + hash.update(skill.path) + hash.update('\0') + hash.update(skill.contentHash) + hash.update('\0') + hash.update(skill.capabilities.toSorted(compareStrings).join(',')) + hash.update('\0') + } + 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/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts index b14fc68..e2e52cf 100644 --- a/packages/intent/tests/lockfile-state.test.ts +++ b/packages/intent/tests/lockfile-state.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { generateManifest, writeIntentManifest } from '../src/core/manifest.js' import type { IntentPackage } from '../src/shared/types.js' const roots: Array = [] @@ -72,6 +73,30 @@ describe('buildCurrentLockfileSources', () => { 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('does not set a resolution for workspace packages', () => { const root = createRoot() const skillPath = writeSkill(root, 'core', 'body') diff --git a/packages/intent/tests/manifest.test.ts b/packages/intent/tests/manifest.test.ts new file mode 100644 index 0000000..c083f93 --- /dev/null +++ b/packages/intent/tests/manifest.test.ts @@ -0,0 +1,226 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { 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' }, + ]) + }) +}) + +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/) + }) +}) + +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() + }) +}) + +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 = { + ...outcome.manifest, + skills: [ + { ...outcome.manifest.skills[0]!, capabilities: ['uses_network'] }, + ], + } + expect(computeManifestHash(mutated)).not.toBe(before) + }) +}) diff --git a/packages/intent/tests/secrets.test.ts b/packages/intent/tests/secrets.test.ts new file mode 100644 index 0000000..09cb0da --- /dev/null +++ b/packages/intent/tests/secrets.test.ts @@ -0,0 +1,72 @@ +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-generate-manifest.test.ts b/packages/intent/tests/skills-generate-manifest.test.ts new file mode 100644 index 0000000..04a0fb4 --- /dev/null +++ b/packages/intent/tests/skills-generate-manifest.test.ts @@ -0,0 +1,128 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { 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) + 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', + } + } + + it('writes a manifest file for a discovered package', async () => { + const pkg = makePackage() + + await runSkillsGenerateManifestCommand( + {}, + () => Promise.resolve(policedScan([pkg])), + ) + + 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('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])), + ), + ).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])), + ) + + 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'), + }, + ]) + }) +}) From 5229dfa42a318a348e558693b4eb70146d4324cd Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 8 Jul 2026 20:39:13 -0700 Subject: [PATCH 19/50] feat: update documentation to include new `stale` and `generate-manifest` commands for intent skills --- docs/cli/intent-skills.md | 37 +++++++++++++++++++++++++++++++++---- docs/security/lockfile.md | 5 +++-- packages/intent/README.md | 30 +++++++++++++++++++----------- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/docs/cli/intent-skills.md b/docs/cli/intent-skills.md index 4f2f7d2..f39e01e 100644 --- a/docs/cli/intent-skills.md +++ b/docs/cli/intent-skills.md @@ -3,10 +3,10 @@ title: intent skills id: intent-skills --- -`intent skills` manages `intent.lock`, the committed record of which skill-bearing sources you've approved and what their content looked like when you approved it. Four subcommands: `scan`, `diff` (read-only), `approve`, `update` (mutating). +`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] +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. @@ -69,6 +69,33 @@ Writes `intent.lock`. This is the mechanical refresh, not a trust decision: it r - 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`, and unrelated to frozen mode. For each skill folder, computes a content hash over the **whole folder** (`SKILL.md` plus any `references/`, `assets/`, `scripts/` — a wider scope than the lockfile's `SKILL.md`-only aggregate hash) and runs static heuristics to 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 skill body 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. + ## Options - `--json`: with `scan`/`diff`, print the structured diff instead of text @@ -76,17 +103,19 @@ Writes `intent.lock`. This is the mechanical refresh, not a trust decision: it r - `--yes`: with `approve`, accept all pending changes non-interactively (alias for `--all`'s non-interactive behavior, for scripted first-run) - `--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 | +| `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 | +| `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 diff --git a/docs/security/lockfile.md b/docs/security/lockfile.md index 609b759..fb1f1db 100644 --- a/docs/security/lockfile.md +++ b/docs/security/lockfile.md @@ -34,13 +34,14 @@ This is tamper-evidence, not semantic validation. Approving a source means **a h - **`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 that fed `contentHash`. It's what lets `diff` show *which files* changed, not just an opaque hash flip. - **`contentHash`** is a `sha256-` digest over each source's `SKILL.md` files (path + bytes, LF-normalized). Only `SKILL.md` files are hashed — scripts, assets, and other files under `skills/` are out of scope for now. A file rename with identical bytes changes the hash, because a path change is a real content-set change. -- **`manifestHash`** and **`capabilities`** are reserved, always `null` today — later milestones populate them. `declaredSecrets`, `mcpTools`, and `mcpPolicy` are similarly reserved and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. +- **`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)) — reserved-nullable so the lockfile works before every package adopts a manifest. Once a manifest exists, its hash and the union of its skills' declared capabilities populate these fields automatically on the next `scan`/`diff`/`approve`. `declaredSecrets`, `mcpTools`, and `mcpPolicy` remain reserved and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. - **`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, a duplicate `(kind, id)` entry, or any other structural problem is a **malformed lockfile** — fails closed, never silently treated as an empty lock. ## Commands -`intent.lock` is managed entirely by the [`intent skills`](../cli/intent-skills) command group: `scan`/`diff` (read-only) and `approve`/`update` (mutating). +`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 diff --git a/packages/intent/README.md b/packages/intent/README.md index 8a4aabd..02968c9 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -119,17 +119,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 From 014c3cc267b61a37c23d7cb4f4e54805ecdeda16 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:20:35 -0700 Subject: [PATCH 20/50] feat: enhance hashing logic to include reference files in skill folders --- packages/intent/src/core/lockfile/hash.ts | 155 +++++++++++++++++++++- packages/intent/tests/hash.test.ts | 4 +- 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 1ef61bd..501e9fc 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { isAbsolute, join, relative } from 'node:path' +import { dirname, isAbsolute, join, relative } from 'node:path' import { closeSync, fstatSync, @@ -7,7 +7,9 @@ import { readFileSync, readdirSync, realpathSync, + statSync, } from 'node:fs' +import type { Dirent } from 'node:fs' export interface SkillContentEntry { relativePath: string @@ -156,8 +158,143 @@ function readSkillMdContent( return isBinaryContent(raw) ? raw : normalizeLineEndings(raw) } -// M2 hashes only SKILL.md files (M2-SPEC §6.5); other files under skills/ -// are out of scope until a later milestone opts in. +function resolveContainedDirectory( + absolutePath: string, + realPackageRoot: string, + logicalRelativePath: string, +): string { + let realPath: string + try { + realPath = 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 (!statSync(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( + dir: string, + baseDir: string, + realPackageRoot: string, +): Array { + const logicalRelativePath = toPosixRelative(baseDir, dir) + const realDir = resolveContainedDirectory( + dir, + realPackageRoot, + logicalRelativePath, + ) + + let dirents: Array> + try { + dirents = readdirSync(realDir, { withFileTypes: true }) + } 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) { + const absolutePath = join(dir, dirent.name) + if (dirent.isDirectory()) { + files.push(...collectSupportFiles(absolutePath, baseDir, realPackageRoot)) + continue + } + if (dirent.isFile()) { + files.push({ + relativePath: toPosixRelative(baseDir, absolutePath), + absolutePath, + }) + continue + } + if (dirent.isSymbolicLink()) { + let stat: ReturnType + try { + stat = statSync(absolutePath) + } catch (err) { + throw new Error( + `Failed to resolve skill file "${toPosixRelative(baseDir, absolutePath)}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + if (stat.isDirectory()) { + files.push(...collectSupportFiles(absolutePath, baseDir, realPackageRoot)) + } else if (stat.isFile()) { + files.push({ + relativePath: toPosixRelative(baseDir, absolutePath), + absolutePath, + }) + } + } + } + + return files +} + +function collectSkillContentEntries( + packageRoot: string, + entries: ReadonlyArray, + realPackageRoot: string, +): Array { + const contentEntries = [...entries] + for (const entry of entries) { + const skillDir = dirname(entry.absolutePath) + let dirents: Array> + try { + dirents = readdirSync(skillDir, { withFileTypes: true }) + } catch (err) { + throw new Error( + `Failed to read skill directory "${toPosixRelative(packageRoot, 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 stat: ReturnType + try { + stat = statSync(supportDir) + } catch (err) { + throw new Error( + `Failed to resolve skill directory "${toPosixRelative(packageRoot, supportDir)}": ${err instanceof Error ? err.message : String(err)}`, + ) + } + if (stat.isDirectory()) { + contentEntries.push( + ...collectSupportFiles(supportDir, packageRoot, realPackageRoot), + ) + } + } + } + + return contentEntries +} + export function computeSourceContentHash( packageRoot: string, entries: ReadonlyArray, @@ -171,7 +308,17 @@ export function computeSourceContentHash( ) const realPackageRoot = realpathSync(packageRoot) - const hashed = entries.map((entry) => ({ + const contentEntries = collectSkillContentEntries( + packageRoot, + entries, + realPackageRoot, + ) + assertNoDuplicateKeys( + contentEntries.map((entry) => entry.relativePath), + 'skill content path', + ) + + const hashed = contentEntries.map((entry) => ({ key: entry.relativePath, value: readSkillMdContent( entry.absolutePath, diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index dc80280..4330e32 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -246,7 +246,7 @@ describe('computeSourceContentHash', () => { expect(hashA).toBe(hashB) }) - it('does not hash a non-SKILL.md file even if present in the skill folder', () => { + 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') @@ -261,7 +261,7 @@ describe('computeSourceContentHash', () => { computeSourceContentHash(root, [ { relativePath: 'skills/a/SKILL.md', absolutePath: skillPath }, ]).contentHash, - ).toBe(before) + ).not.toBe(before) }) it('fails closed when a symlinked SKILL.md escapes the package root', () => { From cc5f509d10831fd8f3db7b9b0ff94f24bb594a1c Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:21:13 -0700 Subject: [PATCH 21/50] feat: enhance source content hash computation with support file modifications --- packages/intent/src/core/lockfile/hash.ts | 4 +- packages/intent/tests/hash.test.ts | 53 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 501e9fc..496523e 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -236,7 +236,9 @@ function collectSupportFiles( ) } if (stat.isDirectory()) { - files.push(...collectSupportFiles(absolutePath, baseDir, realPackageRoot)) + files.push( + ...collectSupportFiles(absolutePath, baseDir, realPackageRoot), + ) } else if (stat.isFile()) { files.push({ relativePath: toPosixRelative(baseDir, absolutePath), diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index 4330e32..18f86ae 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -1,6 +1,7 @@ import { mkdirSync, mkdtempSync, + renameSync, rmSync, symlinkSync, writeFileSync, @@ -29,6 +30,12 @@ function writeFile( 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 }) @@ -264,6 +271,52 @@ describe('computeSourceContentHash', () => { ).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('fails closed when a symlinked SKILL.md escapes the package root', () => { const root = createRoot() const outside = join( From 09465331a490e0a36d2dfb68c4abf2933c29f447 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:24:37 -0700 Subject: [PATCH 22/50] feat: refactor file reading logic to use a customizable filesystem interface --- .../intent/src/commands/skills/support.ts | 2 +- packages/intent/src/core/lockfile/hash.ts | 84 +++++++++---------- .../src/core/lockfile/lockfile-state.ts | 9 +- packages/intent/src/discovery/scanner.ts | 2 + packages/intent/src/shared/types.ts | 3 + packages/intent/tests/lockfile-state.test.ts | 44 +++++++++- 6 files changed, 97 insertions(+), 47 deletions(-) diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts index b2e02aa..795c41f 100644 --- a/packages/intent/src/commands/skills/support.ts +++ b/packages/intent/src/commands/skills/support.ts @@ -77,7 +77,7 @@ export function computeLockfileState( scan: ScanResult, cwd: string, ): LockfileState { - const current = buildCurrentLockfileSources(scan.packages) + const current = buildCurrentLockfileSources(scan.packages, scan.readFs) const lockedResult = readLockfileOrFail(cwd) const diff = diffLockfileSources(current, lockedResult) return { current, lockedResult, diff } diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 496523e..b59637e 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,15 +1,9 @@ import { createHash } from 'node:crypto' import { dirname, isAbsolute, join, relative } from 'node:path' -import { - closeSync, - fstatSync, - openSync, - readFileSync, - readdirSync, - realpathSync, - statSync, -} from 'node:fs' +import { readdirSync } from 'node:fs' import type { Dirent } from 'node:fs' +import { nodeReadFs } from '../../shared/utils.js' +import type { ReadFs } from '../../shared/utils.js' export interface SkillContentEntry { relativePath: string @@ -104,44 +98,32 @@ function hashEntries( return `sha256-${hash.digest('hex')}` } -// Opens once and reads/verifies-type from that same fd rather than -// stat-by-path-then-open-by-path: the fd is bound to a specific inode, so a -// path swap after this call can't produce a torn read mixing old/new bytes. function readRegularFile( + fs: ReadFs, physicalPath: string, logicalRelativePath: string, ): Buffer { - let fd: number try { - fd = openSync(physicalPath, 'r') + 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)}`, ) } - - try { - if (!fstatSync(fd).isFile()) { - throw new Error( - `Failed to read skill file "${logicalRelativePath}": not a regular file.`, - ) - } - return readFileSync(fd) - } finally { - closeSync(fd) - } } -// Resolves through symlinks once, validated against the package root, not the -// original path, to avoid a TOCTOU window between the check and the read. function readSkillMdContent( + fs: ReadFs, absolutePath: string, realPackageRoot: string, logicalRelativePath: string, ): Buffer { let realPath: string try { - realPath = realpathSync(absolutePath) + realPath = fs.realpathSync(absolutePath) } catch (err) { throw new Error( `Failed to resolve skill file "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, @@ -154,18 +136,19 @@ function readSkillMdContent( ) } - const raw = readRegularFile(realPath, logicalRelativePath) + const raw = readRegularFile(fs, realPath, logicalRelativePath) return isBinaryContent(raw) ? raw : normalizeLineEndings(raw) } function resolveContainedDirectory( + fs: ReadFs, absolutePath: string, realPackageRoot: string, logicalRelativePath: string, ): string { let realPath: string try { - realPath = realpathSync(absolutePath) + realPath = fs.realpathSync(absolutePath) } catch (err) { throw new Error( `Failed to resolve skill directory "${logicalRelativePath}": ${err instanceof Error ? err.message : String(err)}`, @@ -179,7 +162,7 @@ function resolveContainedDirectory( } try { - if (!statSync(realPath).isDirectory()) { + if (!fs.lstatSync(realPath).isDirectory()) { throw new Error('not a directory.') } } catch (err) { @@ -192,12 +175,14 @@ function resolveContainedDirectory( } function collectSupportFiles( + fs: ReadFs, dir: string, baseDir: string, realPackageRoot: string, ): Array { const logicalRelativePath = toPosixRelative(baseDir, dir) const realDir = resolveContainedDirectory( + fs, dir, realPackageRoot, logicalRelativePath, @@ -205,7 +190,10 @@ function collectSupportFiles( let dirents: Array> try { - dirents = readdirSync(realDir, { withFileTypes: true }) + 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)}`, @@ -216,7 +204,9 @@ function collectSupportFiles( for (const dirent of dirents) { const absolutePath = join(dir, dirent.name) if (dirent.isDirectory()) { - files.push(...collectSupportFiles(absolutePath, baseDir, realPackageRoot)) + files.push( + ...collectSupportFiles(fs, absolutePath, baseDir, realPackageRoot), + ) continue } if (dirent.isFile()) { @@ -227,17 +217,18 @@ function collectSupportFiles( continue } if (dirent.isSymbolicLink()) { - let stat: ReturnType + let realPath: string try { - stat = statSync(absolutePath) + 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(absolutePath, baseDir, realPackageRoot), + ...collectSupportFiles(fs, absolutePath, baseDir, realPackageRoot), ) } else if (stat.isFile()) { files.push({ @@ -252,6 +243,7 @@ function collectSupportFiles( } function collectSkillContentEntries( + fs: ReadFs, packageRoot: string, entries: ReadonlyArray, realPackageRoot: string, @@ -261,7 +253,10 @@ function collectSkillContentEntries( const skillDir = dirname(entry.absolutePath) let dirents: Array> try { - dirents = readdirSync(skillDir, { withFileTypes: true }) + dirents = fs.readdirSync(skillDir, { + withFileTypes: true, + encoding: 'utf8', + }) } catch (err) { throw new Error( `Failed to read skill directory "${toPosixRelative(packageRoot, skillDir)}": ${err instanceof Error ? err.message : String(err)}`, @@ -278,17 +273,18 @@ function collectSkillContentEntries( } const supportDir = join(skillDir, dirent.name) - let stat: ReturnType + let realPath: string try { - stat = statSync(supportDir) + realPath = fs.realpathSync(supportDir) } catch (err) { throw new Error( `Failed to resolve skill directory "${toPosixRelative(packageRoot, supportDir)}": ${err instanceof Error ? err.message : String(err)}`, ) } + const stat = fs.lstatSync(realPath) if (stat.isDirectory()) { contentEntries.push( - ...collectSupportFiles(supportDir, packageRoot, realPackageRoot), + ...collectSupportFiles(fs, supportDir, packageRoot, realPackageRoot), ) } } @@ -300,6 +296,7 @@ function collectSkillContentEntries( export function computeSourceContentHash( packageRoot: string, entries: ReadonlyArray, + fs: ReadFs = nodeReadFs, ): SourceContentHash { for (const entry of entries) { assertValidRelativePath(entry.relativePath, 'skill path') @@ -309,8 +306,9 @@ export function computeSourceContentHash( 'skill path', ) - const realPackageRoot = realpathSync(packageRoot) + const realPackageRoot = fs.realpathSync(packageRoot) const contentEntries = collectSkillContentEntries( + fs, packageRoot, entries, realPackageRoot, @@ -323,6 +321,7 @@ export function computeSourceContentHash( const hashed = contentEntries.map((entry) => ({ key: entry.relativePath, value: readSkillMdContent( + fs, entry.absolutePath, realPackageRoot, entry.relativePath, @@ -367,7 +366,7 @@ export function computeSkillFolderHash( skillDir: string, packageRoot: string, ): string { - const realPackageRoot = realpathSync(packageRoot) + const realPackageRoot = nodeReadFs.realpathSync(packageRoot) const entries = collectFilesRecursive(skillDir, skillDir) assertNoDuplicateKeys( @@ -378,6 +377,7 @@ export function computeSkillFolderHash( const hashed = entries.map((entry) => ({ key: entry.relativePath, value: readSkillMdContent( + nodeReadFs, entry.absolutePath, realPackageRoot, entry.relativePath, diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts index 4f1666b..b29c3d6 100644 --- a/packages/intent/src/core/lockfile/lockfile-state.ts +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -5,6 +5,8 @@ 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 { nodeReadFs } from '../../shared/utils.js' +import type { ReadFs } from '../../shared/utils.js' function toPosixPath(path: string): string { return sep === '/' ? path : path.split(sep).join('/') @@ -14,13 +16,13 @@ function compareStrings(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } -function buildSourceContent(pkg: IntentPackage): SourceContentHash { +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) + return computeSourceContentHash(pkg.packageRoot, entries, fs) } function buildResolution(pkg: IntentPackage): string | null { @@ -69,10 +71,11 @@ function assertUniqueIdentities( export function buildCurrentLockfileSources( packages: ReadonlyArray, + fs: ReadFs = nodeReadFs, ): Array { const sources = packages .map((pkg): IntentLockfileSource => { - const { skills, contentHash } = buildSourceContent(pkg) + const { skills, contentHash } = buildSourceContent(pkg, fs) const { manifestHash, capabilities } = readManifestFields(pkg) return { id: pkg.name, diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 5b95bc3..5221c2d 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -710,6 +710,7 @@ export function scanForIntents( conflicts, nodeModules, stats: getStats(), + readFs: fsCache.getReadFs(), } } @@ -739,6 +740,7 @@ export function scanForIntents( conflicts, nodeModules, stats: getStats(), + readFs: fsCache.getReadFs(), } } diff --git a/packages/intent/src/shared/types.ts b/packages/intent/src/shared/types.ts index 990ad21..61149eb 100644 --- a/packages/intent/src/shared/types.ts +++ b/packages/intent/src/shared/types.ts @@ -13,6 +13,8 @@ export interface IntentConfig { // Scanner types // --------------------------------------------------------------------------- +import type { ReadFs } from './utils.js' + export interface ScanResult { packageManager: PackageManager packages: Array @@ -24,6 +26,7 @@ export interface ScanResult { global: NodeModulesScanTarget } stats: ScanStats + readFs?: ReadFs } export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts index e2e52cf..6fdd68d 100644 --- a/packages/intent/tests/lockfile-state.test.ts +++ b/packages/intent/tests/lockfile-state.test.ts @@ -2,9 +2,12 @@ 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 type { IntentPackage } from '../src/shared/types.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 = [] @@ -131,6 +134,45 @@ describe('buildCurrentLockfileSources', () => { 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') From cc3e63eba10a1e8bed7756d1406aefb58a73e3dd Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:26:53 -0700 Subject: [PATCH 23/50] feat: enhance manifest reading and lockfile source building with customizable filesystem support --- .../src/core/lockfile/lockfile-state.ts | 8 +- packages/intent/src/core/manifest.ts | 16 +++- .../integration/pnp-berry-corepack.test.ts | 87 +++++++++++++++++-- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts index b29c3d6..4f59e69 100644 --- a/packages/intent/src/core/lockfile/lockfile-state.ts +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -33,12 +33,16 @@ function buildResolution(pkg: IntentPackage): string | null { // 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): { +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 } @@ -76,7 +80,7 @@ export function buildCurrentLockfileSources( const sources = packages .map((pkg): IntentLockfileSource => { const { skills, contentHash } = buildSourceContent(pkg, fs) - const { manifestHash, capabilities } = readManifestFields(pkg) + const { manifestHash, capabilities } = readManifestFields(pkg, fs) return { id: pkg.name, kind: pkg.kind, diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts index a335760..501175d 100644 --- a/packages/intent/src/core/manifest.ts +++ b/packages/intent/src/core/manifest.ts @@ -8,7 +8,9 @@ import { dirname, join, relative } from 'node:path' import { createHash } from 'node:crypto' import { computeSkillFolderHash } from './lockfile/hash.js' import { detectCapabilityHeuristics, findSecretMatches } from './secrets.js' +import { nodeReadFs } from '../shared/utils.js' import type { SkillEntry } from '../shared/types.js' +import type { ReadFs } from '../shared/utils.js' const MANIFEST_VERSION = 1 @@ -84,7 +86,10 @@ export function generateManifest( const matches = findSecretMatches(content) if (matches.length > 0) { for (const match of matches) { - secretFindings.push({ skillPath: relativePath, patternName: match.name }) + secretFindings.push({ + skillPath: relativePath, + patternName: match.name, + }) } continue } @@ -228,10 +233,13 @@ export function parseManifest(raw: unknown): IntentManifest { } } -export function readIntentManifest(filePath: string): IntentManifest | null { - if (!existsSync(filePath)) return null +export function readIntentManifest( + filePath: string, + fs: Pick = nodeReadFs, +): IntentManifest | null { + if (!fs.existsSync(filePath)) return null try { - return parseManifest(JSON.parse(readFileSync(filePath, 'utf8'))) + return parseManifest(JSON.parse(fs.readFileSync(filePath, 'utf8'))) } catch { return null } diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index 193cc64..943e044 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readdirSync, + readFileSync, realpathSync, rmSync, writeFileSync, @@ -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) }) From 4e10b0d6e462b0570dab5475f77f2d4eb034af83 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:29:13 -0700 Subject: [PATCH 24/50] feat: improve package registration logic to handle duplicates and enhance workspace integration in scanner --- packages/intent/src/core/source-policy.ts | 2 -- packages/intent/src/discovery/register.ts | 9 ++++-- packages/intent/src/discovery/scanner.ts | 38 ++++++++++++++++------- packages/intent/src/discovery/walk.ts | 3 ++ packages/intent/tests/scanner.test.ts | 37 ++++++++++++++++++++++ 5 files changed, 73 insertions(+), 16 deletions(-) diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index e47730a..086febe 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -263,8 +263,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/discovery/register.ts b/packages/intent/src/discovery/register.ts index 90516a0..cabe93a 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 { @@ -124,10 +125,14 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { kind: opts.getPackageKind(dirPath), source, } - 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) return true } diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 5221c2d..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, @@ -715,7 +729,7 @@ export function scanForIntents( } 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) diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts index ac233cf..4aafb9a 100644 --- a/packages/intent/src/discovery/walk.ts +++ b/packages/intent/src/discovery/walk.ts @@ -115,6 +115,9 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { const wsPkg = readPkgJsonWithWarning(wsDir, 'workspace') if (wsPkg) { + const workspaceName = + typeof wsPkg.name === 'string' ? wsPkg.name : 'unknown' + opts.tryRegister(wsDir, workspaceName) walkDepsOf(wsPkg, wsDir) } } diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 970016c..7693d94 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'), { From 999deb872f9b43f6ff722ca69b903b12c2b649ce Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:36:13 -0700 Subject: [PATCH 25/50] feat: enhance package registrar and skill load path handling with package kind support --- packages/intent/src/discovery/register.ts | 4 +- packages/intent/src/skills/paths.ts | 3 + .../source-policy-surfaces.test.ts | 144 +++++++++++++++++- 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index cabe93a..ac4f659 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -106,12 +106,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, }) } @@ -122,7 +124,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { intent, skills, packageRoot: dirPath, - kind: opts.getPackageKind(dirPath), + kind, source, } const candidateKey = sourceIdentityKey({ 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/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 6241736..284bb4c 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,117 @@ 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('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'), + ) + }) +}) From 24eb5defc8b6868bd672e5eb2d24f50406c89a46 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:38:52 -0700 Subject: [PATCH 26/50] feat: preserve staleness metadata in lockfile during approve and update commands --- .../intent/src/commands/skills/approve.ts | 3 ++ packages/intent/src/commands/skills/update.ts | 3 ++ packages/intent/tests/skills-approve.test.ts | 38 +++++++++++++++++++ packages/intent/tests/skills-update.test.ts | 38 +++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/packages/intent/src/commands/skills/approve.ts b/packages/intent/src/commands/skills/approve.ts index 8b75e32..e8e5241 100644 --- a/packages/intent/src/commands/skills/approve.ts +++ b/packages/intent/src/commands/skills/approve.ts @@ -209,6 +209,9 @@ export async function runSkillsApproveCommand( writeIntentLockfile(resolveLockfilePath(cwd), { lockfileVersion: 1, intentVersion: getIntentPackageVersion(), + ...(lockedResult.status === 'found' && lockedResult.lockfile.staleness + ? { staleness: lockedResult.lockfile.staleness } + : {}), sources: [...finalSources.values()], policy: lockedResult.status === 'found' diff --git a/packages/intent/src/commands/skills/update.ts b/packages/intent/src/commands/skills/update.ts index c76c012..47c3cc8 100644 --- a/packages/intent/src/commands/skills/update.ts +++ b/packages/intent/src/commands/skills/update.ts @@ -143,6 +143,9 @@ export async function runSkillsUpdateCommand( writeIntentLockfile(resolveLockfilePath(cwd), { lockfileVersion: 1, intentVersion: getIntentPackageVersion(), + ...(lockedResult.lockfile.staleness + ? { staleness: lockedResult.lockfile.staleness } + : {}), sources: [...finalSources.values()], policy: lockedResult.lockfile.policy, }) diff --git a/packages/intent/tests/skills-approve.test.ts b/packages/intent/tests/skills-approve.test.ts index 782c593..bc33804 100644 --- a/packages/intent/tests/skills-approve.test.ts +++ b/packages/intent/tests/skills-approve.test.ts @@ -460,6 +460,44 @@ describe('runSkillsApproveCommand', () => { } }) + it('preserves staleness metadata through approve --all', async () => { + const cwd = makeTempProject() + const staleness = { + baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + staleness, + 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(result.lockfile.staleness).toEqual(staleness) + } + }) + it('does not write intent.lock when every pending change is declined', async () => { const cwd = makeTempProject() writeIntentLockfile(join(cwd, 'intent.lock'), { diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts index 5e18f68..623af12 100644 --- a/packages/intent/tests/skills-update.test.ts +++ b/packages/intent/tests/skills-update.test.ts @@ -528,6 +528,44 @@ describe('runSkillsUpdateCommand', () => { } }) + it('preserves staleness metadata through update --all', async () => { + const cwd = makeTempProject() + const staleness = { + baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + } + writeIntentLockfile(join(cwd, 'intent.lock'), { + ...baseLockfile(), + staleness, + sources: [lockedSource({ id: 'foo', version: '1.0.0' })], + }) + + await runSkillsUpdateCommand( + undefined, + {}, + () => + 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.staleness).toEqual(staleness) + } + }) + it('does not write intent.lock when nothing changed', async () => { const cwd = makeTempProject() writeIntentLockfile(join(cwd, 'intent.lock'), baseLockfile()) From 813c4af3f3a26dc22669b9cadf3d0cd4d13caea8 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:43:01 -0700 Subject: [PATCH 27/50] feat: preserve metadata during source approval and update processes --- packages/intent/tests/skills-approve.test.ts | 49 +++++++++++++++++--- packages/intent/tests/skills-update.test.ts | 49 +++++++++++++++++--- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/packages/intent/tests/skills-approve.test.ts b/packages/intent/tests/skills-approve.test.ts index bc33804..83ee57d 100644 --- a/packages/intent/tests/skills-approve.test.ts +++ b/packages/intent/tests/skills-approve.test.ts @@ -185,10 +185,27 @@ describe('runSkillsApproveCommand', () => { } }) - it('approves a single source id without touching unrelated pending changes', async () => { + 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' })], }) @@ -204,6 +221,10 @@ describe('runSkillsApproveCommand', () => { 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) } }) @@ -460,14 +481,27 @@ describe('runSkillsApproveCommand', () => { } }) - it('preserves staleness metadata through approve --all', async () => { + it('preserves metadata through approve --all', async () => { const cwd = makeTempProject() - const staleness = { - baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + 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(), - staleness, + ...metadata, sources: [lockedSource({ version: '1.0.0' })], }) @@ -494,7 +528,10 @@ describe('runSkillsApproveCommand', () => { const result = readIntentLockfile(join(cwd, 'intent.lock')) expect(result.status).toBe('found') if (result.status === 'found') { - expect(result.lockfile.staleness).toEqual(staleness) + expect({ + staleness: result.lockfile.staleness, + policy: result.lockfile.policy, + }).toEqual(metadata) } }) diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts index 623af12..fadaa8f 100644 --- a/packages/intent/tests/skills-update.test.ts +++ b/packages/intent/tests/skills-update.test.ts @@ -191,10 +191,27 @@ describe('runSkillsUpdateCommand', () => { expect(output).toContain('Updated 1 source(s)') }) - it('updates only the targeted source, leaving other drift untouched', async () => { + 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' }), @@ -235,6 +252,10 @@ describe('runSkillsUpdateCommand', () => { 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) } }) @@ -528,14 +549,27 @@ describe('runSkillsUpdateCommand', () => { } }) - it('preserves staleness metadata through update --all', async () => { + it('preserves metadata through update --all', async () => { const cwd = makeTempProject() - const staleness = { - baseline: { kind: 'tag' as const, ref: 'v1.0.0', commit: 'abc123' }, + 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(), - staleness, + ...metadata, sources: [lockedSource({ id: 'foo', version: '1.0.0' })], }) @@ -562,7 +596,10 @@ describe('runSkillsUpdateCommand', () => { const result = readIntentLockfile(join(cwd, 'intent.lock')) expect(result.status).toBe('found') if (result.status === 'found') { - expect(result.lockfile.staleness).toEqual(staleness) + expect({ + staleness: result.lockfile.staleness, + policy: result.lockfile.policy, + }).toEqual(metadata) } }) From 4529da4dc0b115fed0ddc0139831b3adf0e763bb Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:45:08 -0700 Subject: [PATCH 28/50] feat: enhance provenance tracking for skills and packages across commands and types --- packages/intent/src/commands/list.ts | 5 ++- packages/intent/src/core/source-policy.ts | 16 ++++++++-- packages/intent/src/core/types.ts | 1 + packages/intent/src/discovery/register.ts | 37 +++++++++++++++++++++++ packages/intent/src/discovery/walk.ts | 31 +++++++++++++------ packages/intent/src/shared/types.ts | 1 + packages/intent/tests/scanner.test.ts | 28 +++++++++++++++++ 7 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0fc45c5..f9e9c9e 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}` : ''}`, ) } } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 086febe..48e0d87 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -129,7 +129,15 @@ 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 + }) + .join(', ') + return `${sourceCount} discovered ${noun} skills but ${sourceCount === 1 ? 'is' : 'are'} not listed in intent.skills: ${sources}. Add to opt in.` } export interface SourcePolicyResult { @@ -162,7 +170,11 @@ export function applySourcePolicy( if (!isSourcePermitted(config, pkg.name, pkg.kind)) { if (config.mode === 'explicit') { - hiddenSources.push({ name: pkg.name, skillCount: pkg.skills.length }) + hiddenSources.push({ + name: pkg.name, + skillCount: pkg.skills.length, + ...(pkg.provenance ? { provenance: pkg.provenance } : {}), + }) } continue } diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index cf89703..0ff2949 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 { diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index ac4f659..a93ee47 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -12,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) || @@ -43,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 @@ -80,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') @@ -126,6 +154,13 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { packageRoot: dirPath, kind, source, + ...(provenanceByPackageRoot.get(opts.getFsIdentity(dirPath))?.length + ? { + provenance: provenanceByPackageRoot.get( + opts.getFsIdentity(dirPath), + ), + } + : {}), } const candidateKey = sourceIdentityKey({ kind: candidate.kind, @@ -135,6 +170,7 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) { if (existingIndex === undefined) { opts.rememberVariant(candidate) opts.packageIndexes.set(candidateKey, opts.packages.push(candidate) - 1) + registeredPackagesByRoot.set(opts.getFsIdentity(dirPath), candidate) return true } @@ -161,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/walk.ts b/packages/intent/src/discovery/walk.ts index 4aafb9a..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( @@ -117,8 +130,8 @@ export function createDependencyWalker(opts: CreateDependencyWalkerOptions) { if (wsPkg) { const workspaceName = typeof wsPkg.name === 'string' ? wsPkg.name : 'unknown' - opts.tryRegister(wsDir, workspaceName) - walkDepsOf(wsPkg, wsDir) + opts.tryRegister(wsDir, workspaceName, 'local', [workspaceName]) + walkDepsOf(wsPkg, wsDir, false, [workspaceName]) } } } diff --git a/packages/intent/src/shared/types.ts b/packages/intent/src/shared/types.ts index 61149eb..b212e45 100644 --- a/packages/intent/src/shared/types.ts +++ b/packages/intent/src/shared/types.ts @@ -61,6 +61,7 @@ export interface IntentPackage { packageRoot: string kind: 'npm' | 'workspace' source: 'local' | 'global' + provenance?: Array> } export interface InstalledVariant { diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 7693d94..95c3612 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -235,6 +235,34 @@ describe('scanForIntents', () => { expect(result.packages[0]!.name).toBe('my-lib') }) + 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('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. From 13dc1fedf3f7e96a38c8457a35557574957108f6 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:48:35 -0700 Subject: [PATCH 29/50] feat: update provenance messaging to indicate when provenance is unknown --- packages/intent/src/commands/list.ts | 2 +- packages/intent/src/core/source-policy.ts | 4 +++- packages/intent/tests/core.test.ts | 2 +- packages/intent/tests/source-policy.test.ts | 10 +++++----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index f9e9c9e..e9b1064 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -105,7 +105,7 @@ function printHiddenSources(result: IntentSkillList, audience: string): void { ?.map((path) => path.join(' -> ')) .join('; ') console.log( - ` ${source.name} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})${provenance ? ` via ${provenance}` : ''}`, + ` ${source.name} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})${provenance ? ` via ${provenance}` : ' (provenance unknown)'}`, ) } } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 48e0d87..99e4f6c 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -134,7 +134,9 @@ function formatUnlistedNotice( const provenance = source.provenance ?.map((path) => path.join(' -> ')) .join('; ') - return provenance ? `${source.name} (via ${provenance})` : source.name + 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.` diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 0989b1f..825b4ec 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -271,7 +271,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.', ]) }) diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 122aef9..28338a1 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -66,7 +66,7 @@ describe('applySourcePolicy — allowlist matrix', () => { ) 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.', ]) }) @@ -82,7 +82,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 +104,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 +125,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 +138,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.', ]) }) From 1ba0f0896aea22f364cd937e2ceb41e5162e9c22 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:51:58 -0700 Subject: [PATCH 30/50] feat: enhance provenance tracking for direct and transitive skill packages in tests --- packages/intent/tests/scanner.test.ts | 64 +++++++++++++++++++++ packages/intent/tests/skills-scan.test.ts | 14 ++++- packages/intent/tests/source-policy.test.ts | 21 ++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 95c3612..e3caa29 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -235,6 +235,27 @@ 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', @@ -263,6 +284,49 @@ describe('scanForIntents', () => { expect(result.packages[0]!.provenance).toEqual([['app', 'parent', 'leaf']]) }) + it('retains a bounded number of parent chains for the same skill package', () => { + writeJson(join(root, 'package.json'), { + name: 'app', + dependencies: { + alpha: '1.0.0', + beta: '1.0.0', + gamma: '1.0.0', + delta: '1.0.0', + }, + }) + for (const name of ['alpha', 'beta', 'gamma', 'delta']) { + const parentDir = createDir(root, 'node_modules', name) + writeJson(join(parentDir, 'package.json'), { + name, + version: '1.0.0', + dependencies: { leaf: '1.0.0' }, + }) + } + const leafDir = createDir(root, 'node_modules', 'leaf') + writeJson(join(leafDir, 'package.json'), { + name: 'leaf', + version: '1.0.0', + intent: { version: 1, repo: 'test/leaf', docs: 'docs/' }, + }) + writeSkillMd(createDir(leafDir, 'skills', 'core'), { + name: 'core', + description: 'Leaf skill', + }) + + const result = scanForIntents(root) + const provenance = result.packages[0]!.provenance + + expect(provenance).toHaveLength(3) + expect(provenance).toEqual( + expect.arrayContaining([ + ['app', 'alpha', 'leaf'], + ['app', 'beta', 'leaf'], + ['app', 'gamma', 'leaf'], + ]), + ) + expect(provenance).not.toContainEqual(['app', 'delta', 'leaf']) + }) + it('discovers transitive skills of a skill-bearing direct dep under pnpm isolated linker (#153)', () => { // pnpm isolated layout: a store-only transitive dep (start-core) reached // only through its skill-bearing parent's (react-start) store dir. diff --git a/packages/intent/tests/skills-scan.test.ts b/packages/intent/tests/skills-scan.test.ts index a512533..8a6845a 100644 --- a/packages/intent/tests/skills-scan.test.ts +++ b/packages/intent/tests/skills-scan.test.ts @@ -195,7 +195,19 @@ describe('runSkillsScanCommand', () => { await expect( runSkillsScanCommand( { frozen: true }, - () => Promise.resolve(policedScan({ hiddenSourceCount: 1 })), + () => + Promise.resolve( + policedScan({ + hiddenSourceCount: 1, + hiddenSources: [ + { + name: 'leaf', + skillCount: 1, + provenance: [['app', 'parent', 'leaf']], + }, + ], + }), + ), cwd, ), ).rejects.toMatchObject({ diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 28338a1..e8da1f0 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -59,7 +59,7 @@ 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: [] }, @@ -70,6 +70,25 @@ describe('applySourcePolicy — allowlist matrix', () => { ]) }) + 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.', + ]) + }) + it('collapses several unlisted packages into one sorted summary warning', () => { const result = applySourcePolicy( { From af042bf3820755ea65d81ae866facd6870f4444b Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:54:02 -0700 Subject: [PATCH 31/50] feat: enhance exclude matching to support kind-qualified package patterns --- packages/intent/src/core/excludes.ts | 47 ++++++++++++++++++--- packages/intent/src/core/intent-core.ts | 11 ++--- packages/intent/src/core/source-policy.ts | 12 +++--- packages/intent/tests/excludes.test.ts | 8 ++++ packages/intent/tests/source-policy.test.ts | 14 ++++++ 5 files changed, 75 insertions(+), 17 deletions(-) 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/intent-core.ts b/packages/intent/src/core/intent-core.ts index d24f7a9..c794d5b 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -11,7 +11,6 @@ import { resolveSkillUseFastPath } from './load-resolution.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, - isSourcePermitted, packageNotListedRefusal, readSkillSourcesConfig, scanForPolicedIntents, @@ -302,10 +301,12 @@ function resolveIntentSkillInCwd( 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( diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 99e4f6c..6db1ec4 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -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.`, @@ -102,7 +103,7 @@ export function checkLoadAllowed( 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.`, @@ -168,7 +169,7 @@ 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') { @@ -182,7 +183,8 @@ export function applySourcePolicy( } 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 }, 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/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index e8da1f0..41449d1 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -176,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( { From cba03c4a5548b03722a35d392174838c566ee7a5 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:58:56 -0700 Subject: [PATCH 32/50] feat: implement validation for canonical package relative paths in lockfile and manifest processing --- .../src/core/lockfile/baseline-drift.ts | 32 ++++++++- packages/intent/src/core/lockfile/hash.ts | 29 +-------- packages/intent/src/core/lockfile/lockfile.ts | 6 +- packages/intent/src/core/manifest.ts | 7 +- packages/intent/src/core/skill-path.ts | 65 +++++++++++++++++++ packages/intent/tests/baseline-drift.test.ts | 24 +++++++ packages/intent/tests/lockfile.test.ts | 29 +++++++++ 7 files changed, 158 insertions(+), 34 deletions(-) create mode 100644 packages/intent/src/core/skill-path.ts diff --git a/packages/intent/src/core/lockfile/baseline-drift.ts b/packages/intent/src/core/lockfile/baseline-drift.ts index b164b0b..439a384 100644 --- a/packages/intent/src/core/lockfile/baseline-drift.ts +++ b/packages/intent/src/core/lockfile/baseline-drift.ts @@ -11,6 +11,10 @@ import { repoRoot, resolveCommit, } from '../git-adapter.js' +import { + assertCanonicalPackageRelativePath, + resolveCanonicalPackagePath, +} from '../skill-path.js' import type { IntentLockfile, IntentLockfileSource } from './lockfile.js' export interface BaselineResolution { @@ -90,6 +94,19 @@ export function computeBaselineDrift( 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}` } @@ -109,9 +126,22 @@ export function computeBaselineDrift( const realPackageRoot = realpathSync(packageRoot) 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), + } + } const repoRelativePath = relative( realRoot, - `${realPackageRoot}/${skillPath}`, + resolvedSkillPath, ) if (fileFilter && !fileFilter.has(repoRelativePath)) continue diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index b59637e..b91bea1 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto' import { dirname, isAbsolute, join, relative } from 'node:path' import { readdirSync } from 'node:fs' import type { Dirent } from 'node:fs' +import { assertCanonicalPackageRelativePaths } from '../skill-path.js' import { nodeReadFs } from '../../shared/utils.js' import type { ReadFs } from '../../shared/utils.js' @@ -39,29 +40,6 @@ function isWithinDir(candidate: string, dir: string): boolean { return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) } -function assertValidRelativePath(path: string, label: string): void { - if (path.length === 0) { - throw new Error(`Invalid ${label}: path must not be empty.`) - } - if (isAbsolute(path)) { - throw new Error(`Invalid ${label}: path must be relative, got "${path}".`) - } - if (path.includes('\\')) { - throw new Error( - `Invalid ${label}: path must use "/" separators, got "${path}".`, - ) - } - if ( - path - .split('/') - .some((segment) => segment === '' || segment === '.' || segment === '..') - ) { - throw new Error( - `Invalid ${label}: path must not contain "." or ".." segments, got "${path}".`, - ) - } -} - function assertNoDuplicateKeys(keys: Array, label: string): void { const seen = new Set() for (const key of keys) { @@ -298,10 +276,7 @@ export function computeSourceContentHash( entries: ReadonlyArray, fs: ReadFs = nodeReadFs, ): SourceContentHash { - for (const entry of entries) { - assertValidRelativePath(entry.relativePath, 'skill path') - } - assertNoDuplicateKeys( + assertCanonicalPackageRelativePaths( entries.map((entry) => entry.relativePath), 'skill path', ) diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index c8a2c41..2e11358 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -1,6 +1,7 @@ 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 @@ -139,12 +140,15 @@ function parseSource(value: unknown): IntentLockfileSource { ) } + 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: assertStringArray(source.skills, 'source.skills'), + skills, contentHash: assertString(source.contentHash, 'source.contentHash'), manifestHash: assertNullableString( source.manifestHash, diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts index 501175d..319e492 100644 --- a/packages/intent/src/core/manifest.ts +++ b/packages/intent/src/core/manifest.ts @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto' import { computeSkillFolderHash } from './lockfile/hash.js' import { detectCapabilityHeuristics, findSecretMatches } from './secrets.js' import { nodeReadFs } from '../shared/utils.js' +import { assertCanonicalPackageRelativePath } from './skill-path.js' import type { SkillEntry } from '../shared/types.js' import type { ReadFs } from '../shared/utils.js' @@ -192,11 +193,7 @@ export function parseManifest(raw: unknown): IntentManifest { const skills = skillsRaw.map((entry, index): IntentManifestSkill => { const skillRecord = assertRecord(entry, `skills[${index}]`) const path = assertString(skillRecord.path, `skills[${index}].path`) - if (path.startsWith('/') || path.includes('..')) { - throw new Error( - `Invalid intent.manifest.json: skills[${index}].path must be package-relative without ".." segments.`, - ) - } + assertCanonicalPackageRelativePath(path, `manifest skills[${index}].path`) if (seenPaths.has(path)) { throw new Error( `Invalid intent.manifest.json: duplicate skill path "${path}".`, diff --git a/packages/intent/src/core/skill-path.ts b/packages/intent/src/core/skill-path.ts new file mode 100644 index 0000000..9cb1681 --- /dev/null +++ b/packages/intent/src/core/skill-path.ts @@ -0,0 +1,65 @@ +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 +} \ No newline at end of file diff --git a/packages/intent/tests/baseline-drift.test.ts b/packages/intent/tests/baseline-drift.test.ts index ab0f95b..2d5496c 100644 --- a/packages/intent/tests/baseline-drift.test.ts +++ b/packages/intent/tests/baseline-drift.test.ts @@ -122,6 +122,30 @@ describe('resolveBaseline', () => { }) describe('computeBaselineDrift', () => { + 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('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') diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts index c80ae5a..cbe6064 100644 --- a/packages/intent/tests/lockfile.test.ts +++ b/packages/intent/tests/lockfile.test.ts @@ -180,6 +180,35 @@ describe('parseIntentLockfile', () => { ), ).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/, + ) + }) }) describe('readIntentLockfile', () => { From daeea5e901fe18957e75dbaec90b5993483ac089 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 16:59:01 -0700 Subject: [PATCH 33/50] refactor: simplify path validation logic in resolveCanonicalPackagePath function --- packages/intent/src/core/lockfile/baseline-drift.ts | 5 +---- packages/intent/src/core/skill-path.ts | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/intent/src/core/lockfile/baseline-drift.ts b/packages/intent/src/core/lockfile/baseline-drift.ts index 439a384..a73cc23 100644 --- a/packages/intent/src/core/lockfile/baseline-drift.ts +++ b/packages/intent/src/core/lockfile/baseline-drift.ts @@ -139,10 +139,7 @@ export function computeBaselineDrift( reason: err instanceof Error ? err.message : String(err), } } - const repoRelativePath = relative( - realRoot, - resolvedSkillPath, - ) + const repoRelativePath = relative(realRoot, resolvedSkillPath) if (fileFilter && !fileFilter.has(repoRelativePath)) continue diff --git a/packages/intent/src/core/skill-path.ts b/packages/intent/src/core/skill-path.ts index 9cb1681..f02359c 100644 --- a/packages/intent/src/core/skill-path.ts +++ b/packages/intent/src/core/skill-path.ts @@ -53,13 +53,10 @@ export function resolveCanonicalPackagePath( assertCanonicalPackageRelativePath(path, label) const resolvedPath = resolve(packageRoot, path) const packageRelativePath = relative(packageRoot, resolvedPath) - if ( - packageRelativePath.startsWith('..') || - isAbsolute(packageRelativePath) - ) { + if (packageRelativePath.startsWith('..') || isAbsolute(packageRelativePath)) { throw new Error( `Invalid ${label}: path escapes the package root, got "${path}".`, ) } return resolvedPath -} \ No newline at end of file +} From 00d1f375ca2ecb1724f7db36cc615a56bdc0ad71 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:05:17 -0700 Subject: [PATCH 34/50] feat: add canonicalization for manifest serialization and enhance MCP tool validation in tests --- packages/intent/src/core/manifest.ts | 114 +++++++++++++-- packages/intent/src/core/source-policy.ts | 2 +- packages/intent/tests/lockfile-diff.test.ts | 79 ++++++++++ packages/intent/tests/lockfile-state.test.ts | 64 ++++++++ packages/intent/tests/manifest.test.ts | 145 +++++++++++++++++-- 5 files changed, 375 insertions(+), 29 deletions(-) diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts index 319e492..ea81aae 100644 --- a/packages/intent/src/core/manifest.ts +++ b/packages/intent/src/core/manifest.ts @@ -30,6 +30,14 @@ interface ManifestMcpTool { inputSchema?: Record } +type JsonValue = + | null + | boolean + | number + | string + | Array + | { [key: string]: JsonValue } + export interface IntentManifest { manifestVersion: 1 package: string @@ -130,7 +138,11 @@ export function generateManifest( // generateManifest) and stable key order, no generated timestamps — a // manifest regenerated from unchanged inputs serializes byte-identical. export function serializeManifest(manifest: IntentManifest): string { - const canonical = { + return `${JSON.stringify(canonicalManifest(manifest), null, 2)}\n` +} + +function canonicalManifest(manifest: IntentManifest): IntentManifest { + return { manifestVersion: manifest.manifestVersion, package: manifest.package, packageVersion: manifest.packageVersion, @@ -142,10 +154,9 @@ export function serializeManifest(manifest: IntentManifest): string { contentHash: skill.contentHash, capabilities: skill.capabilities.toSorted(compareStrings), declaredSecrets: skill.declaredSecrets.toSorted(compareStrings), - mcpTools: skill.mcpTools, + mcpTools: canonicalMcpTools(skill.mcpTools, 'mcpTools'), })), } - return `${JSON.stringify(canonical, null, 2)}\n` } export function writeIntentManifest( @@ -178,6 +189,85 @@ function assertStringArray(value: unknown, label: string): Array { return value } +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' && value !== null) { + 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): ManifestMcpTool => { + const record = assertRecord(tool, `${label}[${index}]`) + 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') if (record.manifestVersion !== 1) { @@ -216,9 +306,10 @@ export function parseManifest(raw: unknown): IntentManifest { skillRecord.declaredSecrets ?? [], `skills[${index}].declaredSecrets`, ), - mcpTools: Array.isArray(skillRecord.mcpTools) - ? (skillRecord.mcpTools as Array) - : [], + mcpTools: canonicalMcpTools( + skillRecord.mcpTools ?? [], + `skills[${index}].mcpTools`, + ), } }) @@ -248,15 +339,6 @@ export function readIntentManifest( // without needing to store the whole manifest inline. export function computeManifestHash(manifest: IntentManifest): string { const hash = createHash('sha256') - for (const skill of manifest.skills.toSorted((a, b) => - compareStrings(a.path, b.path), - )) { - hash.update(skill.path) - hash.update('\0') - hash.update(skill.contentHash) - hash.update('\0') - hash.update(skill.capabilities.toSorted(compareStrings).join(',')) - hash.update('\0') - } + hash.update(serializeManifest(manifest)) return `sha256-${hash.digest('hex')}` } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 6db1ec4..b6e88f6 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -99,7 +99,7 @@ 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) } diff --git a/packages/intent/tests/lockfile-diff.test.ts b/packages/intent/tests/lockfile-diff.test.ts index ed92583..ccceeeb 100644 --- a/packages/intent/tests/lockfile-diff.test.ts +++ b/packages/intent/tests/lockfile-diff.test.ts @@ -1,5 +1,6 @@ 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, @@ -137,6 +138,84 @@ describe('diffLockfileSources', () => { ]) }) + 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' }) diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts index 6fdd68d..4d00374 100644 --- a/packages/intent/tests/lockfile-state.test.ts +++ b/packages/intent/tests/lockfile-state.test.ts @@ -100,6 +100,70 @@ describe('buildCurrentLockfileSources', () => { expect(entry!.capabilities).toEqual(['uses_network']) }) + it.each([ + [ + 'declared secrets', + { + declaredSecrets: ['API_TOKEN'], + mcpTools: [], + }, + ], + [ + 'an MCP tool name', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch' }], + }, + ], + [ + 'an MCP tool description', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch', description: 'Fetch a resource.' }], + }, + ], + [ + 'an MCP tool schema', + { + declaredSecrets: [], + mcpTools: [{ name: 'fetch', inputSchema: { type: 'object' } }], + }, + ], + ])('changes manifestHash when %s changes', (_, disclosure) => { + const root = createRoot() + const skillPath = writeSkill(root, 'core', 'body') + const pkg = createPackage({ + name: '@acme/pkg', + kind: 'npm', + packageRoot: root, + skills: [{ name: 'core', path: skillPath, description: 'desc' }], + }) + const manifestPath = join(root, 'skills', 'intent.manifest.json') + const baseManifest = { + manifestVersion: 1, + package: '@acme/pkg', + packageVersion: '1.0.0', + skills: [ + { + name: 'core', + path: 'skills/core/SKILL.md', + contentHash: 'sha256-core', + capabilities: [], + declaredSecrets: [], + mcpTools: [], + }, + ], + } + 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') diff --git a/packages/intent/tests/manifest.test.ts b/packages/intent/tests/manifest.test.ts index c083f93..6b9cc62 100644 --- a/packages/intent/tests/manifest.test.ts +++ b/packages/intent/tests/manifest.test.ts @@ -1,9 +1,4 @@ -import { - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -27,15 +22,16 @@ afterEach(() => { rmSync(packageRoot, { recursive: true, force: true }) }) -function writeSkill( - relDir: string, - content: string, -): SkillEntry { +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: '' } + return { + name: relDir.split('/').pop() ?? relDir, + path: filePath, + description: '', + } } describe('generateManifest', () => { @@ -59,7 +55,10 @@ describe('generateManifest', () => { }) it('pre-fills uses_network from a curl/fetch reference', () => { - const skill = writeSkill('skills/net', 'Run `curl https://example.com/api`.') + 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) @@ -223,4 +222,126 @@ describe('computeManifestHash', () => { } 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/) + } + }) }) From b686e878bf0acc5f9c7cffd15a3d4ff46c4af001 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:09:33 -0700 Subject: [PATCH 35/50] feat: enhance currentBlobSha function with error handling for directories and add tests for Git command failures --- packages/intent/src/core/git-adapter.ts | 29 ++++++++-- .../intent/tests/git-adapter-failure.test.ts | 53 +++++++++++++++++++ packages/intent/tests/git-adapter.test.ts | 16 +++++- 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 packages/intent/tests/git-adapter-failure.test.ts diff --git a/packages/intent/src/core/git-adapter.ts b/packages/intent/src/core/git-adapter.ts index 2c62225..0471b86 100644 --- a/packages/intent/src/core/git-adapter.ts +++ b/packages/intent/src/core/git-adapter.ts @@ -6,6 +6,8 @@ // 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 @@ -37,6 +39,9 @@ const FORBIDDEN_FLAG_PREFIXES = [ '--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) { @@ -78,6 +83,8 @@ function runGit( env: hardenedEnv(), stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', + maxBuffer: GIT_MAX_OUTPUT_BYTES, + timeout: GIT_TIMEOUT_MS, }) return ok(stdout) } catch (err) { @@ -154,11 +161,23 @@ export function currentBlobSha( cwd: string, relPath: string, ): GitAdapterOutcome { - const result = runGit(cwd, ['hash-object', '--', relPath]) - if (!result.ok) { - // hash-object fails with a non-zero exit when the path does not exist; - // treat that as "no current content" rather than an adapter failure. - return ok(null) + 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/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 index dfd61f1..a79b18f 100644 --- a/packages/intent/tests/git-adapter.test.ts +++ b/packages/intent/tests/git-adapter.test.ts @@ -1,4 +1,10 @@ -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +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' @@ -136,6 +142,14 @@ describe('currentBlobSha', () => { } }) + 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']) From 2f13bbec1bfa4c0fb0606a9e04cdf90353b3463d Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:12:09 -0700 Subject: [PATCH 36/50] feat: add validation for hash limits in source content and skill folder processing with corresponding tests --- packages/intent/src/core/lockfile/hash.ts | 187 +++++++++++++++------- packages/intent/tests/hash.test.ts | 59 ++++++- 2 files changed, 190 insertions(+), 56 deletions(-) diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index b91bea1..9de949b 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto' import { dirname, isAbsolute, join, relative } from 'node:path' -import { readdirSync } from 'node:fs' import type { Dirent } from 'node:fs' import { assertCanonicalPackageRelativePaths } from '../skill-path.js' import { nodeReadFs } from '../../shared/utils.js' @@ -18,6 +17,22 @@ export interface SourceContentHash { const RECORD_SEPARATOR = Buffer.from([0]) +export const HASH_LIMITS = { + maxRecursionDepth: 32, + maxFileCount: 1000, + maxFileBytes: 4 * 1024 * 1024, + maxTotalBytes: 16 * 1024 * 1024, +} as const + +type HashCollectionState = { + fileCount: number +} + +type ReadSkillContent = { + content: Buffer + bytesRead: number +} + function compareStrings(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } @@ -50,6 +65,24 @@ function assertNoDuplicateKeys(keys: Array, label: string): void { } } +function assertHashFileCount(fileCount: number): void { + if (fileCount > HASH_LIMITS.maxFileCount) { + throw new Error( + `Hash file count limit (${HASH_LIMITS.maxFileCount}) exceeded.`, + ) + } +} + +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. @@ -98,7 +131,7 @@ function readSkillMdContent( absolutePath: string, realPackageRoot: string, logicalRelativePath: string, -): Buffer { +): ReadSkillContent { let realPath: string try { realPath = fs.realpathSync(absolutePath) @@ -115,7 +148,38 @@ function readSkillMdContent( } const raw = readRegularFile(fs, realPath, logicalRelativePath) - return isBinaryContent(raw) ? raw : normalizeLineEndings(raw) + 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( @@ -157,7 +221,14 @@ function collectSupportFiles( 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, @@ -183,15 +254,26 @@ function collectSupportFiles( const absolutePath = join(dir, dirent.name) if (dirent.isDirectory()) { files.push( - ...collectSupportFiles(fs, absolutePath, baseDir, realPackageRoot), + ...collectSupportFiles( + fs, + absolutePath, + baseDir, + realPackageRoot, + depth + 1, + state, + ), ) continue } if (dirent.isFile()) { - files.push({ - relativePath: toPosixRelative(baseDir, absolutePath), - absolutePath, - }) + appendHashFile( + files, + { + relativePath: toPosixRelative(baseDir, absolutePath), + absolutePath, + }, + state, + ) continue } if (dirent.isSymbolicLink()) { @@ -206,13 +288,24 @@ function collectSupportFiles( const stat = fs.lstatSync(realPath) if (stat.isDirectory()) { files.push( - ...collectSupportFiles(fs, absolutePath, baseDir, realPackageRoot), + ...collectSupportFiles( + fs, + absolutePath, + baseDir, + realPackageRoot, + depth + 1, + state, + ), ) } else if (stat.isFile()) { - files.push({ - relativePath: toPosixRelative(baseDir, absolutePath), - absolutePath, - }) + appendHashFile( + files, + { + relativePath: toPosixRelative(baseDir, absolutePath), + absolutePath, + }, + state, + ) } } } @@ -222,11 +315,13 @@ function collectSupportFiles( function collectSkillContentEntries( fs: ReadFs, - packageRoot: string, + pathBaseDir: string, entries: ReadonlyArray, realPackageRoot: string, ): Array { const contentEntries = [...entries] + const state = { fileCount: contentEntries.length } + assertHashFileCount(state.fileCount) for (const entry of entries) { const skillDir = dirname(entry.absolutePath) let dirents: Array> @@ -237,7 +332,7 @@ function collectSkillContentEntries( }) } catch (err) { throw new Error( - `Failed to read skill directory "${toPosixRelative(packageRoot, skillDir)}": ${err instanceof Error ? err.message : String(err)}`, + `Failed to read skill directory "${toPosixRelative(pathBaseDir, skillDir)}": ${err instanceof Error ? err.message : String(err)}`, ) } @@ -256,13 +351,20 @@ function collectSkillContentEntries( realPath = fs.realpathSync(supportDir) } catch (err) { throw new Error( - `Failed to resolve skill directory "${toPosixRelative(packageRoot, supportDir)}": ${err instanceof Error ? err.message : String(err)}`, + `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, packageRoot, realPackageRoot), + ...collectSupportFiles( + fs, + supportDir, + pathBaseDir, + realPackageRoot, + 0, + state, + ), ) } } @@ -293,15 +395,7 @@ export function computeSourceContentHash( 'skill content path', ) - const hashed = contentEntries.map((entry) => ({ - key: entry.relativePath, - value: readSkillMdContent( - fs, - entry.absolutePath, - realPackageRoot, - entry.relativePath, - ), - })) + const hashed = readHashEntries(contentEntries, fs, realPackageRoot) return { skills: entries.map((entry) => entry.relativePath).toSorted(compareStrings), @@ -314,25 +408,6 @@ function toPosixRelative(baseDir: string, absolutePath: string): string { return rel.split('\\').join('/') } -function collectFilesRecursive( - dir: string, - baseDir: string, -): Array { - const entries: Array = [] - for (const dirent of readdirSync(dir, { withFileTypes: true })) { - const absolutePath = join(dir, dirent.name) - if (dirent.isDirectory()) { - entries.push(...collectFilesRecursive(absolutePath, baseDir)) - } else if (dirent.isFile()) { - entries.push({ - relativePath: toPosixRelative(baseDir, absolutePath), - absolutePath, - }) - } - } - return entries -} - // 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 @@ -342,22 +417,24 @@ export function computeSkillFolderHash( packageRoot: string, ): string { const realPackageRoot = nodeReadFs.realpathSync(packageRoot) - const entries = collectFilesRecursive(skillDir, skillDir) + const entries = collectSkillContentEntries( + nodeReadFs, + skillDir, + [ + { + relativePath: 'SKILL.md', + absolutePath: join(skillDir, 'SKILL.md'), + }, + ], + realPackageRoot, + ) assertNoDuplicateKeys( entries.map((entry) => entry.relativePath), 'skill folder path', ) - const hashed = entries.map((entry) => ({ - key: entry.relativePath, - value: readSkillMdContent( - nodeReadFs, - entry.absolutePath, - realPackageRoot, - entry.relativePath, - ), - })) + const hashed = readHashEntries(entries, nodeReadFs, realPackageRoot) return hashEntries(hashed) } diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index 18f86ae..15adbff 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -9,7 +9,11 @@ import { import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' -import { computeSourceContentHash } from '../src/core/lockfile/hash.js' +import { + computeSkillFolderHash, + computeSourceContentHash, + HASH_LIMITS, +} from '../src/core/lockfile/hash.js' const roots: Array = [] @@ -317,6 +321,59 @@ describe('computeSourceContentHash', () => { 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 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( From 15f1d86b1a41a6c48750c42f868b207f0340ea6f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:14:04 -0700 Subject: [PATCH 37/50] feat: add readSkillFolderContents function and update generateManifest to handle skill folder content with secret detection --- packages/intent/src/core/lockfile/hash.ts | 29 ++++++++++--- packages/intent/src/core/manifest.ts | 50 ++++++++++++++++------- packages/intent/tests/manifest.test.ts | 33 ++++++++++++++- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 9de949b..1a30973 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -15,6 +15,11 @@ export interface SourceContentHash { contentHash: string } +export interface SkillFolderContentEntry { + relativePath: string + content: Buffer +} + const RECORD_SEPARATOR = Buffer.from([0]) export const HASH_LIMITS = { @@ -416,9 +421,22 @@ export function computeSkillFolderHash( skillDir: string, packageRoot: string, ): string { - const realPackageRoot = nodeReadFs.realpathSync(packageRoot) + return hashEntries( + readSkillFolderContents(skillDir, packageRoot).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( - nodeReadFs, + fs, skillDir, [ { @@ -434,7 +452,8 @@ export function computeSkillFolderHash( 'skill folder path', ) - const hashed = readHashEntries(entries, nodeReadFs, realPackageRoot) - - return hashEntries(hashed) + return readHashEntries(entries, fs, realPackageRoot).map((entry) => ({ + relativePath: entry.key, + content: entry.value, + })) } diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts index ea81aae..e606642 100644 --- a/packages/intent/src/core/manifest.ts +++ b/packages/intent/src/core/manifest.ts @@ -3,10 +3,13 @@ // 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, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { existsSync, readdirSync, writeFileSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import { createHash } from 'node:crypto' -import { computeSkillFolderHash } from './lockfile/hash.js' +import { + computeSkillFolderHash, + readSkillFolderContents, +} from './lockfile/hash.js' import { detectCapabilityHeuristics, findSecretMatches } from './secrets.js' import { nodeReadFs } from '../shared/utils.js' import { assertCanonicalPackageRelativePath } from './skill-path.js' @@ -75,9 +78,9 @@ export type GenerateManifestOutcome = // 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 skill body contains a literal -// secret value; a declared secret NAME belongs in declaredSecrets, never a -// value in the body. +// 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, @@ -90,20 +93,37 @@ export function generateManifest( for (const skill of skills) { const skillDir = dirname(skill.path) const relativePath = toPosixPath(relative(packageRoot, skill.path)) - const content = readFileSync(skill.path, 'utf8') - - const matches = findSecretMatches(content) - if (matches.length > 0) { - for (const match of matches) { - secretFindings.push({ - skillPath: relativePath, - patternName: match.name, - }) + 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(content) + 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') diff --git a/packages/intent/tests/manifest.test.ts b/packages/intent/tests/manifest.test.ts index 6b9cc62..7ec1b67 100644 --- a/packages/intent/tests/manifest.test.ts +++ b/packages/intent/tests/manifest.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { computeManifestHash, @@ -122,6 +122,37 @@ describe('generateManifest', () => { { 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('serializeManifest / parseManifest round-trip', () => { From d2d0a83bc6993653e4afbd61665a6756a35438a9 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:17:31 -0700 Subject: [PATCH 38/50] feat: update lockfile documentation and enhance frozen mode error messages; add tests for capabilities handling --- docs/security/lockfile.md | 2 +- .../intent/src/commands/skills/support.ts | 2 +- .../integration/skills-frozen-cli.test.ts | 117 ++++++++++++++++++ packages/intent/tests/lockfile-state.test.ts | 23 ++++ packages/intent/tests/lockfile.test.ts | 16 +++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 packages/intent/tests/integration/skills-frozen-cli.test.ts diff --git a/docs/security/lockfile.md b/docs/security/lockfile.md index fb1f1db..d19a5e7 100644 --- a/docs/security/lockfile.md +++ b/docs/security/lockfile.md @@ -34,7 +34,7 @@ This is tamper-evidence, not semantic validation. Approving a source means **a h - **`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 that fed `contentHash`. It's what lets `diff` show *which files* changed, not just an opaque hash flip. - **`contentHash`** is a `sha256-` digest over each source's `SKILL.md` files (path + bytes, LF-normalized). Only `SKILL.md` files are hashed — scripts, assets, and other files under `skills/` are out of scope for now. A file rename with identical bytes changes the hash, because a path change is a real content-set change. -- **`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)) — reserved-nullable so the lockfile works before every package adopts a manifest. Once a manifest exists, its hash and the union of its skills' declared capabilities populate these fields automatically on the next `scan`/`diff`/`approve`. `declaredSecrets`, `mcpTools`, and `mcpPolicy` remain reserved and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. +- **`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)). Once a manifest exists, `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 and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. - **`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, a duplicate `(kind, id)` entry, or any other structural problem is a **malformed lockfile** — fails closed, never silently treated as an empty lock. diff --git a/packages/intent/src/commands/skills/support.ts b/packages/intent/src/commands/skills/support.ts index 795c41f..f3e4398 100644 --- a/packages/intent/src/commands/skills/support.ts +++ b/packages/intent/src/commands/skills/support.ts @@ -117,7 +117,7 @@ export function enforceFrozenMode( if (!diff.hasLockfile) { fail( - 'Frozen mode requires intent.lock. Run `intent skills scan` outside frozen mode first.', + 'Frozen mode requires intent.lock. Run `intent skills approve --all` outside frozen mode first.', 4, ) } 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..7090204 --- /dev/null +++ b/packages/intent/tests/integration/skills-frozen-cli.test.ts @@ -0,0 +1,117 @@ +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' + +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.', +): void { + 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`, + ) +} + +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 runCli(root: string, args: Array): number | null { + return spawnSync(process.execPath, [cliPath, 'skills', ...args], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, CI: '', INTENT_FROZEN: '' }, + timeout: 30_000, + }).status +} + +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 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 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) + }) +}) diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts index 4d00374..5b27f1a 100644 --- a/packages/intent/tests/lockfile-state.test.ts +++ b/packages/intent/tests/lockfile-state.test.ts @@ -100,6 +100,29 @@ describe('buildCurrentLockfileSources', () => { 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.each([ [ 'declared secrets', diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts index cbe6064..43715f2 100644 --- a/packages/intent/tests/lockfile.test.ts +++ b/packages/intent/tests/lockfile.test.ts @@ -173,6 +173,22 @@ describe('parseIntentLockfile', () => { ).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( From d8c62c2bd9499cd68220c918adba6098d3f8dfd4 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:19:39 -0700 Subject: [PATCH 39/50] feat: update lockfile documentation to include consumer CI instructions and enhance error message for missing intent.lock --- docs/security/lockfile.md | 11 +++++++++++ packages/intent/tests/skills-stale.test.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/security/lockfile.md b/docs/security/lockfile.md index d19a5e7..15dde51 100644 --- a/docs/security/lockfile.md +++ b/docs/security/lockfile.md @@ -67,6 +67,17 @@ Frozen mode is the CI gate: it turns "an allowlisted source's content silently d 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. diff --git a/packages/intent/tests/skills-stale.test.ts b/packages/intent/tests/skills-stale.test.ts index 64f113d..60072c8 100644 --- a/packages/intent/tests/skills-stale.test.ts +++ b/packages/intent/tests/skills-stale.test.ts @@ -98,7 +98,9 @@ describe('runSkillsStaleCommand', () => { 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') + 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 () => { From 5b545d5b6a80607967d2d16b854ca816ca905bdb Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:26:29 -0700 Subject: [PATCH 40/50] feat: add lockfile scan benchmarks and update writeFile function to accept Buffer --- benchmarks/intent/README.md | 19 ++++ benchmarks/intent/helpers.ts | 2 +- benchmarks/intent/lockfile-scan.bench.ts | 137 +++++++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 benchmarks/intent/README.md create mode 100644 benchmarks/intent/lockfile-scan.bench.ts 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, +}) From 5637107a0976310678d93a064e07ca575dd2aa11 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:28:40 -0700 Subject: [PATCH 41/50] feat: export lockfile types and add tests for public lockfile types --- packages/intent/src/core/lockfile/lockfile.ts | 8 +-- packages/intent/src/index.ts | 10 ++++ .../tests/public-lockfile-types.test.ts | 52 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 packages/intent/tests/public-lockfile-types.test.ts diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index 2e11358..a8a10a2 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -13,11 +13,11 @@ export interface IntentLockfile { policy: IntentLockfilePolicy } -interface IntentLockfileStaleness { +export interface IntentLockfileStaleness { baseline: IntentLockfileStalenessBaseline } -interface IntentLockfileStalenessBaseline { +export interface IntentLockfileStalenessBaseline { kind: 'tag' ref: string commit: string @@ -37,11 +37,11 @@ export interface IntentLockfileSource { mcpPolicy?: Record } -interface IntentLockfilePolicy { +export interface IntentLockfilePolicy { ignores: Array } -interface IntentLockfilePolicyIgnore { +export interface IntentLockfilePolicyIgnore { id: string scope: { source: string diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index dc71567..f0e0844 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -48,3 +48,13 @@ export type { SkillStaleness, StalenessSignal, } from './shared/types.js' +export type { + IntentLockfile, + IntentLockfilePolicy, + IntentLockfilePolicyIgnore, + IntentLockfileSource, + IntentLockfileStaleness, + IntentLockfileStalenessBaseline, + ReadIntentLockfileResult, +} from './core/lockfile/lockfile.js' +export type { SourceIdentity } from './core/types.js' diff --git a/packages/intent/tests/public-lockfile-types.test.ts b/packages/intent/tests/public-lockfile-types.test.ts new file mode 100644 index 0000000..b93e86a --- /dev/null +++ b/packages/intent/tests/public-lockfile-types.test.ts @@ -0,0 +1,52 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { + IntentLockfile, + IntentLockfilePolicy, + IntentLockfilePolicyIgnore, + IntentLockfileSource, + IntentLockfileStaleness, + IntentLockfileStalenessBaseline, + ReadIntentLockfileResult, + SourceIdentity, +} from '@tanstack/intent' + +describe('public lockfile types', () => { + it('imports lockfile and source identity types from the package root', () => { + const source: IntentLockfileSource = { + id: 'foo', + kind: 'npm', + version: '1.0.0', + resolution: 'npm:foo@1.0.0', + skills: ['skills/core/SKILL.md'], + contentHash: 'sha256-foo', + manifestHash: null, + capabilities: null, + } + const ignore: IntentLockfilePolicyIgnore = { + id: 'ignored', + scope: { source: 'npm:foo', contentHash: 'sha256-foo' }, + reason: 'reviewed', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + } + const policy: IntentLockfilePolicy = { ignores: [ignore] } + const baseline: IntentLockfileStalenessBaseline = { + kind: 'tag', + ref: 'v1.0.0', + commit: 'abc123', + } + const staleness: IntentLockfileStaleness = { baseline } + const lockfile: IntentLockfile = { + lockfileVersion: 1, + intentVersion: '1.0.0', + staleness, + sources: [source], + policy, + } + const result: ReadIntentLockfileResult = { status: 'found', lockfile } + const identity: SourceIdentity = { kind: 'npm', id: 'foo' } + + expectTypeOf(result).toMatchTypeOf() + expectTypeOf(identity).toMatchTypeOf() + }) +}) From e00c50221d3b769307b6240e1029f0c0f855649e Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 17:43:56 -0700 Subject: [PATCH 42/50] feat: enhance documentation and error handling for lockfile and skill resolution; add tests for ambiguous package scenarios --- docs/cli/intent-list.md | 6 ++-- docs/cli/intent-skills.md | 4 +-- docs/concepts/configuration.md | 8 +++-- docs/security/lockfile.md | 10 +++--- packages/intent/README.md | 2 ++ packages/intent/src/core/load-resolution.ts | 14 +++++--- .../src/core/lockfile/baseline-drift.ts | 26 +++++++++++++- packages/intent/src/core/manifest.ts | 18 ++++++++-- packages/intent/src/core/types.ts | 1 + packages/intent/src/skills/resolver.ts | 22 +++++++++++- packages/intent/tests/baseline-drift.test.ts | 34 ++++++++++++++++++- .../source-policy-surfaces.test.ts | 10 ++++++ packages/intent/tests/lockfile-state.test.ts | 16 +++++++++ packages/intent/tests/manifest.test.ts | 10 ++++++ packages/intent/tests/resolver.test.ts | 16 +++++++++ 15 files changed, 176 insertions(+), 21 deletions(-) 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 index f39e01e..76a2506 100644 --- a/docs/cli/intent-skills.md +++ b/docs/cli/intent-skills.md @@ -91,9 +91,9 @@ Read-only. Surfaces staleness **candidates** for human/agent review — never a npx @tanstack/intent@latest skills generate-manifest [--json] ``` -Maintainer-only. Writes `skills/intent.manifest.json` inside each discovered package — never `intent.lock`, and unrelated to frozen mode. For each skill folder, computes a content hash over the **whole folder** (`SKILL.md` plus any `references/`, `assets/`, `scripts/` — a wider scope than the lockfile's `SKILL.md`-only aggregate hash) and runs static heuristics to 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. +Maintainer-only. Writes `skills/intent.manifest.json` inside each discovered package — never `intent.lock`, and unrelated to 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 skill body 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. +- **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. ## Options 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/security/lockfile.md b/docs/security/lockfile.md index 15dde51..189be62 100644 --- a/docs/security/lockfile.md +++ b/docs/security/lockfile.md @@ -32,12 +32,12 @@ This is tamper-evidence, not semantic validation. Approving a source means **a h ``` - **`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 that fed `contentHash`. It's what lets `diff` show *which files* changed, not just an opaque hash flip. -- **`contentHash`** is a `sha256-` digest over each source's `SKILL.md` files (path + bytes, LF-normalized). Only `SKILL.md` files are hashed — scripts, assets, and other files under `skills/` are out of scope for now. A file rename with identical bytes changes the hash, because a path change is a real content-set change. -- **`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)). Once a manifest exists, `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 and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. +- **`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 validate. 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 and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. - **`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, a duplicate `(kind, id)` entry, or any other structural problem is a **malformed lockfile** — fails closed, never silently treated as an empty lock. +- A `lockfileVersion` newer than this Intent version supports, a duplicate `(kind, id)` entry, a non-canonical skill path, or any other structural problem is a **malformed lockfile** — fails closed, never silently treated as an empty lock. ## Commands @@ -63,7 +63,7 @@ Frozen mode is the CI gate: it turns "an allowlisted source's content silently d - 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`). -- Makes zero network calls (skips the staleness-check registry lookup) and zero subprocess calls (skips shelling out to a package manager to detect a global install path). +- `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. diff --git a/packages/intent/README.md b/packages/intent/README.md index 02968c9..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 ` | 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 index a73cc23..0fdb295 100644 --- a/packages/intent/src/core/lockfile/baseline-drift.ts +++ b/packages/intent/src/core/lockfile/baseline-drift.ts @@ -2,7 +2,7 @@ // 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 { relative } from 'node:path' +import { isAbsolute, relative } from 'node:path' import { realpathSync } from 'node:fs' import { blobShaAtCommit, @@ -82,6 +82,11 @@ export interface BaselineDriftFailure { 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 @@ -139,6 +144,25 @@ export function computeBaselineDrift( reason: err instanceof Error ? err.message : String(err), } } + try { + const realSkillPath = realpathSync(resolvedSkillPath) + if (!isWithinDir(realSkillPath, realPackageRoot)) { + return { + ok: false, + reason: `source.skills path escapes the package root via a symlink: "${skillPath}".`, + } + } + } 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, resolvedSkillPath) if (fileFilter && !fileFilter.has(repoRelativePath)) continue diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts index e606642..8998210 100644 --- a/packages/intent/src/core/manifest.ts +++ b/packages/intent/src/core/manifest.ts @@ -346,10 +346,22 @@ export function readIntentManifest( fs: Pick = nodeReadFs, ): IntentManifest | null { if (!fs.existsSync(filePath)) return null + + let content: string try { - return parseManifest(JSON.parse(fs.readFileSync(filePath, 'utf8'))) - } catch { - return null + 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)}`, + ) } } diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 0ff2949..31ea529 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -118,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/skills/resolver.ts b/packages/intent/src/skills/resolver.ts index 93f0142..3bd53b6 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 @@ -162,6 +165,21 @@ export function resolveSkillUse( }) } + const identities = [ + ...new Set( + packages.map((candidate) => `${candidate.kind}:${candidate.name}`), + ), + ].toSorted() + if (identities.length > 1) { + throw new ResolveSkillUseError({ + availablePackages: identities, + code: 'package-ambiguous', + packageName, + skillName, + use, + }) + } + const resolvedSkill = resolveSkillEntry(packageName, skillName, pkg.skills) const skill = resolvedSkill.skill @@ -213,6 +231,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/tests/baseline-drift.test.ts b/packages/intent/tests/baseline-drift.test.ts index 2d5496c..2cd4979 100644 --- a/packages/intent/tests/baseline-drift.test.ts +++ b/packages/intent/tests/baseline-drift.test.ts @@ -1,4 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +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' @@ -146,6 +152,32 @@ describe('computeBaselineDrift', () => { }) }) + 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') diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 284bb4c..be381d6 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -207,6 +207,16 @@ describe('source identity lifecycle', () => { }) } + 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') diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts index 5b27f1a..76e6662 100644 --- a/packages/intent/tests/lockfile-state.test.ts +++ b/packages/intent/tests/lockfile-state.test.ts @@ -123,6 +123,22 @@ describe('buildCurrentLockfileSources', () => { 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([ [ 'declared secrets', diff --git a/packages/intent/tests/manifest.test.ts b/packages/intent/tests/manifest.test.ts index 7ec1b67..3f36411 100644 --- a/packages/intent/tests/manifest.test.ts +++ b/packages/intent/tests/manifest.test.ts @@ -224,6 +224,16 @@ describe('writeIntentManifest / readIntentManifest', () => { 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', () => { diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 1ffb28e..a1c5824 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -77,6 +77,22 @@ 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 local package and exact skill', () => { const pkg = intentPackage({ name: '@tanstack/query', From 34d4644ee4a9b11ae61dab66e5837e17a2330ef1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 18:07:54 -0700 Subject: [PATCH 43/50] feat: enhance skills generate manifest command to handle frozen mode and validate package ownership; add tests for workspace and installed dependencies --- packages/intent/src/cli.ts | 2 +- .../src/commands/skills/generate-manifest.ts | 35 +++++ .../tests/skills-generate-manifest.test.ts | 126 +++++++++++++++++- 3 files changed, 159 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 0ed7d66..6589fe4 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -326,7 +326,7 @@ function createCli(): CAC { const { runSkillsGenerateManifestCommand } = await import('./commands/skills/generate-manifest.js') await runSkillsGenerateManifestCommand( - options, + { ...options, ...frozenOptions }, scanPolicedIntentsOrFail, ) return diff --git a/packages/intent/src/commands/skills/generate-manifest.ts b/packages/intent/src/commands/skills/generate-manifest.ts index 9985c80..0ae1043 100644 --- a/packages/intent/src/commands/skills/generate-manifest.ts +++ b/packages/intent/src/commands/skills/generate-manifest.ts @@ -1,10 +1,16 @@ +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 { @@ -18,8 +24,37 @@ interface GenerateManifestResult { 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) { diff --git a/packages/intent/tests/skills-generate-manifest.test.ts b/packages/intent/tests/skills-generate-manifest.test.ts index 04a0fb4..36620bd 100644 --- a/packages/intent/tests/skills-generate-manifest.test.ts +++ b/packages/intent/tests/skills-generate-manifest.test.ts @@ -1,6 +1,13 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +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' @@ -45,6 +52,10 @@ describe('runSkillsGenerateManifestCommand', () => { 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') @@ -61,12 +72,43 @@ describe('runSkillsGenerateManifestCommand', () => { } } + 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') @@ -79,8 +121,84 @@ describe('runSkillsGenerateManifestCommand', () => { 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([]))) + 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.') @@ -97,6 +215,7 @@ describe('runSkillsGenerateManifestCommand', () => { runSkillsGenerateManifestCommand( {}, () => Promise.resolve(policedScan([pkg])), + pkg.packageRoot, ), ).rejects.toMatchObject({ message: expect.stringContaining('literal secret value'), @@ -112,6 +231,7 @@ describe('runSkillsGenerateManifestCommand', () => { await runSkillsGenerateManifestCommand( { json: true }, () => Promise.resolve(policedScan([pkg])), + pkg.packageRoot, ) const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') From 570df5fcdf7826517063f28c261e52668d14daa6 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 18:15:09 -0700 Subject: [PATCH 44/50] feat: enhance baseline drift computation to skip installed dependencies outside the consumer repository; add tests for frozen mode and approved installed dependencies --- .../src/core/lockfile/baseline-drift.ts | 5 +- packages/intent/tests/baseline-drift.test.ts | 52 +++++++++++++++++++ packages/intent/tests/skills-stale.test.ts | 42 ++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/intent/src/core/lockfile/baseline-drift.ts b/packages/intent/src/core/lockfile/baseline-drift.ts index 0fdb295..5427a8d 100644 --- a/packages/intent/src/core/lockfile/baseline-drift.ts +++ b/packages/intent/src/core/lockfile/baseline-drift.ts @@ -129,6 +129,7 @@ export function computeBaselineDrift( if (!packageRoot) continue const realPackageRoot = realpathSync(packageRoot) + if (!isWithinDir(realPackageRoot, realRoot)) continue for (const skillPath of source.skills) { let resolvedSkillPath: string @@ -144,6 +145,7 @@ export function computeBaselineDrift( reason: err instanceof Error ? err.message : String(err), } } + let gitPath = resolvedSkillPath try { const realSkillPath = realpathSync(resolvedSkillPath) if (!isWithinDir(realSkillPath, realPackageRoot)) { @@ -152,6 +154,7 @@ export function computeBaselineDrift( reason: `source.skills path escapes the package root via a symlink: "${skillPath}".`, } } + gitPath = realSkillPath } catch (err) { if ( !(err instanceof Error) || @@ -163,7 +166,7 @@ export function computeBaselineDrift( } } } - const repoRelativePath = relative(realRoot, resolvedSkillPath) + const repoRelativePath = relative(realRoot, gitPath) if (fileFilter && !fileFilter.has(repoRelativePath)) continue diff --git a/packages/intent/tests/baseline-drift.test.ts b/packages/intent/tests/baseline-drift.test.ts index 2cd4979..dfa62d2 100644 --- a/packages/intent/tests/baseline-drift.test.ts +++ b/packages/intent/tests/baseline-drift.test.ts @@ -19,6 +19,7 @@ import type { } 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' }) @@ -59,6 +60,9 @@ beforeEach(() => { afterEach(() => { rmSync(repoDir, { recursive: true, force: true }) + for (const dir of externalDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } }) describe('resolveBaseline', () => { @@ -128,6 +132,54 @@ describe('resolveBaseline', () => { }) 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') diff --git a/packages/intent/tests/skills-stale.test.ts b/packages/intent/tests/skills-stale.test.ts index 60072c8..4d7a9a4 100644 --- a/packages/intent/tests/skills-stale.test.ts +++ b/packages/intent/tests/skills-stale.test.ts @@ -1,8 +1,9 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +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 { @@ -70,12 +71,16 @@ function source( 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 { @@ -131,6 +136,41 @@ describe('runSkillsStaleCommand', () => { 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') From b43215b78d9a82142078cc254be417c9de481c97 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 18:20:50 -0700 Subject: [PATCH 45/50] feat: implement frozen mode checks for unlisted skill-bearing sources and update related tests --- packages/intent/src/commands/skills/stale.ts | 8 ++- packages/intent/src/core/intent-core.ts | 72 ++++++++++++++++--- packages/intent/tests/core.test.ts | 58 +++++++++++++++ .../integration/skills-frozen-cli.test.ts | 19 ++++- packages/intent/tests/skills-stale.test.ts | 19 +++++ 5 files changed, 163 insertions(+), 13 deletions(-) diff --git a/packages/intent/src/commands/skills/stale.ts b/packages/intent/src/commands/skills/stale.ts index e0302b4..7aacbf9 100644 --- a/packages/intent/src/commands/skills/stale.ts +++ b/packages/intent/src/commands/skills/stale.ts @@ -92,7 +92,13 @@ export async function runSkillsStaleCommand( noFrozen: options.noFrozen, }) - const { scan } = await scanPolicedIntents() + 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') { diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index c794d5b..eaa8e6e 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -1,5 +1,8 @@ -import { isAbsolute, relative, resolve } from 'node:path' +import { isAbsolute, join, relative, resolve } from 'node:path' import { createIntentFsCache } from '../discovery/fs-cache.js' +import { diffLockfileSources } from './lockfile/lockfile-diff.js' +import { readIntentLockfile } from './lockfile/lockfile.js' +import { buildCurrentLockfileSources } from './lockfile/lockfile-state.js' import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' import { formatSkillUse, parseSkillUse } from '../skills/use.js' import { @@ -8,6 +11,7 @@ import { } from './excludes.js' import { rewriteLoadedSkillMarkdownDestinations } from './markdown.js' import { resolveSkillUseFastPath } from './load-resolution.js' +import { isFrozenMode } from '../shared/mode.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, @@ -18,7 +22,8 @@ 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, ScanScope } from '../shared/types.js' +import type { ScanResult } from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, @@ -260,6 +265,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, @@ -293,13 +340,10 @@ 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) { const lateRefusal = checkLoadAllowed(use, parsedUse, { config, @@ -327,7 +371,11 @@ function resolveIntentSkillInCwd( ) } - const { scan: scanResult, droppedNames } = scanForPolicedIntents({ + const { + scan: scanResult, + droppedNames, + hiddenSourceCount, + } = scanForPolicedIntents({ cwd, scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, @@ -349,6 +397,10 @@ function resolveIntentSkillInCwd( throw err } + if (frozen) { + assertFrozenLoadLockfile(cwd, scanResult, hiddenSourceCount) + } + return toResolvedIntentSkill( cwd, use, diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 825b4ec..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()) @@ -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/integration/skills-frozen-cli.test.ts b/packages/intent/tests/integration/skills-frozen-cli.test.ts index 7090204..71ce290 100644 --- a/packages/intent/tests/integration/skills-frozen-cli.test.ts +++ b/packages/intent/tests/integration/skills-frozen-cli.test.ts @@ -48,8 +48,8 @@ function createProject(skills: Array = ['foo']): string { return root } -function runCli(root: string, args: Array): number | null { - return spawnSync(process.execPath, [cliPath, 'skills', ...args], { +function runCommand(root: string, args: Array): number | null { + return spawnSync(process.execPath, [cliPath, ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, CI: '', INTENT_FROZEN: '' }, @@ -57,6 +57,10 @@ function runCli(root: string, args: Array): number | null { }).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) } @@ -114,4 +118,15 @@ describe('built CLI frozen mode', () => { 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/skills-stale.test.ts b/packages/intent/tests/skills-stale.test.ts index 4d7a9a4..dd9548b 100644 --- a/packages/intent/tests/skills-stale.test.ts +++ b/packages/intent/tests/skills-stale.test.ts @@ -122,6 +122,25 @@ describe('runSkillsStaleCommand', () => { }) }) + 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', '.']) From 6dfdbb9676ad2e707782025dc6a5b246bd14f15f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 18:44:18 -0700 Subject: [PATCH 46/50] feat: enhance intent skills and lockfile management - Updated `intent-skills.md` to include `manifestHash` and `capabilities` in the mechanical refresh description. - Improved `lockfile.md` to clarify the conditions under which `manifestHash` and `capabilities` are populated. - Refactored `intent-core.ts` to streamline imports and maintain consistency. - Enhanced `hash.ts` to enforce limits on entry counts and added validation for hash entries. - Updated `lockfile-state.ts` to assert manifest matches package details. - Improved `lockfile.ts` to reject undeclared fields in lockfile parsing. - Enhanced `manifest.ts` to validate capabilities and MCP tools against declared fields. - Added tests to ensure proper handling of unknown capabilities and undeclared fields in manifests. - Updated resolver logic to handle skill resolution more effectively, especially for same-name skills across different sources. --- docs/cli/intent-skills.md | 5 +- docs/security/lockfile.md | 4 +- packages/intent/src/core/intent-core.ts | 14 ++- packages/intent/src/core/lockfile/hash.ts | 23 +++- .../src/core/lockfile/lockfile-state.ts | 16 ++- packages/intent/src/core/lockfile/lockfile.ts | 59 ++++++++++ packages/intent/src/core/manifest.ts | 108 ++++++++++++++++-- packages/intent/src/core/source-policy.ts | 2 +- packages/intent/src/index.ts | 6 + packages/intent/src/shared/types.ts | 4 +- packages/intent/src/skills/resolver.ts | 38 ++++-- packages/intent/tests/hash.test.ts | 14 ++- .../integration/pnp-berry-corepack.test.ts | 2 +- .../integration/skills-frozen-cli.test.ts | 39 ++++++- packages/intent/tests/lockfile-state.test.ts | 58 +++++++--- packages/intent/tests/lockfile.test.ts | 72 ++++++++++++ packages/intent/tests/manifest.test.ts | 43 ++++++- .../tests/public-lockfile-types.test.ts | 28 +++++ packages/intent/tests/resolver.test.ts | 20 ++++ 19 files changed, 496 insertions(+), 59 deletions(-) diff --git a/docs/cli/intent-skills.md b/docs/cli/intent-skills.md index 76a2506..3366dda 100644 --- a/docs/cli/intent-skills.md +++ b/docs/cli/intent-skills.md @@ -62,7 +62,7 @@ Writes `intent.lock`. This is the trust decision — approving means a human rev npx @tanstack/intent@latest skills update [source] [--all] ``` -Writes `intent.lock`. This is the mechanical refresh, not a trust decision: it re-reads currently-installed sources and re-syncs the matching **already-locked** entries' `version`, `resolution`, `skills`, and `contentHash` to whatever's installed now. +Writes `intent.lock`. This is the mechanical refresh, not a trust decision: it re-reads currently-installed sources and re-syncs the matching **already-locked** entries' `version`, `resolution`, `skills`, `contentHash`, `manifestHash`, and `capabilities` to whatever's installed now. - 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.` @@ -91,10 +91,11 @@ Read-only. Surfaces staleness **candidates** for human/agent review — never a npx @tanstack/intent@latest skills generate-manifest [--json] ``` -Maintainer-only. Writes `skills/intent.manifest.json` inside each discovered package — never `intent.lock`, and unrelated to 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. +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 diff --git a/docs/security/lockfile.md b/docs/security/lockfile.md index 189be62..8eeea29 100644 --- a/docs/security/lockfile.md +++ b/docs/security/lockfile.md @@ -34,10 +34,10 @@ This is tamper-evidence, not semantic validation. Approving a source means **a h - **`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 validate. 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 and, if present (e.g. written by a newer Intent version), are preserved on read/write but not required. +- **`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, a duplicate `(kind, id)` entry, a non-canonical skill path, or any other structural problem is a **malformed lockfile** — fails closed, never silently treated as an empty lock. +- 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 diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index eaa8e6e..a513ee4 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -1,17 +1,17 @@ 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 { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' -import { formatSkillUse, parseSkillUse } from '../skills/use.js' import { compileExcludePatterns, getEffectiveExcludePatterns, } from './excludes.js' import { rewriteLoadedSkillMarkdownDestinations } from './markdown.js' import { resolveSkillUseFastPath } from './load-resolution.js' -import { isFrozenMode } from '../shared/mode.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, @@ -22,8 +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 { IntentPackage, ScanOptions, ScanScope } from '../shared/types.js' -import type { ScanResult } from '../shared/types.js' +import type { + IntentPackage, + ScanOptions, + ScanResult, + ScanScope, +} from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 1a30973..8b069c5 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,9 +1,9 @@ import { createHash } from 'node:crypto' import { dirname, isAbsolute, join, relative } from 'node:path' -import type { Dirent } from 'node:fs' 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 @@ -25,12 +25,14 @@ 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 = { @@ -78,6 +80,19 @@ function assertHashFileCount(fileCount: number): void { } } +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, @@ -256,6 +271,7 @@ function collectSupportFiles( const files: Array = [] for (const dirent of dirents) { + appendHashEntry(state) const absolutePath = join(dir, dirent.name) if (dirent.isDirectory()) { files.push( @@ -325,7 +341,7 @@ function collectSkillContentEntries( realPackageRoot: string, ): Array { const contentEntries = [...entries] - const state = { fileCount: contentEntries.length } + const state = { fileCount: contentEntries.length, entryCount: 0 } assertHashFileCount(state.fileCount) for (const entry of entries) { const skillDir = dirname(entry.absolutePath) @@ -420,9 +436,10 @@ function toPosixRelative(baseDir: string, absolutePath: string): string { export function computeSkillFolderHash( skillDir: string, packageRoot: string, + fs: ReadFs = nodeReadFs, ): string { return hashEntries( - readSkillFolderContents(skillDir, packageRoot).map((entry) => ({ + readSkillFolderContents(skillDir, packageRoot, fs).map((entry) => ({ key: entry.relativePath, value: entry.content, })), diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts index 4f59e69..80c52fe 100644 --- a/packages/intent/src/core/lockfile/lockfile-state.ts +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -1,11 +1,15 @@ import { join, relative, sep } from 'node:path' import { sourceIdentityKey } from '../types.js' -import { computeManifestHash, readIntentManifest } from '../manifest.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 { nodeReadFs } from '../../shared/utils.js' import type { ReadFs } from '../../shared/utils.js' function toPosixPath(path: string): string { @@ -47,6 +51,14 @@ function readManifestFields( 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)), diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index a8a10a2..bdb4b29 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -71,6 +71,20 @@ function assertRecord(value: unknown, label: string): Record { 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.`) @@ -133,6 +147,23 @@ function assertNoDuplicateSourceIdentities( 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( @@ -170,6 +201,16 @@ function parseSource(value: unknown): IntentLockfileSource { 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: { @@ -187,6 +228,7 @@ function parsePolicyIgnore(value: unknown): IntentLockfilePolicyIgnore { 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.') } @@ -197,6 +239,12 @@ 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.') } @@ -326,6 +374,17 @@ export function parseIntentLockfile(content: string): IntentLockfile { } 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)}`, diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts index 8998210..d23ff20 100644 --- a/packages/intent/src/core/manifest.ts +++ b/packages/intent/src/core/manifest.ts @@ -6,28 +6,43 @@ 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 { nodeReadFs } from '../shared/utils.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 - -interface IntentManifestSkill { +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']) + +export interface IntentManifestSkill { name: string path: string contentHash: string - capabilities: Array + capabilities: Array declaredSecrets: Array - mcpTools: Array + mcpTools: Array } -interface ManifestMcpTool { +export interface IntentManifestMcpTool { name: string description?: string inputSchema?: Record @@ -124,7 +139,7 @@ export function generateManifest( const heuristics = detectCapabilityHeuristics( skillContent.content.toString('utf8'), ) - const capabilities: Array = [] + 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') @@ -209,6 +224,21 @@ function assertStringArray(value: unknown, label: string): Array { 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 || @@ -230,7 +260,7 @@ function canonicalJsonValue(value: unknown, label: string): JsonValue { canonicalJsonValue(item, `${label}[${index}]`), ) } - if (typeof value === 'object' && value !== null) { + if (typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record) .sort(([a], [b]) => compareStrings(a, b)) @@ -248,13 +278,20 @@ function canonicalJsonValue(value: unknown, label: string): JsonValue { function canonicalMcpTools( value: unknown, label: string, -): Array { +): Array { if (!Array.isArray(value)) { throw new Error(`Invalid intent.manifest.json: ${label} must be an array.`) } - const tools = value.map((tool, index): ManifestMcpTool => { + 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 @@ -318,7 +355,7 @@ export function parseManifest(raw: unknown): IntentManifest { skillRecord.contentHash, `skills[${index}].contentHash`, ), - capabilities: assertStringArray( + capabilities: assertCapabilities( skillRecord.capabilities ?? [], `skills[${index}].capabilities`, ), @@ -341,6 +378,55 @@ export function parseManifest(raw: unknown): IntentManifest { } } +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, diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index b6e88f6..0329ffe 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', diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index f0e0844..448136e 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -58,3 +58,9 @@ export type { 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/types.ts b/packages/intent/src/shared/types.ts index b212e45..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) // --------------------------------------------------------------------------- @@ -13,8 +15,6 @@ export interface IntentConfig { // Scanner types // --------------------------------------------------------------------------- -import type { ReadFs } from './utils.js' - export interface ScanResult { packageManager: PackageManager packages: Array diff --git a/packages/intent/src/skills/resolver.ts b/packages/intent/src/skills/resolver.ts index 3bd53b6..49ea08f 100644 --- a/packages/intent/src/skills/resolver.ts +++ b/packages/intent/src/skills/resolver.ts @@ -152,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', @@ -165,14 +163,31 @@ export function resolveSkillUse( }) } - const identities = [ - ...new Set( - packages.map((candidate) => `${candidate.kind}:${candidate.name}`), - ), - ].toSorted() - if (identities.length > 1) { + const packagesByIdentity = Map.groupBy( + packages, + (candidate) => `${candidate.kind}:${candidate.name}`, + ) + 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: identities, + availablePackages: matches + .map((candidate) => candidate.identity) + .toSorted(), code: 'package-ambiguous', packageName, skillName, @@ -180,7 +195,8 @@ export function resolveSkillUse( }) } - const resolvedSkill = resolveSkillEntry(packageName, skillName, pkg.skills) + const selected = matches[0] ?? candidates[0]! + const { pkg, resolvedSkill } = selected const skill = resolvedSkill.skill if (!skill) { diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index 15adbff..f113db5 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -10,9 +10,9 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { + HASH_LIMITS, computeSkillFolderHash, computeSourceContentHash, - HASH_LIMITS, } from '../src/core/lockfile/hash.js' const roots: Array = [] @@ -343,6 +343,18 @@ describe('computeSourceContentHash', () => { 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') diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index 943e044..c1130fe 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -2,8 +2,8 @@ import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, - readdirSync, readFileSync, + readdirSync, realpathSync, rmSync, writeFileSync, diff --git a/packages/intent/tests/integration/skills-frozen-cli.test.ts b/packages/intent/tests/integration/skills-frozen-cli.test.ts index 71ce290..b6756f8 100644 --- a/packages/intent/tests/integration/skills-frozen-cli.test.ts +++ b/packages/intent/tests/integration/skills-frozen-cli.test.ts @@ -4,6 +4,10 @@ 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') @@ -18,7 +22,7 @@ function writeSkillPackage( root: string, name: string, content = 'Guidance.', -): void { +): string { const packageRoot = join(root, 'node_modules', ...name.split('/')) writeJson(join(packageRoot, 'package.json'), { name, @@ -30,6 +34,30 @@ function writeSkillPackage( 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 { @@ -119,6 +147,15 @@ describe('built CLI frozen mode', () => { 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) diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts index 76e6662..4503936 100644 --- a/packages/intent/tests/lockfile-state.test.ts +++ b/packages/intent/tests/lockfile-state.test.ts @@ -139,6 +139,45 @@ describe('buildCurrentLockfileSources', () => { ) }) + 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', @@ -178,21 +217,10 @@ describe('buildCurrentLockfileSources', () => { skills: [{ name: 'core', path: skillPath, description: 'desc' }], }) const manifestPath = join(root, 'skills', 'intent.manifest.json') - const baseManifest = { - manifestVersion: 1, - package: '@acme/pkg', - packageVersion: '1.0.0', - skills: [ - { - name: 'core', - path: 'skills/core/SKILL.md', - contentHash: 'sha256-core', - capabilities: [], - declaredSecrets: [], - mcpTools: [], - }, - ], - } + 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 diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts index 43715f2..57bf945 100644 --- a/packages/intent/tests/lockfile.test.ts +++ b/packages/intent/tests/lockfile.test.ts @@ -225,6 +225,78 @@ describe('parseIntentLockfile', () => { /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', () => { diff --git a/packages/intent/tests/manifest.test.ts b/packages/intent/tests/manifest.test.ts index 3f36411..eb907dc 100644 --- a/packages/intent/tests/manifest.test.ts +++ b/packages/intent/tests/manifest.test.ts @@ -205,6 +205,42 @@ describe('serializeManifest / parseManifest round-trip', () => { }), ).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', () => { @@ -255,10 +291,13 @@ describe('computeManifestHash', () => { if (!outcome.ok) return const before = computeManifestHash(outcome.manifest) - const mutated = { + const mutated: typeof outcome.manifest = { ...outcome.manifest, skills: [ - { ...outcome.manifest.skills[0]!, capabilities: ['uses_network'] }, + { + ...outcome.manifest.skills[0]!, + capabilities: ['uses_network'], + }, ], } expect(computeManifestHash(mutated)).not.toBe(before) diff --git a/packages/intent/tests/public-lockfile-types.test.ts b/packages/intent/tests/public-lockfile-types.test.ts index b93e86a..9b01acc 100644 --- a/packages/intent/tests/public-lockfile-types.test.ts +++ b/packages/intent/tests/public-lockfile-types.test.ts @@ -6,6 +6,10 @@ import type { IntentLockfileSource, IntentLockfileStaleness, IntentLockfileStalenessBaseline, + IntentManifest, + IntentManifestCapability, + IntentManifestMcpTool, + IntentManifestSkill, ReadIntentLockfileResult, SourceIdentity, } from '@tanstack/intent' @@ -49,4 +53,28 @@ describe('public lockfile types', () => { 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 a1c5824..926adf7 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -93,6 +93,26 @@ describe('resolveSkillUse', () => { ) }) + 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', From 8d43fd3921d172f4956aaa8cf4fdcb7af3afab2c Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 18:49:05 -0700 Subject: [PATCH 47/50] changeset --- .changeset/many-forks-cough.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/many-forks-cough.md 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`. From 3d59f209c1ca0a7f00ba147c81b9dc0482a23467 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 19:16:19 -0700 Subject: [PATCH 48/50] feat: enhance skills update command to require approval for trust-bearing changes; update CLI options and tests --- docs/cli/intent-skills.md | 6 +- packages/intent/src/cli.ts | 4 +- packages/intent/src/commands/skills/update.ts | 20 +++++ packages/intent/src/commands/support.ts | 10 ++- packages/intent/src/core/manifest.ts | 34 ++++++++ packages/intent/src/core/source-policy.ts | 4 +- .../integration/skills-frozen-cli.test.ts | 16 ++++ packages/intent/tests/manifest.test.ts | 33 ++++++++ packages/intent/tests/skills-update.test.ts | 78 +++++++++++++++++-- packages/intent/tests/source-policy.test.ts | 1 + 10 files changed, 190 insertions(+), 16 deletions(-) diff --git a/docs/cli/intent-skills.md b/docs/cli/intent-skills.md index 3366dda..f6c793f 100644 --- a/docs/cli/intent-skills.md +++ b/docs/cli/intent-skills.md @@ -59,10 +59,10 @@ Writes `intent.lock`. This is the trust decision — approving means a human rev ## `intent skills update [source]` ```bash -npx @tanstack/intent@latest skills update [source] [--all] +npx @tanstack/intent@latest skills update [source] [--all] [--yes] ``` -Writes `intent.lock`. This is the mechanical refresh, not a trust decision: it re-reads currently-installed sources and re-syncs the matching **already-locked** entries' `version`, `resolution`, `skills`, `contentHash`, `manifestHash`, and `capabilities` to whatever's installed now. +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.` @@ -101,7 +101,7 @@ Maintainer-only. Writes `skills/intent.manifest.json` inside each discovered pac - `--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 (alias for `--all`'s non-interactive behavior, for scripted first-run) +- `--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 diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 6589fe4..5c127b4 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -228,7 +228,7 @@ function createCli(): CAC { ) .option( '--yes', - 'With `approve`, accept all pending changes non-interactively (scripted first-run)', + 'With `approve`/`update`, accept trust-bearing changes non-interactively', ) .option( '--frozen', @@ -268,7 +268,7 @@ function createCli(): CAC { ) => { const { scanPolicedIntentsOrFail, frozenOptionsFromGlobalFlags } = await import('./commands/support.js') - const frozenOptions = frozenOptionsFromGlobalFlags(cli.rawArgs) + const frozenOptions = frozenOptionsFromGlobalFlags(options, cli.rawArgs) if (action === 'scan') { const { runSkillsScanCommand } = diff --git a/packages/intent/src/commands/skills/update.ts b/packages/intent/src/commands/skills/update.ts index 47c3cc8..d5a7597 100644 --- a/packages/intent/src/commands/skills/update.ts +++ b/packages/intent/src/commands/skills/update.ts @@ -16,10 +16,24 @@ 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}` } @@ -119,6 +133,12 @@ export async function runSkillsUpdateCommand( 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]), ) diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index d72a12c..f6aec40 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -155,12 +155,18 @@ export function noticeOptionsFromGlobalFlags(options: GlobalScanFlags): { // 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(argv: ReadonlyArray): { +export function frozenOptionsFromGlobalFlags( + options: { frozen?: boolean }, + argv: ReadonlyArray, +): { frozen: boolean noFrozen: boolean } { + const hasFrozenFlag = argv.some( + (arg) => arg === '--frozen' || arg.startsWith('--frozen='), + ) return { - frozen: argv.includes('--frozen'), + frozen: hasFrozenFlag && options.frozen === true, noFrozen: argv.includes('--no-frozen'), } } diff --git a/packages/intent/src/core/manifest.ts b/packages/intent/src/core/manifest.ts index d23ff20..30b17a3 100644 --- a/packages/intent/src/core/manifest.ts +++ b/packages/intent/src/core/manifest.ts @@ -32,6 +32,20 @@ const MANIFEST_CAPABILITIES = new Set([ '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 @@ -215,6 +229,20 @@ function assertString(value: unknown, label: string): 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( @@ -327,6 +355,7 @@ function canonicalMcpTools( 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.') } @@ -339,6 +368,11 @@ export function parseManifest(raw: unknown): IntentManifest { 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)) { diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 0329ffe..3f67966 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -172,7 +172,7 @@ export function applySourcePolicy( if (isPackageExcluded(pkg.name, excludeMatchers, pkg.kind)) continue if (!isSourcePermitted(config, pkg.name, pkg.kind)) { - if (config.mode === 'explicit') { + if (config.mode === 'explicit' || config.mode === 'empty') { hiddenSources.push({ name: pkg.name, skillCount: pkg.skills.length, @@ -191,7 +191,7 @@ export function applySourcePolicy( ) } - if (hiddenSources.length > 0) { + if (hiddenSources.length > 0 && config.mode === 'explicit') { emit(formatUnlistedNotice(hiddenSources, audience)) } diff --git a/packages/intent/tests/integration/skills-frozen-cli.test.ts b/packages/intent/tests/integration/skills-frozen-cli.test.ts index b6756f8..016cf19 100644 --- a/packages/intent/tests/integration/skills-frozen-cli.test.ts +++ b/packages/intent/tests/integration/skills-frozen-cli.test.ts @@ -104,6 +104,10 @@ describe('built CLI frozen mode', () => { 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') @@ -119,6 +123,18 @@ describe('built CLI frozen mode', () => { 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) diff --git a/packages/intent/tests/manifest.test.ts b/packages/intent/tests/manifest.test.ts index eb907dc..ea7b4dc 100644 --- a/packages/intent/tests/manifest.test.ts +++ b/packages/intent/tests/manifest.test.ts @@ -155,6 +155,39 @@ describe('generateManifest', () => { ) }) +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.') diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts index fadaa8f..a4f1235 100644 --- a/packages/intent/tests/skills-update.test.ts +++ b/packages/intent/tests/skills-update.test.ts @@ -157,7 +157,7 @@ describe('runSkillsUpdateCommand', () => { await runSkillsUpdateCommand( undefined, - {}, + { yes: true }, () => Promise.resolve( policedScan({ @@ -191,6 +191,70 @@ describe('runSkillsUpdateCommand', () => { 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 = { @@ -220,7 +284,7 @@ describe('runSkillsUpdateCommand', () => { await runSkillsUpdateCommand( 'npm:foo', - {}, + { yes: true }, () => Promise.resolve( policedScan({ @@ -265,7 +329,7 @@ describe('runSkillsUpdateCommand', () => { await runSkillsUpdateCommand( undefined, - { all: true }, + { all: true, yes: true }, () => Promise.resolve( policedScan({ @@ -302,7 +366,7 @@ describe('runSkillsUpdateCommand', () => { await runSkillsUpdateCommand( undefined, - { all: true }, + { all: true, yes: true }, () => Promise.resolve( policedScan({ @@ -437,7 +501,7 @@ describe('runSkillsUpdateCommand', () => { await runSkillsUpdateCommand( 'foo', - {}, + { yes: true }, () => Promise.resolve( policedScan({ @@ -524,7 +588,7 @@ describe('runSkillsUpdateCommand', () => { await runSkillsUpdateCommand( undefined, - {}, + { yes: true }, () => Promise.resolve( policedScan({ @@ -575,7 +639,7 @@ describe('runSkillsUpdateCommand', () => { await runSkillsUpdateCommand( undefined, - {}, + { yes: true }, () => Promise.resolve( policedScan({ diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 41449d1..ba7b9aa 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -227,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]) }) From 215e2dbd6e14b37e84a253cc8e49c53f0a2d90ba Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:34:32 +0000 Subject: [PATCH 49/50] ci: apply automated fixes --- packages/intent/tests/secrets.test.ts | 31 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/intent/tests/secrets.test.ts b/packages/intent/tests/secrets.test.ts index 09cb0da..da5b0ab 100644 --- a/packages/intent/tests/secrets.test.ts +++ b/packages/intent/tests/secrets.test.ts @@ -36,36 +36,41 @@ describe('findSecretMatches / containsSecretLiteral', () => { }) it('does not flag a secret NAME by itself (no value)', () => { - const content = 'This skill requires the `GITHUB_TOKEN` environment variable.' + 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( + expect( + detectCapabilityHeuristics('run `curl https://example.com`').usesNetwork, + ).toBe(true) + expect(detectCapabilityHeuristics('await fetch(url)').usesNetwork).toBe( true, ) - expect(detectCapabilityHeuristics('await fetch(url)').usesNetwork).toBe(true) - expect(detectCapabilityHeuristics('no network here').usesNetwork).toBe(false) + 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('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('child_process.exec(cmd)').shellsOut, + ).toBe(true) expect(detectCapabilityHeuristics('spawn("ls")').shellsOut).toBe(true) expect(detectCapabilityHeuristics('nothing here').shellsOut).toBe(false) }) From 625e5323f5e099d27d03b75dc2642fe26fe87a4c Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 9 Jul 2026 21:06:32 -0700 Subject: [PATCH 50/50] refactor: optimize package grouping in resolveSkillUse function and update test to stub global fetch --- packages/intent/src/skills/resolver.ts | 14 ++++++++++---- packages/intent/tests/skills-update.test.ts | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/intent/src/skills/resolver.ts b/packages/intent/src/skills/resolver.ts index 49ea08f..8f3ea65 100644 --- a/packages/intent/src/skills/resolver.ts +++ b/packages/intent/src/skills/resolver.ts @@ -163,10 +163,16 @@ export function resolveSkillUse( }) } - const packagesByIdentity = Map.groupBy( - packages, - (candidate) => `${candidate.kind}:${candidate.name}`, - ) + 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 = diff --git a/packages/intent/tests/skills-update.test.ts b/packages/intent/tests/skills-update.test.ts index a4f1235..8c318e2 100644 --- a/packages/intent/tests/skills-update.test.ts +++ b/packages/intent/tests/skills-update.test.ts @@ -72,6 +72,7 @@ describe('runSkillsUpdateCommand', () => { afterEach(() => { logSpy.mockClear() vi.unstubAllEnvs() + vi.unstubAllGlobals() for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } @@ -153,7 +154,7 @@ describe('runSkillsUpdateCommand', () => { }) const fetchSpy = vi.fn() - globalThis.fetch = fetchSpy + vi.stubGlobal('fetch', fetchSpy) await runSkillsUpdateCommand( undefined,