diff --git a/.changeset/thin-candies-accept.md b/.changeset/thin-candies-accept.md new file mode 100644 index 0000000..839c91a --- /dev/null +++ b/.changeset/thin-candies-accept.md @@ -0,0 +1,10 @@ +--- +'@tanstack/intent': patch +--- + +Read skill frontmatter scalar fields (`type`, `framework`, `library_version`) +from `metadata.*` with a fallback to the top-level key (#159). This is a +back-compat safety net for the frontmatter migration: skills authored in the +new `metadata`-nested shape resolve correctly while existing top-level skills +keep working unchanged. The scanner, staleness checker, and the framework +`requires` validation all honor both shapes. diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 47f9efc..abda04b 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -221,10 +221,8 @@ export async function runValidateCommand( } async function runValidateCommandInternal(dir?: string): Promise { - const [{ parse: parseYaml }, { findSkillFiles }] = await Promise.all([ - import('yaml'), - import('../utils.js'), - ]) + const [{ parse: parseYaml }, { findSkillFiles, readScalarField }] = + await Promise.all([import('yaml'), import('../utils.js')]) const context = resolveProjectContext({ cwd: process.cwd(), targetPath: dir, @@ -315,7 +313,10 @@ async function runValidateCommandInternal(dir?: string): Promise { }) } - if (fm.type === 'framework' && !Array.isArray(fm.requires)) { + if ( + readScalarField(fm, 'type') === 'framework' && + !Array.isArray(fm.requires) + ) { errors.push({ file: rel, message: 'Framework skills must have a "requires" field', diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index d679e2b..a706393 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -14,6 +14,7 @@ import { detectGlobalNodeModules, nodeReadFs, parseFrontmatter, + readScalarField, toPosixPath, } from './utils.js' import { createIntentFsCache } from './fs-cache.js' @@ -266,8 +267,8 @@ function readSkillEntry( name: typeof fm?.name === 'string' ? fm.name : relName, path: skillFile, description: desc, - type: typeof fm?.type === 'string' ? fm.type : undefined, - framework: typeof fm?.framework === 'string' ? fm.framework : undefined, + type: readScalarField(fm, 'type'), + framework: readScalarField(fm, 'framework'), } } diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 178711c..2a0e1a7 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -2,7 +2,12 @@ import { existsSync, readFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import semver from 'semver' import { readIntentArtifacts } from './artifact-coverage.js' -import { findSkillFiles, parseFrontmatter, toPosixPath } from './utils.js' +import { + findSkillFiles, + parseFrontmatter, + readScalarField, + toPosixPath, +} from './utils.js' import type { IntentArtifactSet, IntentArtifactSkill, @@ -484,7 +489,7 @@ export async function checkStaleness( name: typeof fm?.name === 'string' ? fm.name : relName, relName, filePath, - libraryVersion: fm?.library_version as string | undefined, + libraryVersion: readScalarField(fm, 'library_version'), sources: Array.isArray(fm?.sources) ? (fm.sources as Array) : undefined, diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index 70cbcf2..2bc8d30 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -346,6 +346,23 @@ export function resolveDepDir( return null } +/** + * Read a scalar string field from frontmatter, preferring `metadata.` over + * a top-level `` (#159 back-compat for the frontmatter migration). + */ +export function readScalarField( + fm: Record | null | undefined, + key: string, +): string | undefined { + const metadata = fm?.metadata + if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) { + const nested = (metadata as Record)[key] + if (typeof nested === 'string') return nested + } + const top = fm?.[key] + return typeof top === 'string' ? top : undefined +} + /** * Parse YAML frontmatter from a file. Returns null if no frontmatter or on error. */ diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index c7385a1..58ff47e 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1767,6 +1767,36 @@ describe('cli commands', () => { ) }) + it('enforces framework requires when type is under metadata (new shape)', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-fw-meta-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata:', + ' type: framework', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('Framework skills must have a "requires" field') + }) + it('validates package skills from repo root without root packaging warnings', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-mono-')) tempDirs.push(root) diff --git a/packages/intent/tests/read-scalar-field.test.ts b/packages/intent/tests/read-scalar-field.test.ts new file mode 100644 index 0000000..c4b0215 --- /dev/null +++ b/packages/intent/tests/read-scalar-field.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { readScalarField } from '../src/utils.js' + +describe('readScalarField', () => { + it('reads a top-level scalar (old shape)', () => { + expect(readScalarField({ type: 'core' }, 'type')).toBe('core') + }) + + it('reads a scalar nested under metadata (new shape)', () => { + expect(readScalarField({ metadata: { type: 'core' } }, 'type')).toBe('core') + }) + + it('prefers metadata over a top-level value when both are present', () => { + expect( + readScalarField({ type: 'top', metadata: { type: 'nested' } }, 'type'), + ).toBe('nested') + }) + + it('falls back to top-level when metadata exists but lacks the key (partial migration)', () => { + expect( + readScalarField( + { type: 'top', metadata: { framework: 'react' } }, + 'type', + ), + ).toBe('top') + }) + + it('falls back to top-level when the metadata value is not a string', () => { + expect( + readScalarField({ type: 'top', metadata: { type: 123 } }, 'type'), + ).toBe('top') + }) + + it('ignores a metadata array and uses the top-level value', () => { + expect(readScalarField({ type: 'top', metadata: ['type'] }, 'type')).toBe( + 'top', + ) + }) + + it('ignores a metadata string and uses the top-level value', () => { + expect(readScalarField({ type: 'top', metadata: 'nope' }, 'type')).toBe( + 'top', + ) + }) + + it('returns undefined when the key is absent in both shapes', () => { + expect(readScalarField({ name: 'x' }, 'type')).toBeUndefined() + }) + + it('returns undefined when a non-string top-level value has no metadata fallback', () => { + expect(readScalarField({ type: 123 }, 'type')).toBeUndefined() + }) + + it('returns undefined for null frontmatter', () => { + expect(readScalarField(null, 'type')).toBeUndefined() + }) + + it('returns an empty-string metadata value as-is', () => { + expect(readScalarField({ metadata: { type: '' } }, 'type')).toBe('') + }) +}) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 711141d..8be85f4 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -1895,6 +1895,57 @@ describe('scanIntentPackageAtRoot', () => { }) }) +describe('back-compat frontmatter reader (metadata.* fallback)', () => { + function writeRawSkillMd(dir: string, frontmatter: string): void { + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'SKILL.md'), + `---\n${frontmatter}\n---\n\nSkill content here.\n`, + ) + } + + function installPackageWithRawSkill(frontmatter: string): void { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'db') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/db', + version: '0.5.2', + intent: { version: 1, repo: 'TanStack/db', docs: 'docs/' }, + }) + writeRawSkillMd(join(pkgDir, 'skills', 'db-core'), frontmatter) + } + + it('resolves type and framework from metadata (new shape)', () => { + installPackageWithRawSkill( + [ + 'name: db-core', + 'description: Core database concepts', + 'metadata:', + ' type: core', + ' framework: react', + ].join('\n'), + ) + + const skill = scanForIntents(root).packages[0]!.skills[0]! + expect(skill.type).toBe('core') + expect(skill.framework).toBe('react') + }) + + it('prefers metadata over top-level during partial migration', () => { + installPackageWithRawSkill( + [ + 'name: db-core', + 'description: Core database concepts', + 'type: legacy', + 'metadata:', + ' type: core', + ].join('\n'), + ) + + const skill = scanForIntents(root).packages[0]!.skills[0]! + expect(skill.type).toBe('core') + }) +}) + describe('package manager detection', () => { it('detects npm from package-lock.json', () => { writeFileSync(join(root, 'package-lock.json'), '{}') diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index f3a7af8..d25fbdc 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -180,6 +180,30 @@ describe('checkStaleness', () => { expect(report.versionDrift).toBe('patch') }) + it('reads library_version from metadata (new shape)', async () => { + const skillDir = join(tmpDir, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: core', + 'description: Core', + 'metadata:', + ' library_version: 1.2.3', + '---', + '# Skill', + '', + ].join('\n'), + ) + + mockFetchVersion('2.0.0') + + const report = await checkStaleness(tmpDir, '@example/lib') + expect(report.skillVersion).toBe('1.2.3') + expect(report.versionDrift).toBe('major') + }) + it.each([ ['1.0.0', '2.0.0', 'major'], ['1.0.0', '1.1.0', 'minor'],