Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/thin-candies-accept.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 6 additions & 5 deletions packages/intent/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,8 @@ export async function runValidateCommand(
}

async function runValidateCommandInternal(dir?: string): Promise<void> {
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,
Expand Down Expand Up @@ -315,7 +313,10 @@ async function runValidateCommandInternal(dir?: string): Promise<void> {
})
}

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',
Expand Down
5 changes: 3 additions & 2 deletions packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
detectGlobalNodeModules,
nodeReadFs,
parseFrontmatter,
readScalarField,
toPosixPath,
} from './utils.js'
import { createIntentFsCache } from './fs-cache.js'
Expand Down Expand Up @@ -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'),
}
}

Expand Down
9 changes: 7 additions & 2 deletions packages/intent/src/staleness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>)
: undefined,
Expand Down
17 changes: 17 additions & 0 deletions packages/intent/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,23 @@ export function resolveDepDir(
return null
}

/**
* Read a scalar string field from frontmatter, preferring `metadata.<key>` over
* a top-level `<key>` (#159 back-compat for the frontmatter migration).
*/
export function readScalarField(
fm: Record<string, unknown> | null | undefined,
key: string,
): string | undefined {
const metadata = fm?.metadata
if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) {
const nested = (metadata as Record<string, unknown>)[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.
*/
Expand Down
30 changes: 30 additions & 0 deletions packages/intent/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions packages/intent/tests/read-scalar-field.test.ts
Original file line number Diff line number Diff line change
@@ -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('')
})
})
51 changes: 51 additions & 0 deletions packages/intent/tests/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'), '{}')
Expand Down
24 changes: 24 additions & 0 deletions packages/intent/tests/staleness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading