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
5 changes: 5 additions & 0 deletions .changeset/validate-links-inside-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

`validate` reports an error when a relative Markdown link in a `SKILL.md` resolves to a path outside the package that owns the skill, even when the target exists in the repository. Only the package is installed in a consumer project, so such a link is dead for every consumer.
2 changes: 1 addition & 1 deletion docs/cli/intent-validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Module augmentations and global declarations still share the package compiler co

TypeScript 5.0 or newer must be available in the repository for code checking. If it or the library type entry is unavailable, Intent reports why those checks were skipped; this is not a successful typecheck. Prose-only skills do not load TypeScript. With TypeScript 7.0, Intent checks examples through the compiler API that TypeScript 7 publishes as unstable; Node.js 24 or newer is supported. When `@typescript/typescript6` is installed beside TypeScript 7, Intent uses that package instead. If neither API can run, Intent reports that the checks were skipped. TypeScript 7.1 is not supported at this time.

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.
Relative Markdown links outside fenced examples must point to an existing file or directory. The target must also be inside the package that owns the skill. Only the package is installed in a consumer project, so a link to a file elsewhere in the repository fails with `Link target is outside the package: <target>` even when the file exists. The boundary is the package root directory; the `files` list in `package.json` is not consulted. External URLs and anchors are not checked. Link checks still run when TypeScript is unavailable. Repeated validations read current source files and link targets.

### Reference files

Expand Down
11 changes: 8 additions & 3 deletions packages/intent/src/validate/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,17 +478,22 @@ export function skillLinks(

function checkSkillLinks(
root: string,
packageDir: string,
file: string,
content: string,
): Array<SkillBlockFinding> {
const findings: Array<SkillBlockFinding> = []
const absolute = resolve(root, file)
for (const { target, path, line } of skillLinks(content)) {
if (!existsSync(resolve(dirname(absolute), path)))
const resolved = resolve(dirname(absolute), path)
const outside = relative(packageDir, resolved).startsWith('..')
if (outside || !existsSync(resolved))
findings.push({
file,
line,
message: `Link target not found: ${target}`,
message: outside
? `Link target is outside the package: ${target}`
: `Link target not found: ${target}`,
severity: 'error',
})
}
Expand Down Expand Up @@ -647,7 +652,7 @@ export async function checkSkillBlocks(
): Promise<SkillBlockCheck> {
const { root, packageDir, library, cache = {} } = options
const findings = options.skills.flatMap((skill) =>
checkSkillLinks(root, skill.file, skill.content),
checkSkillLinks(root, packageDir, skill.file, skill.content),
)
const blocks = [...options.skills, ...(options.references ?? [])].flatMap(
(skill) => extractCodeBlocks(skill.file, skill.content),
Expand Down
52 changes: 52 additions & 0 deletions packages/intent/tests/validate-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,58 @@ it('checks prose links without loading TypeScript', async () => {
expect(existsSync(join(root, 'typescript-loaded'))).toBe(false)
})

it('rejects links that leave the package, even when the target exists', async () => {
write('docs/guide.md', '# Guide\n')
write('packages/client/README.md', '# Client\n')
write(
'packages/client/skills/run/SKILL.md',
[
'---',
'name: run',
'description: Use when running.',
'---',
'See the [guide](../../../../docs/guide.md#setup).',
'See the [readme](../../README.md).',
'See [missing](references/missing.md).',
'See the [missing guide](../../../../docs/missing.md).',
'',
].join('\n'),
)
expect(
(
await checkSkillBlocks({
root,
packageDir: join(root, 'packages/client'),
library: '@acme/client',
skills: [
{
file: 'packages/client/skills/run/SKILL.md',
content: read('packages/client/skills/run/SKILL.md'),
},
],
})
).findings,
).toEqual([
expect.objectContaining({
line: 5,
severity: 'error',
message:
'Link target is outside the package: ../../../../docs/guide.md#setup',
}),
expect.objectContaining({
line: 7,
severity: 'error',
message: 'Link target not found: references/missing.md',
}),
expect.objectContaining({
line: 8,
severity: 'error',
message:
'Link target is outside the package: ../../../../docs/missing.md',
}),
])
})

it('skips typechecking with a reason when TypeScript 7 has no compiler API Intent can use', async () => {
fakeTypeScript7()
skill(
Expand Down
Loading