diff --git a/.changeset/validate-links-inside-package.md b/.changeset/validate-links-inside-package.md new file mode 100644 index 0000000..d593056 --- /dev/null +++ b/.changeset/validate-links-inside-package.md @@ -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. diff --git a/docs/cli/intent-validate.md b/docs/cli/intent-validate.md index 3dd435f..80e2288 100644 --- a/docs/cli/intent-validate.md +++ b/docs/cli/intent-validate.md @@ -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: ` 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 diff --git a/packages/intent/src/validate/blocks.ts b/packages/intent/src/validate/blocks.ts index e2f36ef..5d06a5f 100644 --- a/packages/intent/src/validate/blocks.ts +++ b/packages/intent/src/validate/blocks.ts @@ -478,17 +478,22 @@ export function skillLinks( function checkSkillLinks( root: string, + packageDir: string, file: string, content: string, ): Array { const findings: Array = [] 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', }) } @@ -647,7 +652,7 @@ export async function checkSkillBlocks( ): Promise { 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), diff --git a/packages/intent/tests/validate-blocks.test.ts b/packages/intent/tests/validate-blocks.test.ts index 7460708..b80d879 100644 --- a/packages/intent/tests/validate-blocks.test.ts +++ b/packages/intent/tests/validate-blocks.test.ts @@ -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(