Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/agent/index-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
}
},
});
}
Expand Down
275 changes: 275 additions & 0 deletions src/agent/wiki-link-validator.ts
Original file line number Diff line number Diff line change
@@ -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<WikiLinkIssue[]> {
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<string>,
): Promise<WikiLinkIssue | null> {
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<string[]> {
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<string> {
const counts = new Map<string, number>();
const anchors = new Set<string>();

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<boolean> {
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<string> {
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.`);
}
20 changes: 20 additions & 0 deletions test/wiki-link-validator-dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading