diff --git a/src/agent/index-middleware.ts b/src/agent/index-middleware.ts index 586a42ca..1f25aee5 100644 --- a/src/agent/index-middleware.ts +++ b/src/agent/index-middleware.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { parse } from "yaml"; import { addFrontmatterWarning } from "./frontmatter-validator.js"; import type { OpenWikiOutputMode } from "./types.js"; +import { + formatWikiLinkIssues, + validateWikiInternalLinks, +} from "./wiki-link-validator.js"; const INDEX_FILE = "index.md"; const LOG_FILE = "log.md"; @@ -40,6 +44,10 @@ export function createOpenWikiIndexMiddleware( ), afterAgent: async () => { await synchronizeWikiIndexes(backend, outputMode); + const linkIssues = await validateWikiInternalLinks(backend, outputMode); + if (linkIssues.length > 0) { + throw new Error(formatWikiLinkIssues(linkIssues)); + } }, }); } diff --git a/src/agent/wiki-link-validator.ts b/src/agent/wiki-link-validator.ts new file mode 100644 index 00000000..5ca68fd2 --- /dev/null +++ b/src/agent/wiki-link-validator.ts @@ -0,0 +1,275 @@ +import type { BackendProtocolV2 } from "deepagents"; +import path from "node:path"; +import type { OpenWikiOutputMode } from "./types.js"; + +export interface WikiLinkIssue { + href: string; + line: number; + message: string; + sourcePath: string; +} + +const MARKDOWN_LINK_PATTERN = /\[([^\]]*)\]\(([^)]+)\)/gu; +const HEADING_PATTERN = /^(#{1,6})\s+(.+?)\s*#*\s*$/u; + +/** Validates relative wiki links and GitHub-style heading anchors after generation. */ +export async function validateWikiInternalLinks( + backend: BackendProtocolV2, + outputMode: OpenWikiOutputMode, +): Promise { + const wikiRoot = outputMode === "local-wiki" ? "/" : "/openwiki"; + const markdownFiles = await collectMarkdownFiles(backend, wikiRoot); + const issues: WikiLinkIssue[] = []; + + for (const sourcePath of markdownFiles) { + const content = await readText(backend, sourcePath); + const headingAnchors = buildHeadingAnchors(extractHeadings(content)); + + for (const { href, line } of extractMarkdownLinks(content)) { + const issue = await validateLink( + backend, + wikiRoot, + sourcePath, + href, + line, + headingAnchors, + ); + if (issue) { + issues.push(issue); + } + } + } + + return issues; +} + +/** Formats link issues into a single actionable error message. */ +export function formatWikiLinkIssues(issues: WikiLinkIssue[]): string { + const lines = issues.map( + (issue) => + `${issue.sourcePath}:${issue.line} [${issue.href}] ${issue.message}`, + ); + return `OpenWiki internal link validation failed:\n${lines.join("\n")}`; +} + +async function validateLink( + backend: BackendProtocolV2, + wikiRoot: string, + sourcePath: string, + rawHref: string, + line: number, + sourceAnchors: Set, +): Promise { + const href = rawHref.trim(); + if (!href || isExternalHref(href)) { + return null; + } + + const { anchor, path: linkPath } = parseLinkDestination(href); + if (!linkPath) { + if (!anchor) { + return null; + } + if (!sourceAnchors.has(decodeURIComponent(anchor))) { + return { + href, + line, + message: `heading anchor "${anchor}" does not exist in ${sourcePath}`, + sourcePath, + }; + } + return null; + } + + const resolvedPath = resolveWikiLinkPath(wikiRoot, sourcePath, linkPath); + const isDirectory = resolvedPath.endsWith("/"); + const targetPath = isDirectory + ? resolvedPath.replace(/\/+$/u, "") + : resolvedPath; + + if (!(await pathExists(backend, targetPath, isDirectory))) { + return { + href, + line, + message: isDirectory + ? `directory "${linkPath}" does not exist` + : `file "${linkPath}" does not exist`, + sourcePath, + }; + } + + if (!anchor || isDirectory) { + return null; + } + + const targetContent = await readText(backend, targetPath); + const targetAnchors = buildHeadingAnchors(extractHeadings(targetContent)); + if (!targetAnchors.has(decodeURIComponent(anchor))) { + return { + href, + line, + message: `heading anchor "${anchor}" does not exist in ${targetPath}`, + sourcePath, + }; + } + + return null; +} + +async function collectMarkdownFiles( + backend: BackendProtocolV2, + directoryPath: string, +): Promise { + const result = await backend.ls(directoryPath); + if (result.error) { + return []; + } + + const files: string[] = []; + for (const entry of result.files ?? []) { + const name = path.posix.basename(entry.path.replace(/\/$/u, "")); + if (!name || name.startsWith(".")) { + continue; + } + + const entryPath = path.posix.join(directoryPath, name); + if (entry.is_dir) { + files.push(...(await collectMarkdownFiles(backend, entryPath))); + continue; + } + + if (path.posix.extname(name).toLowerCase() === ".md") { + files.push(entryPath); + } + } + + return files; +} + +function extractMarkdownLinks( + content: string, +): Array<{ href: string; line: number }> { + const links: Array<{ href: string; line: number }> = []; + const lines = content.split(/\r?\n/u); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) { + if (match.index !== undefined && line[match.index - 1] === "!") { + continue; + } + links.push({ href: match[2], line: index + 1 }); + } + } + + return links; +} + +function extractHeadings(content: string): string[] { + const headings: string[] = []; + for (const line of content.split(/\r?\n/u)) { + const match = HEADING_PATTERN.exec(line); + if (match) { + headings.push(match[2]); + } + } + return headings; +} + +function buildHeadingAnchors(headings: string[]): Set { + const counts = new Map(); + const anchors = new Set(); + + for (const heading of headings) { + const base = slugifyHeading(heading); + if (!base) { + continue; + } + + const count = counts.get(base) ?? 0; + counts.set(base, count + 1); + anchors.add(count === 0 ? base : `${base}-${count}`); + } + + return anchors; +} + +function slugifyHeading(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\w\s-]/gu, "") + .replace(/\s+/gu, "-"); +} + +function parseLinkDestination(rawHref: string): { + anchor?: string; + path: string; +} { + const withoutTitle = rawHref.replace(/\s+(["']).*\1\s*$/u, "").trim(); + const hashIndex = withoutTitle.indexOf("#"); + if (hashIndex === -1) { + return { path: withoutTitle }; + } + + return { + anchor: withoutTitle.slice(hashIndex + 1), + path: withoutTitle.slice(0, hashIndex), + }; +} + +function resolveWikiLinkPath( + wikiRoot: string, + sourcePath: string, + linkPath: string, +): string { + if (linkPath.startsWith("/")) { + return path.posix.join(wikiRoot, linkPath.slice(1)); + } + + return path.posix.normalize( + path.posix.join(path.posix.dirname(sourcePath), linkPath), + ); +} + +function isExternalHref(href: string): boolean { + return /^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(href); +} + +async function pathExists( + backend: BackendProtocolV2, + targetPath: string, + isDirectory: boolean, +): Promise { + try { + if (isDirectory) { + const result = await backend.ls(targetPath); + return !result.error; + } + + const result = await backend.readRaw(targetPath); + return !result.error; + } catch { + return false; + } +} + +async function readText( + backend: BackendProtocolV2, + filePath: string, +): Promise { + const result = await backend.readRaw(filePath); + if (result.error) { + throw new Error(`Unable to read ${filePath}: ${result.error}`); + } + + const content = result.data?.content; + if (Array.isArray(content)) { + return content.join("\n"); + } + if (typeof content === "string") { + return content; + } + + throw new Error(`${filePath} is not a text file.`); +} diff --git a/test/wiki-link-validator-dogfood.test.ts b/test/wiki-link-validator-dogfood.test.ts new file mode 100644 index 00000000..fafd3e26 --- /dev/null +++ b/test/wiki-link-validator-dogfood.test.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; +import { validateWikiInternalLinks } from "../src/agent/wiki-link-validator.ts"; + +describe("validateWikiInternalLinks dogfood", () => { + test("accepts the repository's checked-in openwiki tree", async () => { + const repoRoot = path.resolve(import.meta.dirname, ".."); + const backend = new OpenWikiLocalShellBackend({ + docsOnly: true, + outputMode: "repository", + rootDir: repoRoot, + virtualMode: true, + }); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toEqual([]); + }); +}); diff --git a/test/wiki-link-validator.test.ts b/test/wiki-link-validator.test.ts new file mode 100644 index 00000000..ed15f0c2 --- /dev/null +++ b/test/wiki-link-validator.test.ts @@ -0,0 +1,154 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; +import { + formatWikiLinkIssues, + validateWikiInternalLinks, +} from "../src/agent/wiki-link-validator.ts"; + +async function setupWiki() { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "openwiki-links-")); + const backend = new OpenWikiLocalShellBackend({ + docsOnly: true, + outputMode: "repository", + rootDir, + virtualMode: true, + }); + return { backend, rootDir }; +} + +describe("validateWikiInternalLinks", () => { + test("accepts valid relative file links", async () => { + const { backend } = await setupWiki(); + await backend.write( + "/openwiki/quickstart.md", + "# Quickstart\n\nSee [architecture](./architecture/overview.md).\n", + ); + await backend.write("/openwiki/architecture/overview.md", "# Overview\n"); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toEqual([]); + }); + + test("accepts root-relative links from the wiki root", async () => { + const { backend } = await setupWiki(); + await backend.write( + "/openwiki/integrations/connectors.md", + "See [CLI usage](/cli/usage.md).\n", + ); + await backend.write("/openwiki/cli/usage.md", "# CLI usage\n"); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toEqual([]); + }); + + test("reports missing target files", async () => { + const { backend } = await setupWiki(); + await backend.write( + "/openwiki/quickstart.md", + "Broken [link](./missing.md).\n", + ); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + href: "./missing.md", + line: 1, + sourcePath: "/openwiki/quickstart.md", + }); + expect(issues[0].message).toContain("does not exist"); + }); + + test("reports missing heading anchors using GitHub slug rules", async () => { + const { backend } = await setupWiki(); + await backend.write( + "/openwiki/quickstart.md", + "See [section](./architecture/overview.md#missing-anchor).\n", + ); + await backend.write( + "/openwiki/architecture/overview.md", + "# Architecture Overview\n\n## a + b\n", + ); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + href: "./architecture/overview.md#missing-anchor", + sourcePath: "/openwiki/quickstart.md", + }); + }); + + test("accepts duplicate heading anchors with numeric suffixes", async () => { + const { backend } = await setupWiki(); + await backend.write( + "/openwiki/quickstart.md", + [ + "# Hello", + "", + "# Hello", + "", + "Jump to [first](#hello) or [second](#hello-1).", + "Cross-page [third](./other.md#hello-1).", + ].join("\n"), + ); + await backend.write( + "/openwiki/other.md", + "# Hello\n\n# Hello\n\n## Details\n", + ); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toEqual([]); + }); + + test("accepts directory links", async () => { + const { backend, rootDir } = await setupWiki(); + await mkdir(path.join(rootDir, "openwiki", "agent"), { recursive: true }); + await writeFile( + path.join(rootDir, "openwiki", "index.md"), + "- [agent](agent/)\n", + "utf8", + ); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toEqual([]); + }); + + test("ignores external links and images", async () => { + const { backend } = await setupWiki(); + await backend.write( + "/openwiki/quickstart.md", + [ + "External [site](https://example.com).", + "Image ![logo](./missing.png).", + ].join("\n"), + ); + + const issues = await validateWikiInternalLinks(backend, "repository"); + + expect(issues).toEqual([]); + }); + + test("formats actionable validation errors", () => { + const message = formatWikiLinkIssues([ + { + href: "./missing.md", + line: 4, + message: 'file "./missing.md" does not exist', + sourcePath: "/openwiki/quickstart.md", + }, + ]); + + expect(message).toContain("OpenWiki internal link validation failed"); + expect(message).toContain( + "/openwiki/quickstart.md:4 [./missing.md] file \"./missing.md\" does not exist", + ); + }); +});