Skip to content

Commit d5d9181

Browse files
feat: read frontmatter scalars from metadata.* with top-level fallback (#162)
* read frontmatter scalars from metadata.* with top-level fallback * changeset * rm unneeded tests
1 parent 2c17c9f commit d5d9181

9 files changed

Lines changed: 209 additions & 9 deletions

File tree

.changeset/thin-candies-accept.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@tanstack/intent': patch
3+
---
4+
5+
Read skill frontmatter scalar fields (`type`, `framework`, `library_version`)
6+
from `metadata.*` with a fallback to the top-level key (#159). This is a
7+
back-compat safety net for the frontmatter migration: skills authored in the
8+
new `metadata`-nested shape resolve correctly while existing top-level skills
9+
keep working unchanged. The scanner, staleness checker, and the framework
10+
`requires` validation all honor both shapes.

packages/intent/src/commands/validate.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,8 @@ export async function runValidateCommand(
221221
}
222222

223223
async function runValidateCommandInternal(dir?: string): Promise<void> {
224-
const [{ parse: parseYaml }, { findSkillFiles }] = await Promise.all([
225-
import('yaml'),
226-
import('../utils.js'),
227-
])
224+
const [{ parse: parseYaml }, { findSkillFiles, readScalarField }] =
225+
await Promise.all([import('yaml'), import('../utils.js')])
228226
const context = resolveProjectContext({
229227
cwd: process.cwd(),
230228
targetPath: dir,
@@ -315,7 +313,10 @@ async function runValidateCommandInternal(dir?: string): Promise<void> {
315313
})
316314
}
317315

318-
if (fm.type === 'framework' && !Array.isArray(fm.requires)) {
316+
if (
317+
readScalarField(fm, 'type') === 'framework' &&
318+
!Array.isArray(fm.requires)
319+
) {
319320
errors.push({
320321
file: rel,
321322
message: 'Framework skills must have a "requires" field',

packages/intent/src/scanner.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
detectGlobalNodeModules,
1515
nodeReadFs,
1616
parseFrontmatter,
17+
readScalarField,
1718
toPosixPath,
1819
} from './utils.js'
1920
import { createIntentFsCache } from './fs-cache.js'
@@ -266,8 +267,8 @@ function readSkillEntry(
266267
name: typeof fm?.name === 'string' ? fm.name : relName,
267268
path: skillFile,
268269
description: desc,
269-
type: typeof fm?.type === 'string' ? fm.type : undefined,
270-
framework: typeof fm?.framework === 'string' ? fm.framework : undefined,
270+
type: readScalarField(fm, 'type'),
271+
framework: readScalarField(fm, 'framework'),
271272
}
272273
}
273274

packages/intent/src/staleness.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { existsSync, readFileSync } from 'node:fs'
22
import { isAbsolute, join, relative, resolve } from 'node:path'
33
import semver from 'semver'
44
import { readIntentArtifacts } from './artifact-coverage.js'
5-
import { findSkillFiles, parseFrontmatter, toPosixPath } from './utils.js'
5+
import {
6+
findSkillFiles,
7+
parseFrontmatter,
8+
readScalarField,
9+
toPosixPath,
10+
} from './utils.js'
611
import type {
712
IntentArtifactSet,
813
IntentArtifactSkill,
@@ -484,7 +489,7 @@ export async function checkStaleness(
484489
name: typeof fm?.name === 'string' ? fm.name : relName,
485490
relName,
486491
filePath,
487-
libraryVersion: fm?.library_version as string | undefined,
492+
libraryVersion: readScalarField(fm, 'library_version'),
488493
sources: Array.isArray(fm?.sources)
489494
? (fm.sources as Array<string>)
490495
: undefined,

packages/intent/src/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,23 @@ export function resolveDepDir(
346346
return null
347347
}
348348

349+
/**
350+
* Read a scalar string field from frontmatter, preferring `metadata.<key>` over
351+
* a top-level `<key>` (#159 back-compat for the frontmatter migration).
352+
*/
353+
export function readScalarField(
354+
fm: Record<string, unknown> | null | undefined,
355+
key: string,
356+
): string | undefined {
357+
const metadata = fm?.metadata
358+
if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) {
359+
const nested = (metadata as Record<string, unknown>)[key]
360+
if (typeof nested === 'string') return nested
361+
}
362+
const top = fm?.[key]
363+
return typeof top === 'string' ? top : undefined
364+
}
365+
349366
/**
350367
* Parse YAML frontmatter from a file. Returns null if no frontmatter or on error.
351368
*/

packages/intent/tests/cli.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,36 @@ describe('cli commands', () => {
17671767
)
17681768
})
17691769

1770+
it('enforces framework requires when type is under metadata (new shape)', async () => {
1771+
const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-fw-meta-'))
1772+
tempDirs.push(root)
1773+
1774+
const skillDir = join(root, 'skills', 'db-core')
1775+
mkdirSync(skillDir, { recursive: true })
1776+
writeFileSync(
1777+
join(skillDir, 'SKILL.md'),
1778+
[
1779+
'---',
1780+
'name: db-core',
1781+
'description: Core database concepts',
1782+
'metadata:',
1783+
' type: framework',
1784+
'---',
1785+
'',
1786+
'Skill content here.',
1787+
'',
1788+
].join('\n'),
1789+
)
1790+
1791+
process.chdir(root)
1792+
1793+
const exitCode = await main(['validate'])
1794+
const output = errorSpy.mock.calls.flat().join('\n')
1795+
1796+
expect(exitCode).toBe(1)
1797+
expect(output).toContain('Framework skills must have a "requires" field')
1798+
})
1799+
17701800
it('validates package skills from repo root without root packaging warnings', async () => {
17711801
const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-mono-'))
17721802
tempDirs.push(root)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { readScalarField } from '../src/utils.js'
3+
4+
describe('readScalarField', () => {
5+
it('reads a top-level scalar (old shape)', () => {
6+
expect(readScalarField({ type: 'core' }, 'type')).toBe('core')
7+
})
8+
9+
it('reads a scalar nested under metadata (new shape)', () => {
10+
expect(readScalarField({ metadata: { type: 'core' } }, 'type')).toBe('core')
11+
})
12+
13+
it('prefers metadata over a top-level value when both are present', () => {
14+
expect(
15+
readScalarField({ type: 'top', metadata: { type: 'nested' } }, 'type'),
16+
).toBe('nested')
17+
})
18+
19+
it('falls back to top-level when metadata exists but lacks the key (partial migration)', () => {
20+
expect(
21+
readScalarField(
22+
{ type: 'top', metadata: { framework: 'react' } },
23+
'type',
24+
),
25+
).toBe('top')
26+
})
27+
28+
it('falls back to top-level when the metadata value is not a string', () => {
29+
expect(
30+
readScalarField({ type: 'top', metadata: { type: 123 } }, 'type'),
31+
).toBe('top')
32+
})
33+
34+
it('ignores a metadata array and uses the top-level value', () => {
35+
expect(readScalarField({ type: 'top', metadata: ['type'] }, 'type')).toBe(
36+
'top',
37+
)
38+
})
39+
40+
it('ignores a metadata string and uses the top-level value', () => {
41+
expect(readScalarField({ type: 'top', metadata: 'nope' }, 'type')).toBe(
42+
'top',
43+
)
44+
})
45+
46+
it('returns undefined when the key is absent in both shapes', () => {
47+
expect(readScalarField({ name: 'x' }, 'type')).toBeUndefined()
48+
})
49+
50+
it('returns undefined when a non-string top-level value has no metadata fallback', () => {
51+
expect(readScalarField({ type: 123 }, 'type')).toBeUndefined()
52+
})
53+
54+
it('returns undefined for null frontmatter', () => {
55+
expect(readScalarField(null, 'type')).toBeUndefined()
56+
})
57+
58+
it('returns an empty-string metadata value as-is', () => {
59+
expect(readScalarField({ metadata: { type: '' } }, 'type')).toBe('')
60+
})
61+
})

packages/intent/tests/scanner.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1895,6 +1895,57 @@ describe('scanIntentPackageAtRoot', () => {
18951895
})
18961896
})
18971897

1898+
describe('back-compat frontmatter reader (metadata.* fallback)', () => {
1899+
function writeRawSkillMd(dir: string, frontmatter: string): void {
1900+
mkdirSync(dir, { recursive: true })
1901+
writeFileSync(
1902+
join(dir, 'SKILL.md'),
1903+
`---\n${frontmatter}\n---\n\nSkill content here.\n`,
1904+
)
1905+
}
1906+
1907+
function installPackageWithRawSkill(frontmatter: string): void {
1908+
const pkgDir = createDir(root, 'node_modules', '@tanstack', 'db')
1909+
writeJson(join(pkgDir, 'package.json'), {
1910+
name: '@tanstack/db',
1911+
version: '0.5.2',
1912+
intent: { version: 1, repo: 'TanStack/db', docs: 'docs/' },
1913+
})
1914+
writeRawSkillMd(join(pkgDir, 'skills', 'db-core'), frontmatter)
1915+
}
1916+
1917+
it('resolves type and framework from metadata (new shape)', () => {
1918+
installPackageWithRawSkill(
1919+
[
1920+
'name: db-core',
1921+
'description: Core database concepts',
1922+
'metadata:',
1923+
' type: core',
1924+
' framework: react',
1925+
].join('\n'),
1926+
)
1927+
1928+
const skill = scanForIntents(root).packages[0]!.skills[0]!
1929+
expect(skill.type).toBe('core')
1930+
expect(skill.framework).toBe('react')
1931+
})
1932+
1933+
it('prefers metadata over top-level during partial migration', () => {
1934+
installPackageWithRawSkill(
1935+
[
1936+
'name: db-core',
1937+
'description: Core database concepts',
1938+
'type: legacy',
1939+
'metadata:',
1940+
' type: core',
1941+
].join('\n'),
1942+
)
1943+
1944+
const skill = scanForIntents(root).packages[0]!.skills[0]!
1945+
expect(skill.type).toBe('core')
1946+
})
1947+
})
1948+
18981949
describe('package manager detection', () => {
18991950
it('detects npm from package-lock.json', () => {
19001951
writeFileSync(join(root, 'package-lock.json'), '{}')

packages/intent/tests/staleness.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,30 @@ describe('checkStaleness', () => {
180180
expect(report.versionDrift).toBe('patch')
181181
})
182182

183+
it('reads library_version from metadata (new shape)', async () => {
184+
const skillDir = join(tmpDir, 'skills', 'core')
185+
mkdirSync(skillDir, { recursive: true })
186+
writeFileSync(
187+
join(skillDir, 'SKILL.md'),
188+
[
189+
'---',
190+
'name: core',
191+
'description: Core',
192+
'metadata:',
193+
' library_version: 1.2.3',
194+
'---',
195+
'# Skill',
196+
'',
197+
].join('\n'),
198+
)
199+
200+
mockFetchVersion('2.0.0')
201+
202+
const report = await checkStaleness(tmpDir, '@example/lib')
203+
expect(report.skillVersion).toBe('1.2.3')
204+
expect(report.versionDrift).toBe('major')
205+
})
206+
183207
it.each([
184208
['1.0.0', '2.0.0', 'major'],
185209
['1.0.0', '1.1.0', 'minor'],

0 commit comments

Comments
 (0)