import fs from 'node:fs' import path from 'node:path' import { execFileSync } from 'node:child_process' const root = process.cwd() // A portable checkout must not accidentally use a parent directory's Git repository. let collectionMode try { fs.lstatSync(path.join(root, '.git')) collectionMode = 'git' } catch (error) { if (error.code === 'ENOENT') collectionMode = 'filesystem' else { console.error(JSON.stringify({ error: 'Git metadata detection failed', detail: error.message })) process.exit(2) } } function ignoreRules(directory) { const ignoreFile = path.join(directory, '.gitignore') let source try { source = fs.readFileSync(ignoreFile, 'utf8') } catch (error) { if (error.code === 'ENOENT') return []; throw error } return source.split(/\r?\n/).flatMap(line => { if (!line || line.startsWith('#')) return [] const negate = line.startsWith('!') let pattern = (negate ? line.slice(1) : line).trimEnd() const directoryOnly = pattern.endsWith('/') if (directoryOnly) pattern = pattern.slice(0, -1) const anchored = pattern.startsWith('/') || pattern.includes('/') pattern = pattern.replace(/^\//, '') // Repository ignore rules use literal paths, *, **, ? and negation. let expression = '' for (let i = 0; i < pattern.length; i++) { const char = pattern[i] if (char === '*' && pattern[i + 1] === '*') { i++ if (pattern[i + 1] === '/') { expression += '(?:.*/)?'; i++ } else expression += '.*' } else if (char === '*') expression += '[^/]*' else if (char === '?') expression += '[^/]' else expression += char.replace(/[\\^$+?.()|{}\[\]]/g, '\\$&') } return [{ directory, negate, directoryOnly, regex: new RegExp(`${anchored ? '^' : '(?:^|/)'}${expression}$`) }] }) } function filesystemMarkdown(directory = root, inherited = []) { const rules = [...inherited, ...ignoreRules(directory)] return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { if (entry.name === '.git') return [] const absolute = path.join(directory, entry.name) let ignored = false for (const rule of rules) { const relative = path.relative(rule.directory, absolute).replaceAll('\\', '/') if ((!rule.directoryOnly || entry.isDirectory()) && rule.regex.test(relative)) ignored = !rule.negate } if (ignored) return [] if (entry.isDirectory()) return filesystemMarkdown(absolute, rules) return entry.isFile() && entry.name.endsWith('.md') ? [path.relative(root, absolute).replaceAll('\\', '/')] : [] }) } let files try { files = collectionMode === 'git' ? [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).split('\0'))].filter(file => file.endsWith('.md')) : filesystemMarkdown().sort() } catch (error) { console.error(JSON.stringify({ collectionMode, error: 'Markdown collection failed', detail: error.message })) process.exit(2) } const stripCode = text => text.replace(/^```[^\n]*\n[\s\S]*?^```[^\n]*$/gm, '') const slug = text => text.toLowerCase().replace(/`/g, '').replace(/[^\p{Letter}\p{Number}\p{Mark}_\- ]/gu, '').replace(/ /g, '-') const anchors = file => { const source = stripCode(fs.readFileSync(file, 'utf8')) const used = new Map(), result = new Set() for (const [, heading] of source.matchAll(/^#{1,6}\s+(.+)$/gm)) { const name = slug(heading.trim()), count = used.get(name) ?? 0 result.add(count ? `${name}-${count}` : name); used.set(name, count + 1) } for (const [, name] of source.matchAll(/\b(?:id|name)=["']([^"']+)["']/g)) result.add(name) return result } const problems = []; let internalLinks = 0 for (const file of files) for (const [, raw] of stripCode(fs.readFileSync(file, 'utf8')).matchAll(/!?\[[^\]\n]*\]\(([^)\n]+)\)/g)) { const href = raw.trim().replace(/^<|>$/g, '') if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)) continue internalLinks++ try { const [target, fragment] = href.split('#') const resolved = target ? path.resolve(path.dirname(file), decodeURIComponent(target)) : path.resolve(file) const relative = path.relative(root, resolved).replaceAll('\\', '/') let reason = relative.startsWith('_local/') || relative === '_local' ? 'local evidence is excluded from Git Clone' : !fs.existsSync(resolved) ? 'missing path' : null if (!reason && fragment && resolved.endsWith('.md') && !anchors(resolved).has(decodeURIComponent(fragment))) reason = 'missing heading' if (reason) problems.push({ file, href, reason }) } catch (error) { problems.push({ file, href, reason: error.message }) } } console.log(JSON.stringify({ collectionMode, files: files.length, internalLinks, problems }, null, 2)) if (problems.length) process.exitCode = 1