| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- #!/usr/bin/env bun
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
- import { dirname, join, normalize } from 'node:path'
- import { fileURLToPath } from 'node:url'
- const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
- const SKIP_RULES_FILE = join(REPO_ROOT, 'scripts', 'validation', 'markdown-link-skip-patterns.txt')
- const TARGET_DIRS = [
- '.opencode/agent',
- '.opencode/skills',
- '.opencode/command',
- '.opencode/context'
- ]
- type MissingRef = {
- source: string
- line: number
- target: string
- resolved: string
- kind: 'link' | 'path'
- }
- function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
- }
- function parseRegexRule(rule: string): RegExp {
- const trimmed = rule.trim()
- if (trimmed.startsWith('/') && trimmed.length > 2) {
- const lastSlash = trimmed.lastIndexOf('/')
- if (lastSlash > 0) {
- const pattern = trimmed.slice(1, lastSlash)
- const flags = trimmed.slice(lastSlash + 1)
- return new RegExp(pattern, flags)
- }
- }
- return new RegExp(trimmed)
- }
- function loadSkipPatterns(): RegExp[] {
- if (!existsSync(SKIP_RULES_FILE)) {
- throw new Error(`Skip rules file not found: ${SKIP_RULES_FILE}`)
- }
- const lines = readFileSync(SKIP_RULES_FILE, 'utf-8').split('\n')
- const patterns: RegExp[] = []
- for (const line of lines) {
- const rule = line.trim()
- if (!rule || rule.startsWith('#')) continue
- // Literal rule: literal:foo/bar
- if (rule.startsWith('literal:')) {
- const literal = rule.slice('literal:'.length).trim()
- if (!literal) continue
- patterns.push(new RegExp(escapeRegExp(literal)))
- continue
- }
- patterns.push(parseRegexRule(rule))
- }
- return patterns
- }
- function shouldSkipFile(filePath: string, skipPatterns: RegExp[]): boolean {
- const relPath = filePath.replace(`${REPO_ROOT}/`, '')
- return skipPatterns.some((pattern) => pattern.test(relPath))
- }
- function walkMdFiles(dir: string, out: string[], skipPatterns: RegExp[]): void {
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
- if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'build') continue
- const full = join(dir, entry.name)
- if (entry.isDirectory()) {
- walkMdFiles(full, out, skipPatterns)
- continue
- }
- if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
- if (!shouldSkipFile(full, skipPatterns)) {
- out.push(full)
- }
- }
- }
- }
- function lineFromIndex(content: string, index: number): number {
- return content.slice(0, index).split('\n').length
- }
- function shouldSkip(rawTarget: string, skipPatterns: RegExp[]): boolean {
- const value = rawTarget.trim()
- if (!value) return true
- if (value.startsWith('http://') || value.startsWith('https://')) return true
- if (value.startsWith('mailto:') || value.startsWith('#')) return true
- return skipPatterns.some((pattern) => pattern.test(value))
- }
- function cleanupTarget(rawTarget: string): string {
- let target = rawTarget.trim()
- if (!target) return target
- if (target.startsWith('<') && target.endsWith('>')) {
- target = target.slice(1, -1)
- }
- const firstSpace = target.indexOf(' ')
- if (firstSpace >= 0) {
- target = target.slice(0, firstSpace)
- }
- target = target.replace(/^@/, '')
- target = target.split('#')[0]
- target = target.split('?')[0]
- return target
- }
- function resolveTarget(sourceFile: string, rawTarget: string): string {
- const target = cleanupTarget(rawTarget)
- if (target.startsWith('.opencode/')) return join(REPO_ROOT, target)
- if (target.startsWith('/')) return join(REPO_ROOT, target.slice(1))
- return join(dirname(sourceFile), target)
- }
- function collectMissingRefs(file: string, skipPatterns: RegExp[]): MissingRef[] {
- const content = readFileSync(file, 'utf-8')
- const rel = file.replace(`${REPO_ROOT}/`, '')
- const missing: MissingRef[] = []
- const linkRegex = /\[[^\]]*\]\(([^)]+)\)/g
- for (const match of content.matchAll(linkRegex)) {
- const rawTarget = match[1]
- if (!rawTarget) continue
- if (shouldSkip(rawTarget, skipPatterns)) continue
- const cleaned = cleanupTarget(rawTarget)
- if (!cleaned) continue
- if (!cleaned.includes('.md') && !cleaned.startsWith('.opencode/')) continue
- const resolved = normalize(resolveTarget(file, cleaned))
- if (!existsSync(resolved)) {
- missing.push({
- source: rel,
- line: lineFromIndex(content, match.index ?? 0),
- target: rawTarget,
- resolved: resolved.replace(`${REPO_ROOT}/`, ''),
- kind: 'link',
- })
- }
- }
- const pathRegex = /@?(\.opencode\/[A-Za-z0-9_./-]+\.md)/g
- for (const match of content.matchAll(pathRegex)) {
- const rawTarget = match[1]
- if (!rawTarget) continue
- if (shouldSkip(rawTarget, skipPatterns)) continue
- const cleaned = cleanupTarget(rawTarget)
- const resolved = normalize(resolveTarget(file, cleaned))
- if (!existsSync(resolved)) {
- missing.push({
- source: rel,
- line: lineFromIndex(content, match.index ?? 0),
- target: rawTarget,
- resolved: resolved.replace(`${REPO_ROOT}/`, ''),
- kind: 'path',
- })
- }
- }
- return missing
- }
- function dedupe(refs: MissingRef[]): MissingRef[] {
- const seen = new Set<string>()
- const out: MissingRef[] = []
- for (const ref of refs) {
- const key = `${ref.source}:${ref.line}:${ref.target}:${ref.kind}`
- if (seen.has(key)) continue
- seen.add(key)
- out.push(ref)
- }
- return out
- }
- const skipPatterns = loadSkipPatterns()
- const files: string[] = []
- for (const dir of TARGET_DIRS) {
- const absolute = join(REPO_ROOT, dir)
- if (existsSync(absolute)) walkMdFiles(absolute, files, skipPatterns)
- }
- let missing: MissingRef[] = []
- for (const file of files) {
- missing = missing.concat(collectMissingRefs(file, skipPatterns))
- }
- missing = dedupe(missing)
- if (missing.length === 0) {
- console.log(`OK: validated ${files.length} markdown files, no broken internal references found.`)
- process.exit(0)
- }
- console.log(`ERROR: found ${missing.length} broken internal markdown references:`)
- for (const ref of missing) {
- console.log(`- ${ref.source}:${ref.line} [${ref.kind}] ${ref.target} -> ${ref.resolved}`)
- }
- process.exit(1)
|