Skip to content

Commit db658f7

Browse files
feat: validate skill reference files (#318)
* feat: validate skill reference files Check every Markdown file under a skill's references/ directory for frontmatter, a direct link from SKILL.md, and code examples that typecheck. Require a skill tree entry to list its skill's reference files, and check the list against the files. * perf: scan skill links only for skills with reference files
1 parent 9d8a463 commit db658f7

10 files changed

Lines changed: 495 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/intent': patch
3+
---
4+
5+
`intent validate` now checks every Markdown file under a skill's `references/` directory: the file must not begin with frontmatter, the `SKILL.md` body must link to it directly, and its TypeScript and JavaScript examples are checked with the skill's examples. A `skill_tree.yaml` entry for a skill with reference files must list them in a `references` key, and the list must match the files. Existing repositories now fail validation for reference files that `SKILL.md` does not link to directly, reference frontmatter, reference examples that do not typecheck, and tree entries that do not list their skill's reference files.

‎docs/cli/intent-validate.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,39 @@ TypeScript 5.0 or newer must be available in the repository for code checking. I
110110

111111
Relative Markdown links outside fenced examples must point to an existing file or directory. External URLs and anchors are not checked. Link checks still run when TypeScript is unavailable. Repeated validations read current source files and link targets.
112112

113+
### Reference files
114+
115+
Every `.md` file under a skill's `references/` directory, at any depth, is checked with its skill. No planning artifacts are required.
116+
117+
- The file is plain Markdown: it does not begin with YAML frontmatter
118+
- The body of the owning `SKILL.md` links to the file with a direct relative Markdown link outside fenced examples, such as `[retry behavior](references/retries.md)`. A path in backticks or a link from another reference file does not count, because an agent finds a reference only through that link. A reference file can also link to another reference file
119+
- TypeScript and JavaScript fences are checked with the skill's fences, under the rules in [Code examples and links](#code-examples-and-links). Errors name the reference file and line. Relative links inside a reference file are not checked
120+
121+
A `skill_tree.yaml` entry lists the skill's reference files in a `references` key, as paths relative to the skill directory:
122+
123+
```yaml
124+
skills:
125+
- name: React Table State
126+
slug: table-state
127+
package: packages/react-table
128+
path: skills/table-state/SKILL.md
129+
references:
130+
- references/reactivity.md
131+
```
132+
133+
Intent reads the tree from `<dir>/_artifacts` and, in a monorepo, from `_artifacts` at the workspace root. It matches an entry to a skill by the resolved `path`, so two packages can use the same slug. When a skill has a tree entry, the entry must agree with the files:
134+
135+
- An entry for a skill that has reference files has a `references` key. The error shows the YAML lines to add
136+
- `references`, when present, is a list of strings
137+
138+
- Each path has the form `references/<name>.md`, stays inside the skill directory (no absolute path and no `..` segment), and is listed once
139+
- Each listed file exists
140+
- Each `.md` file under `references/` is listed
141+
142+
A skill without reference files needs no `references` key. A skill without a tree entry, or a repository without planning artifacts, gets only the file checks above.
143+
144+
Upgrade impact: a repository that already has reference files fails validation when a reference file is not linked directly from its `SKILL.md`, begins with frontmatter, or contains a code example that does not typecheck, or when a matching `skill_tree.yaml` entry does not list the skill's reference files.
145+
113146
### Artifacts
114147

115148
When `<dir>/_artifacts` exists, Intent also checks:

‎packages/intent/meta/generate-skill/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Work in the maintainer's library repository with their existing coding agent. Pr
1919

2020
Use the current conversation, supplied diff, identified code/docs change, review report, or selected skill tree entry. Reuse the task, decisions, and evidence already established in this session; do not ask the maintainer to paste them again. Identify what developers need help doing and which package owns that task. If there is no usable task or review input, ask one question: “What do developers need help doing with this library?” Wait for the answer.
2121

22-
Check repository instructions and Git status before edits; preserve unrelated changes. Read [maintainer commands](references/maintainer-commands.md) when setting up the repository, adding a skill, or preparing a batch for review. Use those commands for registration, metadata synchronization, and completion checks. Discover the package name, version, repository, skill root, and vocabulary from the repository. Use an established custom root; otherwise use `skills/` inside the owning package, including in monorepos.
22+
Check repository instructions and Git status before edits; preserve unrelated changes. Read [maintainer commands](references/maintainer-commands.md) when setting up the repository, adding a skill, or preparing a batch for review. When installing or updating CI, read [workflow security](references/workflow-security.md) before changing pins, triggers, or permissions. Use those commands for registration, metadata synchronization, and completion checks. Discover the package name, version, repository, skill root, and vocabulary from the repository. Use an established custom root; otherwise use `skills/` inside the owning package, including in monorepos.
2323

2424
Before choosing or changing repository exports, follow [repository distribution](references/distribution.md). For an export-only change, complete that procedure against existing skill records; use the authoring steps below only if guidance must change.
2525

‎packages/intent/meta/generate-skill/references/planning-records.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ skills:
3535
purpose: '[descriptive explanation; synchronized from metadata.purpose]'
3636
requires: ['[other skill slugs]'] # omit if none
3737
sources: ['src/[path].ts', 'owner/repo:docs/[path].md']
38+
references: ['references/[file].md'] # required when the skill has reference files; relative to the skill directory
3839
```
3940
4041
- `domain_map.yaml` owns the domain/task relationships, supported failure modes, cross-references, tensions, and knowledge gaps. Keep task slugs and package ownership aligned with the skills.
4142
- `skill_spec.md` is the human-readable coverage and decision record. Retain the existing inventories and add a **Coverage and batch history** section recording the assessed scope, each completed batch or behavior change, its source revision/version, consequential decisions and reasons, check outcomes, and remaining work. Keep entries concise; do not store transcripts. Distinguish implemented guidance from planned work and unassessed areas.
4243
- `skill_tree.yaml` owns skill identities, paths, prerequisites, source mappings, and package placement. Keep existing planned entries and their status or explanatory notes. Add each new skill, and update entries when their names, paths, dependencies, or sources change. Resolve `path` relative to the owning package when `package` is present; otherwise resolve it relative to the library root. Keep `generated_from` links accurate for the actual artifact location.
44+
- `references` lists the `.md` files under the skill's `references/` directory. `intent validate` requires the key when the skill has reference files, and requires the list to match those files: each path has the form `references/<name>.md`, is listed once, and exists, and no file is left out. Omit the key, or use an empty list, for a skill without reference files. `intent validate` also checks every reference file as described in [the skill format](skill-format.md).
4345

4446
Keep each tree entry's `description` aligned with its skill's activation description and its `purpose` aligned with `metadata.purpose`. Existing map/spec task explanations retain their meaning; do not replace them with trigger text. Record a purpose migration and its preserved source text in the batch review without discarding prior decisions.
4547

‎packages/intent/meta/generate-skill/references/skill-format.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Example pointer (create the reference only for a real retry branch):
6161
When configuring retries, read [retry behavior](references/retries.md) before choosing a policy.
6262
```
6363

64+
`intent validate` checks every `.md` file under `references/`. Write each reference as plain Markdown without frontmatter. Link to it from the `SKILL.md` body with a direct relative Markdown link; a link from another reference file is not sufficient. Its TypeScript and JavaScript fences are checked with the skill's fences.
65+
6466
For a verification task (security, go-live, migration audit), use checks instead of setup/patterns: state what to inspect, the expected result, the failure condition, and the remediation. Include sourced mistakes and a final completion check. Use the applicable checklist template in [tree-generator's writing reference](../../tree-generator/references/write-skills.md#step-7--write-checklistaudit-skills-where-applicable) only when that detailed format is needed.
6567

6668
For an entry selected from a full-library tree, retain its package placement, dependencies, failure-mode status, and cross-skill relationships. Read the applicable type template in [the tree writing reference](../../tree-generator/references/write-skills.md) when generating overview registries, framework trees, compositions, or cross-domain tension notes. The focused procedure remains in [generate-skill](../SKILL.md).

‎packages/intent/src/commands/validate.ts‎

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1-
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
1+
import {
2+
appendFileSync,
3+
existsSync,
4+
readFileSync,
5+
readdirSync,
6+
statSync,
7+
} from 'node:fs'
28
import { basename, dirname, join, relative, resolve } from 'node:path'
39
import { fail, isCliFailure } from '../shared/cli-error.js'
410
import { resolveProjectContext } from '../core/project-context.js'
511
import { findWorkspacePackages } from '../setup/workspace-patterns.js'
612
import { createIntentFsCache } from '../discovery/fs-cache.js'
7-
import { checkSkillBlocks, summarizeSkillExamples } from '../validate/blocks.js'
13+
import {
14+
checkSkillBlocks,
15+
skillLinks,
16+
summarizeSkillExamples,
17+
} from '../validate/blocks.js'
18+
import { readIntentArtifacts } from '../staleness/artifact-coverage.js'
819
import { writeChanges } from '../maintainer/files.js'
920
import { repositoryWritePath } from '../shared/write-path.js'
1021
import {
@@ -15,6 +26,7 @@ import { printWarnings } from './support.js'
1526
import type { SkillBlockCache } from '../validate/blocks.js'
1627
import type { FileChange } from '../maintainer/files.js'
1728
import type { ProjectContext } from '../core/project-context.js'
29+
import type { IntentArtifactSkill } from '../shared/types.js'
1830

1931
interface ValidationError {
2032
file: string
@@ -184,6 +196,47 @@ function collectPackagingWarnings(
184196
return warnings
185197
}
186198

199+
function findReferenceFiles(skillDir: string): Array<string> {
200+
const referencesDir = join(skillDir, 'references')
201+
if (!statSync(referencesDir, { throwIfNoEntry: false })?.isDirectory())
202+
return []
203+
return readdirSync(referencesDir, { recursive: true, withFileTypes: true })
204+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
205+
.map((entry) =>
206+
relative(skillDir, join(entry.parentPath, entry.name)).replaceAll(
207+
'\\',
208+
'/',
209+
),
210+
)
211+
.sort()
212+
}
213+
214+
function readTreeEntries(
215+
context: ProjectContext,
216+
skillsDir: string,
217+
artifacts: Map<string, ReturnType<typeof readIntentArtifacts>>,
218+
): Map<string, IntentArtifactSkill> {
219+
const entries = new Map<string, IntentArtifactSkill>()
220+
for (const [artifactRoot, root] of [
221+
[skillsDir, context.packageRoot ?? context.cwd],
222+
[context.workspaceRoot, context.workspaceRoot],
223+
]) {
224+
if (!artifactRoot || !root) continue
225+
if (!artifacts.has(artifactRoot))
226+
artifacts.set(artifactRoot, readIntentArtifacts(artifactRoot))
227+
for (const entry of artifacts.get(artifactRoot)?.skills ?? []) {
228+
if (entry.artifactKind !== 'skill-tree' || !entry.path) continue
229+
entries.set(
230+
entry.package && !entry.path.startsWith(`${entry.package}/`)
231+
? resolve(root, entry.package, entry.path)
232+
: resolve(root, entry.path),
233+
entry,
234+
)
235+
}
236+
}
237+
return entries
238+
}
239+
187240
function formatWarning({ file, message }: ValidationWarning): string {
188241
return `${file}: ${message}`
189242
}
@@ -404,6 +457,7 @@ async function runValidateCommandInternal(
404457
const setVersionPlans: Array<SetVersionPlan> = []
405458
let validatedCount = 0
406459
const validatedFiles = new Set<string>()
460+
const artifacts = new Map<string, ReturnType<typeof readIntentArtifacts>>()
407461

408462
if (skillsDirs.length === 0) {
409463
console.log('No skills/ directory found — skipping validation.')
@@ -425,7 +479,9 @@ async function runValidateCommandInternal(
425479
file: string
426480
content: string
427481
library: string | undefined
482+
references: Array<{ file: string; content: string }>
428483
}> = []
484+
const treeEntries = readTreeEntries(validateContext, skillsDir, artifacts)
429485
for (const filePath of skillFiles) {
430486
const rel = relative(process.cwd(), filePath)
431487
const content = readFileSync(filePath, 'utf8')
@@ -538,10 +594,101 @@ async function runValidateCommandInternal(
538594
...collectAgentSkillSpecWarnings({ fm, rel }).map(formatWarning),
539595
)
540596

597+
const skillDir = dirname(filePath)
598+
const skillName = basename(skillDir)
599+
const referenceFiles = findReferenceFiles(skillDir)
600+
const linked = new Set(
601+
referenceFiles.length
602+
? skillLinks(match[2]!).map((link) => resolve(skillDir, link.path))
603+
: [],
604+
)
605+
const references = referenceFiles.map((reference) => {
606+
const referencePath = join(skillDir, reference)
607+
const file = relative(process.cwd(), referencePath)
608+
const referenceContent = readFileSync(referencePath, 'utf8')
609+
if (
610+
/^---\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n|$)/.test(
611+
referenceContent,
612+
)
613+
)
614+
errors.push({
615+
file,
616+
message:
617+
'Reference files must be plain Markdown without YAML frontmatter',
618+
})
619+
if (!linked.has(referencePath))
620+
errors.push({
621+
file,
622+
message: `Not linked from SKILL.md — add a direct relative Markdown link such as [${basename(reference, '.md')}](${reference}) so an agent can find it`,
623+
})
624+
return { file, content: referenceContent }
625+
})
626+
627+
const declared = treeEntries.get(filePath)?.references
628+
if (declared === undefined) {
629+
if (treeEntries.has(filePath) && referenceFiles.length)
630+
errors.push({
631+
file: rel,
632+
message: `The skill_tree.yaml entry for skill "${skillName}" must list its reference files. Add to the entry:\n${[
633+
'references:',
634+
...referenceFiles.map((reference) => ` - ${reference}`),
635+
]
636+
.map((line) => ` ${line}`)
637+
.join('\n')}`,
638+
})
639+
} else if (
640+
!Array.isArray(declared) ||
641+
declared.some((reference) => typeof reference !== 'string')
642+
) {
643+
errors.push({
644+
file: rel,
645+
message: `The "references" value of the skill_tree.yaml entry for skill "${skillName}" must be a list of reference file paths`,
646+
})
647+
} else {
648+
const seen = new Set<string>()
649+
for (const reference of declared as Array<string>) {
650+
if (seen.has(reference)) {
651+
errors.push({
652+
file: rel,
653+
message: `Reference "${reference}" is declared more than once in skill_tree.yaml`,
654+
})
655+
continue
656+
}
657+
seen.add(reference)
658+
if (
659+
!/^references\/.+\.md$/.test(reference) ||
660+
reference.includes('\\') ||
661+
reference
662+
.split('/')
663+
.some((part) => part === '' || part === '.' || part === '..')
664+
) {
665+
errors.push({
666+
file: rel,
667+
message: `Declared reference "${reference}" must be a references/<name>.md path inside the skill directory`,
668+
})
669+
} else if (!existsSync(join(skillDir, reference))) {
670+
errors.push({
671+
file: relative(process.cwd(), join(skillDir, reference)),
672+
message:
673+
'Declared in skill_tree.yaml but the file does not exist',
674+
})
675+
}
676+
}
677+
for (const reference of referenceFiles) {
678+
if (!seen.has(reference))
679+
errors.push({
680+
file: relative(process.cwd(), join(skillDir, reference)),
681+
message:
682+
'Not declared in the "references" list of its skill_tree.yaml entry',
683+
})
684+
}
685+
}
686+
541687
checkedSkills.push({
542688
file: rel,
543689
content,
544690
library: readScalarField(fm, 'library'),
691+
references,
545692
})
546693

547694
const lineCount = content.split(/\r?\n/).length
@@ -576,6 +723,7 @@ async function runValidateCommandInternal(
576723
packageDir: validateContext.packageRoot,
577724
library,
578725
skills,
726+
references: skills.flatMap((skill) => skill.references),
579727
cache: blockCache,
580728
})
581729
if (exampleSummaries)

‎packages/intent/src/shared/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export interface IntentArtifactSkill {
136136
packages: Array<string>
137137
sources: Array<string>
138138
covers: Array<string>
139+
references?: unknown
139140
}
140141

141142
export interface IntentArtifactCoverageIgnore {

‎packages/intent/src/staleness/artifact-coverage.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function parseSkills(
9797
packages: [...new Set(packages)].sort((a, b) => a.localeCompare(b)),
9898
sources: stringArray(skill.sources),
9999
covers: stringArray(skill.covers),
100+
...('references' in skill ? { references: skill.references } : {}),
100101
})
101102
}
102103

‎packages/intent/src/validate/blocks.ts‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -448,13 +448,10 @@ async function splitExamples(
448448
return { content: lines.join(''), suggestions }
449449
}
450450

451-
function checkSkillLinks(
452-
root: string,
453-
file: string,
451+
export function skillLinks(
454452
content: string,
455-
): Array<SkillBlockFinding> {
456-
const findings: Array<SkillBlockFinding> = []
457-
const absolute = resolve(root, file)
453+
): Array<{ target: string; path: string; line: number }> {
454+
const links: Array<{ target: string; path: string; line: number }> = []
458455
// Blank out fenced examples, keeping newlines so line numbers still match.
459456
const lines = content.split(/\r?\n/)
460457
for (const fence of codeFences(content))
@@ -470,10 +467,27 @@ function checkSkillLinks(
470467
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#')) continue
471468
const path = target.replace(/[#?].*$/, '')
472469
if (!path) continue
470+
links.push({
471+
target,
472+
path,
473+
line: prose.slice(0, match.index).split('\n').length,
474+
})
475+
}
476+
return links
477+
}
478+
479+
function checkSkillLinks(
480+
root: string,
481+
file: string,
482+
content: string,
483+
): Array<SkillBlockFinding> {
484+
const findings: Array<SkillBlockFinding> = []
485+
const absolute = resolve(root, file)
486+
for (const { target, path, line } of skillLinks(content)) {
473487
if (!existsSync(resolve(dirname(absolute), path)))
474488
findings.push({
475489
file,
476-
line: prose.slice(0, match.index).split('\n').length,
490+
line,
477491
message: `Link target not found: ${target}`,
478492
severity: 'error',
479493
})
@@ -626,6 +640,7 @@ export async function checkSkillBlocks(
626640
packageDir: string
627641
library: string
628642
skills: Array<{ file: string; content: string }>
643+
references?: Array<{ file: string; content: string }>
629644
cache?: SkillBlockCache
630645
},
631646
ts?: typeof TS | null,
@@ -634,8 +649,8 @@ export async function checkSkillBlocks(
634649
const findings = options.skills.flatMap((skill) =>
635650
checkSkillLinks(root, skill.file, skill.content),
636651
)
637-
const blocks = options.skills.flatMap((skill) =>
638-
extractCodeBlocks(skill.file, skill.content),
652+
const blocks = [...options.skills, ...(options.references ?? [])].flatMap(
653+
(skill) => extractCodeBlocks(skill.file, skill.content),
639654
)
640655
const result = (skipped?: string): SkillBlockCheck => ({
641656
blocks: blocks.length,

0 commit comments

Comments
 (0)