import path from 'node:path'; import type { CompanySkillAuditFinding, CompanySkillVersionFileInventoryEntry, SkillPackageInspection, SkillPackageReference } from '@paperclipai/shared'; import { unprocessable } from '../errors.js'; import { skillFileBytes } from './skill-snapshot.js'; /** Inspect explicit Markdown links and inline-code resource paths, not project filenames, prose, or shell commands. */ function referencesIn(markdown: string) { const text = markdown.replace(/^\s*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\s*\1\s*$/gm, ''); const references: { target: string; rootRelative: boolean }[] = []; const prose = text.replace(/(`+)[\s\S]*?\1/g, ''); // Link/image destinations and reference definitions; optional titles are not part of the path. for (const match of prose.matchAll(/(?:!?\[[^\]\n]*\]\(|^\s*\[[^\]\n]+\]:\s*)(?:<([^>\n]+)>|([^\s)]+))/gm)) { references.push({ target: match[1] ?? match[2]!, rootRelative: false }); } for (const match of text.matchAll(/(?): ReadonlySet { const result = new Set(); const add = (value: string) => { result.add(value); if (result.size > 100_000) throw unprocessable('Repository exceeds the 100,000 path scan limit.'); }; for (const file of paths) { add(file); let parent = path.posix.dirname(file); while (parent !== '.' && parent !== '/' && !result.has(parent)) { add(parent); parent = path.posix.dirname(parent); } } if (result.size) add('.'); return result; } export function inspectSkillPackage( skillPath: string, files: CompanySkillVersionFileInventoryEntry[], repositoryPaths: ReadonlySet, frontmatter: Record, findings: CompanySkillAuditFinding[], includedPaths?: ReadonlySet, ): SkillPackageInspection { const root = path.posix.dirname(skillPath); const inPackage = includedPaths ?? indexSkillPackagePaths(files.map(file => file.path === 'SKILL.md' ? skillPath : path.posix.join(root, file.path))); const references = new Map(); const contains = (paths: ReadonlySet, target: string) => paths.has(target); for (const file of files) { if (file.encoding === 'base64' || !/\.md$/i.test(file.path)) continue; for (const { target, rootRelative } of referencesIn(file.content)) { if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)) continue; let decoded: string; try { decoded = decodeURIComponent(target.split(/[?#]/)[0]!); } catch { continue; } if (!decoded) continue; let resolvedPath = path.posix.normalize(path.posix.join(root, rootRelative ? '' : path.posix.dirname(file.path), decoded)).replace(/\/+$/, ''); if (!decoded.startsWith('.') && !repositoryPaths.has(resolvedPath) && repositoryPaths.has(decoded)) resolvedPath = decoded; if (!decoded.startsWith('/') && contains(inPackage, resolvedPath)) continue; const outsideRoot = decoded.startsWith('/') || resolvedPath === '..' || resolvedPath.startsWith('../') || (root !== '.' && !resolvedPath.startsWith(`${root}/`) && resolvedPath !== root); const kind = outsideRoot || contains(repositoryPaths, resolvedPath) ? 'outside_package' : 'missing'; references.set(`${file.path}:${resolvedPath}`, { fromPath: file.path, target, resolvedPath, kind }); } } return { files: files.map(file => ({ path: file.path, kind: file.kind, encoding: file.encoding ?? 'utf8', executable: file.executable ?? false, sizeBytes: skillFileBytes(file).length })), requirements: typeof frontmatter.compatibility === 'string' ? frontmatter.compatibility.trim() || null : null, references: [...references.values()], // Structured reference findings replace the audit's unstructured, less precise link warnings. warnings: [...new Set(findings.filter(finding => finding.severity === 'warning' && finding.code !== 'broken_internal_link').map(finding => finding.message))], }; }