diff --git a/src/components/lint/lint-view.tsx b/src/components/lint/lint-view.tsx index fb52a9b1a..6c7e04980 100644 --- a/src/components/lint/lint-view.tsx +++ b/src/components/lint/lint-view.tsx @@ -1,428 +1,549 @@ -import { useState, useCallback, useMemo } from "react" import { - Link2Off, - Unlink, - ArrowUpRight, - AlertTriangle, - Info, - RefreshCw, - CheckCircle2, - BrainCircuit, - Wrench, - Trash2, -} from "lucide-react" -import { Button } from "@/components/ui/button" -import { useWikiStore } from "@/stores/wiki-store" -import { useReviewStore } from "@/stores/review-store" -import { runStructuralLint, runSemanticLint, type LintResult } from "@/lib/lint" -import { hasUsableLlm } from "@/lib/has-usable-llm" -import { readFile, writeFile, listDirectory } from "@/commands/fs" -import { normalizePath } from "@/lib/path-utils" -import { useTranslation } from "react-i18next" + AlertTriangle, + ArrowUpRight, + BrainCircuit, + CheckCircle2, + Info, + Link2Off, + RefreshCw, + Sparkles, + Trash2, + Unlink, + Wrench, + Zap, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { listDirectory, readFile, writeFile } from "@/commands/fs"; +import { Button } from "@/components/ui/button"; +import { hasUsableLlm } from "@/lib/has-usable-llm"; +import { + type LintResult, + runSemanticLint, + runStructuralLint, +} from "@/lib/lint"; +import { fixAllLintResults, fixLintResult, isFixable } from "@/lib/lint-fixer"; +import { normalizePath } from "@/lib/path-utils"; +import { useReviewStore } from "@/stores/review-store"; +import { useWikiStore } from "@/stores/wiki-store"; interface IndexedLintResult { - result: LintResult - index: number + result: LintResult; + index: number; } export function groupLintResultsForDisplay(results: readonly LintResult[]): { - warnings: IndexedLintResult[] - infos: IndexedLintResult[] + warnings: IndexedLintResult[]; + infos: IndexedLintResult[]; } { - const warnings: IndexedLintResult[] = [] - const infos: IndexedLintResult[] = [] + const warnings: IndexedLintResult[] = []; + const infos: IndexedLintResult[] = []; - results.forEach((result, index) => { - const item = { result, index } - if (result.severity === "warning") { - warnings.push(item) - } else { - infos.push(item) - } - }) + results.forEach((result, index) => { + const item = { result, index }; + if (result.severity === "warning") { + warnings.push(item); + } else { + infos.push(item); + } + }); - return { warnings, infos } + return { warnings, infos }; } export function LintView() { - const { t } = useTranslation() - const project = useWikiStore((s) => s.project) - const llmConfig = useWikiStore((s) => s.llmConfig) - const setSelectedFile = useWikiStore((s) => s.setSelectedFile) - const setFileContent = useWikiStore((s) => s.setFileContent) - const setActiveView = useWikiStore((s) => s.setActiveView) - const setFileTree = useWikiStore((s) => s.setFileTree) - const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion) + const { t } = useTranslation(); + const project = useWikiStore((s) => s.project); + const llmConfig = useWikiStore((s) => s.llmConfig); + const setSelectedFile = useWikiStore((s) => s.setSelectedFile); + const setFileContent = useWikiStore((s) => s.setFileContent); + const setActiveView = useWikiStore((s) => s.setActiveView); + const setFileTree = useWikiStore((s) => s.setFileTree); + const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion); + + // Dynamic type config based on i18n + const typeConfig = useMemo( + () => ({ + orphan: { icon: Unlink, label: t("lint.typeLabels.orphan") }, + "broken-link": { + icon: Link2Off, + label: t("lint.typeLabels.broken-link"), + }, + "no-outlinks": { + icon: ArrowUpRight, + label: t("lint.typeLabels.no-outlinks"), + }, + semantic: { icon: BrainCircuit, label: t("lint.typeLabels.semantic") }, + }), + [t], + ); + + const [results, setResults] = useState([]); + const [running, setRunning] = useState(false); + const [hasRun, setHasRun] = useState(false); + const [runSemantic, setRunSemantic] = useState(false); + const [fixingId, setFixingId] = useState(null); + const [fixingAll, setFixingAll] = useState(false); - // Dynamic type config based on i18n - const typeConfig = useMemo(() => ({ - orphan: { icon: Unlink, label: t("lint.typeLabels.orphan") }, - "broken-link": { icon: Link2Off, label: t("lint.typeLabels.broken-link") }, - "no-outlinks": { icon: ArrowUpRight, label: t("lint.typeLabels.no-outlinks") }, - semantic: { icon: BrainCircuit, label: t("lint.typeLabels.semantic") }, - }), [t]) + const refreshTree = useCallback(async () => { + if (!project) return; + const pp = normalizePath(project.path); + const tree = await listDirectory(pp); + setFileTree(tree); + bumpDataVersion(); + }, [project, setFileTree, bumpDataVersion]); - const [results, setResults] = useState([]) - const [running, setRunning] = useState(false) - const [hasRun, setHasRun] = useState(false) - const [runSemantic, setRunSemantic] = useState(false) - const [fixingId, setFixingId] = useState(null) + const handleRunLint = useCallback(async () => { + if (!project || running) return; + const pp = normalizePath(project.path); + setRunning(true); + setResults([]); + try { + const structural = await runStructuralLint(pp); + let all = structural; - const handleRunLint = useCallback(async () => { - if (!project || running) return - const pp = normalizePath(project.path) - setRunning(true) - setResults([]) - try { - const structural = await runStructuralLint(pp) - let all = structural + if (runSemantic && hasUsableLlm(llmConfig)) { + const semantic = await runSemanticLint(pp, llmConfig); + all = [...structural, ...semantic]; + } - if (runSemantic && hasUsableLlm(llmConfig)) { - const semantic = await runSemanticLint(pp, llmConfig) - all = [...structural, ...semantic] - } + setResults(all); + setHasRun(true); + } catch (err) { + console.error("Lint failed:", err); + } finally { + setRunning(false); + } + }, [project, llmConfig, running, runSemantic]); - setResults(all) - setHasRun(true) - } catch (err) { - console.error("Lint failed:", err) - } finally { - setRunning(false) - } - }, [project, llmConfig, running, runSemantic]) + async function handleOpenPage(page: string) { + if (!project) return; + const pp = normalizePath(project.path); + const candidates = [`${pp}/wiki/${page}`, `${pp}/wiki/${page}.md`]; + setActiveView("wiki"); + for (const path of candidates) { + try { + const content = await readFile(path); + setSelectedFile(path); + setFileContent(content); + return; + } catch { + // try next + } + } + setSelectedFile(candidates[0]); + setFileContent(`Unable to load: ${page}`); + } - async function handleOpenPage(page: string) { - if (!project) return - const pp = normalizePath(project.path) - const candidates = [ - `${pp}/wiki/${page}`, - `${pp}/wiki/${page}.md`, - ] - setActiveView("wiki") - for (const path of candidates) { - try { - const content = await readFile(path) - setSelectedFile(path) - setFileContent(content) - return - } catch { - // try next - } - } - setSelectedFile(candidates[0]) - setFileContent(`Unable to load: ${page}`) - } + async function handleFix(result: LintResult, index: number) { + if (!project) return; + const pp = normalizePath(project.path); + const id = `${result.type}-${index}`; + setFixingId(id); - async function handleFix(result: LintResult, index: number) { - if (!project) return - const pp = normalizePath(project.path) - const id = `${result.type}-${index}` - setFixingId(id) + try { + switch (result.type) { + case "orphan": { + const indexPath = `${pp}/wiki/index.md`; + let indexContent = ""; + try { + indexContent = await readFile(indexPath); + } catch { + indexContent = "# Wiki Index\n"; + } + const pageName = result.page.replace(".md", "").replace(/^.*\//, ""); + const entry = `- [[${pageName}]]`; + if (!indexContent.includes(entry)) { + indexContent = indexContent.trimEnd() + "\n" + entry + "\n"; + await writeFile(indexPath, indexContent); + } + setResults((prev) => prev.filter((_, i) => i !== index)); + break; + } + case "broken-link": { + const pagePath = `${pp}/wiki/${result.page}`; + useReviewStore.getState().addItem({ + type: "confirm", + title: t("lint.fixBrokenLink", { page: result.page }), + description: result.detail, + affectedPages: [result.page], + options: [ + { label: t("lint.openEdit"), action: `open:${result.page}` }, + { label: t("lint.deletePage"), action: `delete:${pagePath}` }, + { label: t("lint.skip"), action: "Skip" }, + ], + }); + setResults((prev) => prev.filter((_, i) => i !== index)); + break; + } + case "no-outlinks": { + useReviewStore.getState().addItem({ + type: "suggestion", + title: t("lint.addCrossRefs", { page: result.page }), + description: t("lint.addCrossRefsDescription"), + affectedPages: [result.page], + options: [ + { label: t("lint.openEdit"), action: `open:${result.page}` }, + { label: t("lint.skip"), action: "Skip" }, + ], + }); + setResults((prev) => prev.filter((_, i) => i !== index)); + break; + } + default: { + useReviewStore.getState().addItem({ + type: "confirm", + title: result.detail.slice(0, 80), + description: result.detail, + affectedPages: result.affectedPages ?? [result.page], + options: [ + { label: t("lint.openEdit"), action: `open:${result.page}` }, + { label: t("lint.skip"), action: "Skip" }, + ], + }); + setResults((prev) => prev.filter((_, i) => i !== index)); + break; + } + } + await refreshTree(); + } catch (err) { + console.error("Fix failed:", err); + } finally { + setFixingId(null); + } + } - try { - switch (result.type) { - case "orphan": { - // Add a link to this page from index.md - const indexPath = `${pp}/wiki/index.md` - let indexContent = "" - try { indexContent = await readFile(indexPath) } catch { indexContent = "# Wiki Index\n" } + async function handleAutoFix(result: LintResult, index: number) { + if (!project || !hasUsableLlm(llmConfig)) return; + const id = `${result.type}-${index}`; + setFixingId(id); - const pageName = result.page.replace(".md", "").replace(/^.*\//, "") - const entry = `- [[${pageName}]]` - if (!indexContent.includes(entry)) { - indexContent = indexContent.trimEnd() + "\n" + entry + "\n" - await writeFile(indexPath, indexContent) - } - // Remove from results - setResults((prev) => prev.filter((_, i) => i !== index)) - break - } + try { + const pp = normalizePath(project.path); + const fixResult = await fixLintResult(pp, result, llmConfig); + if (fixResult.success) { + setResults((prev) => prev.filter((_, i) => i !== index)); + } + await refreshTree(); + } catch (err) { + console.error("Auto fix failed:", err); + } finally { + setFixingId(null); + } + } - case "broken-link": { - // Option: remove the broken link from the page, or send to Review for manual fix - const pagePath = `${pp}/wiki/${result.page}` - useReviewStore.getState().addItem({ - type: "confirm", - title: t("lint.fixBrokenLink", { page: result.page }), - description: result.detail, - affectedPages: [result.page], - options: [ - { label: t("lint.openEdit"), action: `open:${result.page}` }, - { label: t("lint.deletePage"), action: `delete:${pagePath}` }, - { label: t("lint.skip"), action: "Skip" }, - ], - }) - setResults((prev) => prev.filter((_, i) => i !== index)) - break - } + async function handleFixAll() { + if (!project || !hasUsableLlm(llmConfig) || fixingAll) return; + setFixingAll(true); - case "no-outlinks": { - // Send to Review — user should add links manually - useReviewStore.getState().addItem({ - type: "suggestion", - title: t("lint.addCrossRefs", { page: result.page }), - description: t("lint.addCrossRefsDescription"), - affectedPages: [result.page], - options: [ - { label: t("lint.openEdit"), action: `open:${result.page}` }, - { label: t("lint.skip"), action: "Skip" }, - ], - }) - setResults((prev) => prev.filter((_, i) => i !== index)) - break - } + try { + const pp = normalizePath(project.path); + const fixable = results.filter(isFixable); + const fixResults = await fixAllLintResults(pp, fixable, llmConfig); - default: { - // Semantic issues → send to Review for manual resolution - useReviewStore.getState().addItem({ - type: "confirm", - title: result.detail.slice(0, 80), - description: result.detail, - affectedPages: result.affectedPages ?? [result.page], - options: [ - { label: t("lint.openEdit"), action: `open:${result.page}` }, - { label: t("lint.skip"), action: "Skip" }, - ], - }) - setResults((prev) => prev.filter((_, i) => i !== index)) - break - } - } + const fixedIndices = new Set(); + fixable.forEach((result, i) => { + if (fixResults[i]?.success) { + const origIndex = results.indexOf(result); + if (origIndex >= 0) fixedIndices.add(origIndex); + } + }); + setResults((prev) => prev.filter((_, i) => !fixedIndices.has(i))); + await refreshTree(); + } catch (err) { + console.error("Fix all failed:", err); + } finally { + setFixingAll(false); + } + } - // Refresh tree - const tree = await listDirectory(pp) - setFileTree(tree) - bumpDataVersion() - } catch (err) { - console.error("Fix failed:", err) - } finally { - setFixingId(null) - } - } + async function handleDeleteOrphan(result: LintResult, index: number) { + if (!project) return; + const pp = normalizePath(project.path); + const pagePath = `${pp}/wiki/${result.page}`; + const confirmed = window.confirm( + t("lint.deleteOrphanConfirm", { page: result.page }), + ); + if (!confirmed) return; - async function handleDeleteOrphan(result: LintResult, index: number) { - if (!project) return - const pp = normalizePath(project.path) - const pagePath = `${pp}/wiki/${result.page}` - const confirmed = window.confirm(t("lint.deleteOrphanConfirm", { page: result.page })) - if (!confirmed) return + try { + const { cascadeDeleteWikiPagesWithRefs } = await import( + "@/lib/wiki-page-delete" + ); + await cascadeDeleteWikiPagesWithRefs(pp, [pagePath]); + setResults((prev) => prev.filter((_, i) => i !== index)); + await refreshTree(); + } catch (err) { + console.error("Delete failed:", err); + } + } - try { - // Full cascade: file + embedding chunks + every reference to - // the page across the wiki (body wikilinks, index.md listing, - // `related:` frontmatter arrays). Even though "orphan" by lint - // means no incoming wikilinks were detected, `related:` slugs - // and index.md entries can still point at it — the orphan - // detector only walks body refs. - const { cascadeDeleteWikiPagesWithRefs } = await import( - "@/lib/wiki-page-delete" - ) - await cascadeDeleteWikiPagesWithRefs(pp, [pagePath]) - setResults((prev) => prev.filter((_, i) => i !== index)) - const tree = await listDirectory(pp) - setFileTree(tree) - bumpDataVersion() - } catch (err) { - console.error("Delete failed:", err) - } - } + const fixableCount = useMemo( + () => results.filter(isFixable).length, + [results], + ); - const { warnings, infos } = useMemo( - () => groupLintResultsForDisplay(results), - [results], - ) + const { warnings, infos } = useMemo( + () => groupLintResultsForDisplay(results), + [results], + ); - return ( -
-
-
-

{t("lint.title")}

- {hasRun && results.length > 0 && ( - - {results.length === 1 ? t("lint.issues", { count: results.length }) : t("lint.issues_plural", { count: results.length })} - - )} -
-
- - -
-
+ return ( +
+
+
+

{t("lint.title")}

+ {hasRun && results.length > 0 && ( + + {results.length === 1 + ? t("lint.issues", { count: results.length }) + : t("lint.issues_plural", { count: results.length })} + + )} +
+
+ {hasRun && fixableCount > 0 && ( + + )} + + +
+
-
- {!hasRun ? ( -
- -

{t("lint.runLintHint")}

-

{t("lint.runLintDescription")}

-
- ) : results.length === 0 ? ( -
- -

{t("lint.allClear")}

-

{t("lint.noIssues")}

-
- ) : ( -
- {warnings.length > 0 && ( - - )} - {warnings.map(({ result, index }) => ( - - ))} - {infos.length > 0 && ( - - )} - {infos.map(({ result, index }) => ( - - ))} -
- )} -
-
- ) +
+ {!hasRun ? ( +
+ +

{t("lint.runLintHint")}

+

{t("lint.runLintDescription")}

+
+ ) : results.length === 0 ? ( +
+ +

+ {t("lint.allClear")} +

+

{t("lint.noIssues")}

+
+ ) : ( +
+ {warnings.length > 0 && ( + + )} + {warnings.map(({ result, index }) => ( + + ))} + {infos.length > 0 && ( + + )} + {infos.map(({ result, index }) => ( + + ))} +
+ )} +
+
+ ); } function SectionHeader({ - icon: Icon, - label, - count, - color, - t, + icon: Icon, + label, + count, + color, + t, }: { - icon: typeof AlertTriangle - label: string - count: number - color: string - t: (key: string, opts?: Record) => string + icon: typeof AlertTriangle; + label: string; + count: number; + color: string; + t: (key: string, opts?: Record) => string; }) { - return ( -
- - {t("lint.sectionCount", { label, count })} -
- ) + return ( +
+ + {t("lint.sectionCount", { label, count })} +
+ ); } function LintCard({ - result, - index, - fixing, - onOpenPage, - onFix, - onDelete, - typeConfig, - t, + result, + index, + fixing, + fixingAll, + hasLlm, + onOpenPage, + onFix, + onAutoFix, + onDelete, + typeConfig, + t, }: { - result: LintResult - index: number - fixing: boolean - onOpenPage: (page: string) => void - onFix: (result: LintResult, index: number) => void - onDelete?: (result: LintResult, index: number) => void - typeConfig: Record - t: (key: string, opts?: Record) => string + result: LintResult; + index: number; + fixing: boolean; + fixingAll: boolean; + hasLlm: boolean; + onOpenPage: (page: string) => void; + onFix: (result: LintResult, index: number) => void; + onAutoFix: (result: LintResult, index: number) => void; + onDelete?: (result: LintResult, index: number) => void; + typeConfig: Record; + t: (key: string, opts?: Record) => string; }) { - const config = typeConfig[result.type] ?? typeConfig.semantic - const Icon = config.icon + const config = typeConfig[result.type] ?? typeConfig.semantic; + const Icon = config.icon; + const canAutoFix = hasLlm && isFixable(result); - return ( -
-
- -
-
{result.page}
-
{config.label}
-
-
+ return ( +
+
+ +
+
{result.page}
+
+ {config.label} +
+
+
-

{result.detail}

+

{result.detail}

- {result.affectedPages && result.affectedPages.length > 0 && ( -
- {result.affectedPages.map((page) => ( - - ))} -
- )} + {result.affectedPages && result.affectedPages.length > 0 && ( +
+ {result.affectedPages.map((page) => ( + + ))} +
+ )} -
- - - {onDelete && ( - - )} -
-
- ) +
+ + {canAutoFix && ( + + )} + + {onDelete && ( + + )} +
+
+ ); } diff --git a/src/i18n/en.json b/src/i18n/en.json index 4cb3e0280..983f2f207 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -150,7 +150,10 @@ "semantic": "Semantic Issue" }, "issues": "{{count}} issue", - "issues_plural": "{{count}} issues" + "issues_plural": "{{count}} issues", + "autoFix": "Auto Fix", + "fixAll": "Fix All", + "fixingAll": "Fixing all..." }, "review": { "title": "Review", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 39b2146ec..8087e76f4 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -150,7 +150,10 @@ "semantic": "语义问题" }, "issues": "{{count}} 个问题", - "issues_plural": "{{count}} 个问题" + "issues_plural": "{{count}} 个问题", + "autoFix": "自动修复", + "fixAll": "全部修复", + "fixingAll": "正在修复..." }, "review": { "title": "待审阅", diff --git a/src/lib/lint-fixer.test.ts b/src/lib/lint-fixer.test.ts new file mode 100644 index 000000000..af3b3bbf9 --- /dev/null +++ b/src/lib/lint-fixer.test.ts @@ -0,0 +1,395 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LintResult } from "@/lib/lint"; +import type { LlmConfig } from "@/stores/wiki-store"; + +vi.mock("./llm-client", () => ({ + streamChat: vi.fn(), +})); +vi.mock("@/commands/fs", () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + listDirectory: vi.fn(), +})); + +import { listDirectory, readFile, writeFile } from "@/commands/fs"; +import { fixAllLintResults, fixLintResult, isFixable } from "./lint-fixer"; +import { streamChat } from "./llm-client"; + +const mockStreamChat = vi.mocked(streamChat); +const mockReadFile = vi.mocked(readFile); +const mockWriteFile = vi.mocked(writeFile); +const mockListDirectory = vi.mocked(listDirectory); + +function fakeLlmConfig(): LlmConfig { + return { + provider: "openai", + apiKey: "k", + model: "m", + ollamaUrl: "", + customEndpoint: "", + maxContextSize: 128000, + }; +} + +beforeEach(() => { + mockStreamChat.mockReset(); + mockReadFile.mockReset(); + mockWriteFile.mockReset(); + mockListDirectory.mockReset(); +}); + +// ── isFixable ──────────────────────────────────────────────────────────────── + +describe("isFixable", () => { + it("marks orphan as fixable", () => { + expect( + isFixable({ type: "orphan", severity: "info", page: "a.md", detail: "" }), + ).toBe(true); + }); + + it("marks broken-link as fixable", () => { + expect( + isFixable({ + type: "broken-link", + severity: "warning", + page: "a.md", + detail: "", + }), + ).toBe(true); + }); + + it("marks no-outlinks as fixable", () => { + expect( + isFixable({ + type: "no-outlinks", + severity: "info", + page: "a.md", + detail: "", + }), + ).toBe(true); + }); + + it("marks semantic contradiction as fixable", () => { + expect( + isFixable({ + type: "semantic", + severity: "warning", + page: "x", + detail: "[contradiction] pages disagree", + }), + ).toBe(true); + }); + + it("marks semantic stale as fixable", () => { + expect( + isFixable({ + type: "semantic", + severity: "info", + page: "x", + detail: "[stale] outdated info", + }), + ).toBe(true); + }); + + it("marks semantic missing-page as fixable", () => { + expect( + isFixable({ + type: "semantic", + severity: "info", + page: "X", + detail: "[missing-page] no page for X", + }), + ).toBe(true); + }); + + it("does NOT mark semantic suggestion as fixable", () => { + expect( + isFixable({ + type: "semantic", + severity: "info", + page: "x", + detail: "[suggestion] add source", + }), + ).toBe(false); + }); +}); + +// ── fixLintResult: orphan ──────────────────────────────────────────────────── + +describe("fixLintResult — orphan", () => { + it("adds wikilink to index.md", async () => { + mockReadFile.mockResolvedValue("# Wiki Index\n- [[existing]]\n"); + + const result = await fixLintResult( + "/project", + { + type: "orphan", + severity: "info", + page: "entities/foo.md", + detail: "No links", + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(true); + expect(result.filesWritten).toHaveLength(1); + expect(mockWriteFile).toHaveBeenCalledWith( + "/project/wiki/index.md", + expect.stringContaining("[[foo]]"), + ); + }); + + it("does not duplicate if link already exists", async () => { + mockReadFile.mockResolvedValue("# Wiki Index\n- [[foo]]\n"); + + const result = await fixLintResult( + "/project", + { + type: "orphan", + severity: "info", + page: "entities/foo.md", + detail: "No links", + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(true); + // writeFile should NOT be called because the link already exists + expect(mockWriteFile).not.toHaveBeenCalled(); + }); +}); + +// ── fixLintResult: broken-link (LLM) ───────────────────────────────────────── + +describe("fixLintResult — broken-link", () => { + it("uses LLM to fix and writes back", async () => { + mockReadFile.mockResolvedValue( + "---\ntitle: Test\n---\nSome text with [[MissingPage]].", + ); + mockStreamChat.mockImplementation(async (_c, _m, cb) => { + cb.onToken( + "---\ntitle: Test\n---\nSome text with MissingPage (link removed).", + ); + cb.onDone(); + }); + + const result = await fixLintResult( + "/project", + { + type: "broken-link", + severity: "warning", + page: "entities/test.md", + detail: "Broken link: [[MissingPage]]", + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(true); + expect(result.filesWritten).toHaveLength(1); + expect(mockStreamChat).toHaveBeenCalled(); + // Verify prompt mentions the broken link + const prompt = mockStreamChat.mock.calls[0][1][0].content as string; + expect(prompt).toContain("[[MissingPage]]"); + }); + + it("returns failure when LLM produces empty output", async () => { + mockReadFile.mockResolvedValue("---\ntitle: Test\n---\nContent."); + mockStreamChat.mockImplementation(async (_c, _m, cb) => { + cb.onDone(); + }); + + const result = await fixLintResult( + "/project", + { + type: "broken-link", + severity: "warning", + page: "test.md", + detail: "Broken link: [[X]]", + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(false); + }); +}); + +// ── fixLintResult: no-outlinks (LLM) ───────────────────────────────────────── + +describe("fixLintResult — no-outlinks", () => { + it("uses LLM to add cross-references", async () => { + mockReadFile.mockResolvedValue( + "---\ntitle: Foo\n---\nFoo is related to bar and baz.", + ); + mockListDirectory.mockResolvedValue([]); + mockStreamChat.mockImplementation(async (_c, _m, cb) => { + cb.onToken( + "---\ntitle: Foo\n---\nFoo is related to [[bar]] and [[baz]].", + ); + cb.onDone(); + }); + + const result = await fixLintResult( + "/project", + { + type: "no-outlinks", + severity: "info", + page: "entities/foo.md", + detail: "No outbound links", + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(true); + expect(mockWriteFile).toHaveBeenCalled(); + const prompt = mockStreamChat.mock.calls[0][1][0].content as string; + expect(prompt).toContain("no cross-references"); + }); +}); + +// ── fixLintResult: semantic contradiction (LLM) ────────────────────────────── + +describe("fixLintResult — semantic contradiction", () => { + it("reads affected pages and writes FILE blocks back", async () => { + mockReadFile + .mockResolvedValueOnce("---\ntitle: A\n---\nX is 5.") + .mockResolvedValueOnce("---\ntitle: B\n---\nX is 10."); + mockStreamChat.mockImplementation(async (_c, _m, cb) => { + cb.onToken( + "---FILE: entities/a.md---\n---\ntitle: A\n---\nX is 5.\n---END FILE---\n---FILE: entities/b.md---\n---\ntitle: B\n---\nX is 10 (updated).\n---END FILE---", + ); + cb.onDone(); + }); + + const result = await fixLintResult( + "/project", + { + type: "semantic", + severity: "warning", + page: "contradiction", + detail: "[contradiction] A says X is 5, B says X is 10", + affectedPages: ["entities/a.md", "entities/b.md"], + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(true); + expect(result.filesWritten).toHaveLength(2); + }); +}); + +// ── fixLintResult: semantic stale (LLM) ────────────────────────────────────── + +describe("fixLintResult — semantic stale", () => { + it("asks LLM to review and update stale content", async () => { + mockReadFile.mockResolvedValue( + "---\ntitle: Tech\n---\nReact 18 is the latest.", + ); + mockStreamChat.mockImplementation(async (_c, _m, cb) => { + cb.onToken( + "---\ntitle: Tech\n---\nReact 19 is the latest.\n\n> ⚠️ This section may need updating.", + ); + cb.onDone(); + }); + + const result = await fixLintResult( + "/project", + { + type: "semantic", + severity: "info", + page: "tech/react.md", + detail: "[stale] React 18 is outdated", + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(true); + expect(mockWriteFile).toHaveBeenCalled(); + const prompt = mockStreamChat.mock.calls[0][1][0].content as string; + expect(prompt).toContain("outdated"); + }); +}); + +// ── fixLintResult: semantic missing-page (LLM) ────────────────────────────── + +describe("fixLintResult — semantic missing-page", () => { + it("creates a new page from references", async () => { + mockReadFile.mockResolvedValue("---\ntitle: A\n---\nRust is fast."); + mockListDirectory.mockResolvedValue([]); + mockStreamChat.mockImplementation(async (_c, _m, cb) => { + cb.onToken( + "---FILE: entities/rust.md---\n---\ntype: entity\ntitle: Rust\n---\nRust is a systems language.\n---END FILE---", + ); + cb.onDone(); + }); + + const result = await fixLintResult( + "/project", + { + type: "semantic", + severity: "info", + page: "Rust", + detail: "[missing-page] Rust has no page", + affectedPages: ["entities/a.md"], + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(true); + expect(mockWriteFile).toHaveBeenCalled(); + const prompt = mockStreamChat.mock.calls[0][1][0].content as string; + expect(prompt).toContain("Rust"); + }); +}); + +// ── fixLintResult: semantic suggestion (not fixable) ────────────────────────── + +describe("fixLintResult — semantic suggestion", () => { + it("returns failure for suggestion type", async () => { + const result = await fixLintResult( + "/project", + { + type: "semantic", + severity: "info", + page: "x", + detail: "[suggestion] add a source", + }, + fakeLlmConfig(), + ); + + expect(result.success).toBe(false); + expect(result.detail).toContain("Cannot auto-fix"); + }); +}); + +// ── fixAllLintResults ───────────────────────────────────────────────────────── + +describe("fixAllLintResults", () => { + it("only fixes fixable results", async () => { + const results: LintResult[] = [ + { type: "orphan", severity: "info", page: "a.md", detail: "" }, + { + type: "semantic", + severity: "info", + page: "x", + detail: "[suggestion] skip me", + }, + ]; + + // orphan fix reads index.md + mockReadFile.mockResolvedValue("# Wiki Index\n"); + + const fixResults = await fixAllLintResults( + "/project", + results, + fakeLlmConfig(), + ); + + expect(fixResults).toHaveLength(1); // only the orphan + expect(fixResults[0].success).toBe(true); + }); + + it("handles empty input", async () => { + const fixResults = await fixAllLintResults("/project", [], fakeLlmConfig()); + expect(fixResults).toHaveLength(0); + }); +}); diff --git a/src/lib/lint-fixer.ts b/src/lib/lint-fixer.ts new file mode 100644 index 000000000..b75f97cc5 --- /dev/null +++ b/src/lib/lint-fixer.ts @@ -0,0 +1,650 @@ +import { listDirectory, readFile, writeFile } from "@/commands/fs"; +import { sanitizeIngestedFileContent } from "@/lib/ingest-sanitize"; +import type { LintResult } from "@/lib/lint"; +import { streamChat } from "@/lib/llm-client"; +import { buildLanguageDirective } from "@/lib/output-language"; +import { getRelativePath, normalizePath } from "@/lib/path-utils"; +import { useActivityStore } from "@/stores/activity-store"; +import type { LlmConfig } from "@/stores/wiki-store"; +import type { FileNode } from "@/types/wiki"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export interface FixResult { + success: boolean; + detail: string; + filesWritten: string[]; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function flattenMdFiles(nodes: FileNode[]): FileNode[] { + const files: FileNode[] = []; + for (const node of nodes) { + if (node.is_dir && node.children) { + files.push(...flattenMdFiles(node.children)); + } else if (!node.is_dir && node.name.endsWith(".md")) { + files.push(node); + } + } + return files; +} + +async function tryReadFile(path: string): Promise { + try { + return await readFile(path); + } catch { + return null; + } +} + +async function getWikiPageList(projectPath: string): Promise { + const wikiRoot = `${normalizePath(projectPath)}/wiki`; + try { + const tree = await listDirectory(wikiRoot); + const files = flattenMdFiles(tree); + return files.map((f) => + getRelativePath(f.path, wikiRoot).replace(/\.md$/, ""), + ); + } catch { + return []; + } +} + +function isFixable(result: LintResult): boolean { + if ( + result.type === "orphan" || + result.type === "broken-link" || + result.type === "no-outlinks" + ) { + return true; + } + if (result.type === "semantic") { + const subType = getSemanticSubType(result.detail); + return subType !== "suggestion"; + } + return false; +} + +export { isFixable }; + +function getSemanticSubType(detail: string): string { + const m = detail.match(/^\[([^\]]+)\]/); + return m ? m[1].toLowerCase() : ""; +} + +// ── Orphan fix (structural, no LLM) ─────────────────────────────────────────── + +async function fixOrphan( + projectPath: string, + result: LintResult, +): Promise { + const pp = normalizePath(projectPath); + const indexPath = `${pp}/wiki/index.md`; + let indexContent = (await tryReadFile(indexPath)) ?? "# Wiki Index\n"; + + const pageName = result.page.replace(/\.md$/, "").replace(/^.*\//, ""); + const entry = `- [[${pageName}]]`; + if (!indexContent.includes(entry)) { + indexContent = indexContent.trimEnd() + "\n" + entry + "\n"; + await writeFile(indexPath, indexContent); + } + + return { + success: true, + detail: `Linked [[${pageName}]] in index.md`, + filesWritten: [indexPath], + }; +} + +// ── Broken link fix (LLM) ────────────────────────────────────────────────────── + +async function fixBrokenLink( + projectPath: string, + result: LintResult, + llmConfig: LlmConfig, +): Promise { + const pp = normalizePath(projectPath); + const wikiRoot = `${pp}/wiki`; + const pagePath = `${wikiRoot}/${result.page}`; + const content = await tryReadFile(pagePath); + if (!content) + return { success: false, detail: "Cannot read page", filesWritten: [] }; + + const brokenTarget = result.detail.match(/\[\[([^\]]+)\]\]/)?.[1] ?? ""; + const pageList = await getWikiPageList(pp); + + const prompt = [ + "You are a wiki fixer. The following page contains a broken wikilink that points to a non-existent page.", + "", + `Broken link: [[${brokenTarget}]]`, + "", + "Options:", + "- Remove the broken [[wikilink]] entirely (keep the surrounding text)", + "- Replace it with a link to an existing page if one is clearly the intended target", + "", + "Existing pages in the wiki:", + pageList.map((p) => ` - ${p}`).join("\n"), + "", + "Current page content:", + content, + "", + "Output the FULL corrected page. Preserve ALL frontmatter exactly as-is. Only change the broken wikilink.", + ].join("\n"); + + const activity = useActivityStore.getState(); + const activityId = activity.addItem({ + type: "lint", + title: `Fix: broken link in ${result.page}`, + status: "running", + detail: "Asking LLM to fix broken link...", + filesWritten: [], + }); + + let raw = ""; + let hadError = false; + + await streamChat(llmConfig, [{ role: "user", content: prompt }], { + onToken: (token) => { + raw += token; + }, + onDone: () => {}, + onError: (err) => { + hadError = true; + activity.updateItem(activityId, { + status: "error", + detail: `LLM error: ${err.message}`, + }); + }, + }); + + if (hadError || !raw.trim()) { + return { + success: false, + detail: "LLM did not produce output", + filesWritten: [], + }; + } + + const fixed = sanitizeIngestedFileContent(raw.trim()); + await writeFile(pagePath, fixed); + activity.updateItem(activityId, { + status: "done", + detail: "Fixed broken link", + filesWritten: [pagePath], + }); + + return { + success: true, + detail: "Broken link fixed", + filesWritten: [pagePath], + }; +} + +// ── No-outlinks fix (LLM) ────────────────────────────────────────────────────── + +async function fixNoOutlinks( + projectPath: string, + result: LintResult, + llmConfig: LlmConfig, +): Promise { + const pp = normalizePath(projectPath); + const wikiRoot = `${pp}/wiki`; + const pagePath = `${wikiRoot}/${result.page}`; + const content = await tryReadFile(pagePath); + if (!content) + return { success: false, detail: "Cannot read page", filesWritten: [] }; + + const pageList = await getWikiPageList(pp); + + const prompt = [ + "You are a wiki fixer. The following page has no cross-references ([[wikilinks]]) to other wiki pages.", + "Add appropriate [[wikilinks]] to existing pages where the content naturally references related concepts or entities.", + "", + "Existing pages in the wiki:", + pageList.map((p) => ` - ${p}`).join("\n"), + "", + "Current page content:", + content, + "", + "Rules:", + "- Only ADD [[wikilinks]] around existing text that references another topic. Do NOT change any other content.", + "- Only link to pages that exist in the list above.", + "- Preserve ALL frontmatter exactly as-is.", + "- Output the FULL corrected page.", + ].join("\n"); + + const activity = useActivityStore.getState(); + const activityId = activity.addItem({ + type: "lint", + title: `Fix: add links to ${result.page}`, + status: "running", + detail: "Asking LLM to add cross-references...", + filesWritten: [], + }); + + let raw = ""; + let hadError = false; + + await streamChat(llmConfig, [{ role: "user", content: prompt }], { + onToken: (token) => { + raw += token; + }, + onDone: () => {}, + onError: (err) => { + hadError = true; + activity.updateItem(activityId, { + status: "error", + detail: `LLM error: ${err.message}`, + }); + }, + }); + + if (hadError || !raw.trim()) { + return { + success: false, + detail: "LLM did not produce output", + filesWritten: [], + }; + } + + const fixed = sanitizeIngestedFileContent(raw.trim()); + await writeFile(pagePath, fixed); + activity.updateItem(activityId, { + status: "done", + detail: "Added cross-references", + filesWritten: [pagePath], + }); + + return { + success: true, + detail: "Added cross-references", + filesWritten: [pagePath], + }; +} + +// ── Semantic fix: contradiction (LLM) ────────────────────────────────────────── + +async function fixContradiction( + projectPath: string, + result: LintResult, + llmConfig: LlmConfig, +): Promise { + const pp = normalizePath(projectPath); + const wikiRoot = `${pp}/wiki`; + const affectedPages = result.affectedPages ?? [result.page]; + + const pagesContent: string[] = []; + for (const page of affectedPages) { + const content = await tryReadFile(`${wikiRoot}/${page}`); + if (content) { + pagesContent.push(`### ${page}\n${content}`); + } + } + + if (pagesContent.length === 0) { + return { + success: false, + detail: "Cannot read affected pages", + filesWritten: [], + }; + } + + const langDirective = buildLanguageDirective( + pagesContent.join("\n").slice(0, 2000), + ); + + const prompt = [ + "You are a wiki fixer. The following wiki pages contain contradictory information:", + "", + langDirective, + "", + ...pagesContent, + "", + "Problem: " + result.detail, + "", + "Resolve the contradiction. Preserve all CORRECT information from both pages. Remove or correct only the conflicting claims.", + "Output each corrected page using this exact format:", + "", + "---FILE: path/to/page.md---", + "(full corrected page content with frontmatter)", + "---END FILE---", + ].join("\n"); + + const activity = useActivityStore.getState(); + const activityId = activity.addItem({ + type: "lint", + title: "Fix: contradiction", + status: "running", + detail: "Resolving contradiction across pages...", + filesWritten: [], + }); + + let raw = ""; + let hadError = false; + + await streamChat(llmConfig, [{ role: "user", content: prompt }], { + onToken: (token) => { + raw += token; + }, + onDone: () => {}, + onError: (err) => { + hadError = true; + activity.updateItem(activityId, { + status: "error", + detail: `LLM error: ${err.message}`, + }); + }, + }); + + if (hadError || !raw.trim()) { + return { + success: false, + detail: "LLM did not produce output", + filesWritten: [], + }; + } + + const written = await writeFileBlocks(pp, raw); + activity.updateItem(activityId, { + status: "done", + detail: `Resolved contradiction, wrote ${written.length} page(s)`, + filesWritten: written, + }); + + return { + success: true, + detail: "Contradiction resolved", + filesWritten: written, + }; +} + +// ── Semantic fix: stale (LLM) ────────────────────────────────────────────────── + +async function fixStale( + projectPath: string, + result: LintResult, + llmConfig: LlmConfig, +): Promise { + const pp = normalizePath(projectPath); + const wikiRoot = `${pp}/wiki`; + const pagePath = `${wikiRoot}/${result.page}`; + const content = await tryReadFile(pagePath); + if (!content) + return { success: false, detail: "Cannot read page", filesWritten: [] }; + + const langDirective = buildLanguageDirective(content.slice(0, 2000)); + + const prompt = [ + "You are a wiki fixer. The following wiki page may contain outdated or superseded information.", + "", + langDirective, + "", + "Problem: " + result.detail, + "", + "Page content:", + content, + "", + "Review and update any claims that appear stale or superseded.", + "If you cannot verify whether a claim is still current, add a note:", + "> ⚠️ This section may need updating.", + "", + "Preserve ALL frontmatter exactly as-is. Output the FULL corrected page.", + ].join("\n"); + + const activity = useActivityStore.getState(); + const activityId = activity.addItem({ + type: "lint", + title: `Fix: stale content in ${result.page}`, + status: "running", + detail: "Reviewing and updating stale content...", + filesWritten: [], + }); + + let raw = ""; + let hadError = false; + + await streamChat(llmConfig, [{ role: "user", content: prompt }], { + onToken: (token) => { + raw += token; + }, + onDone: () => {}, + onError: (err) => { + hadError = true; + activity.updateItem(activityId, { + status: "error", + detail: `LLM error: ${err.message}`, + }); + }, + }); + + if (hadError || !raw.trim()) { + return { + success: false, + detail: "LLM did not produce output", + filesWritten: [], + }; + } + + const fixed = sanitizeIngestedFileContent(raw.trim()); + await writeFile(pagePath, fixed); + activity.updateItem(activityId, { + status: "done", + detail: "Updated stale content", + filesWritten: [pagePath], + }); + + return { + success: true, + detail: "Stale content updated", + filesWritten: [pagePath], + }; +} + +// ── Semantic fix: missing-page (LLM) ─────────────────────────────────────────── + +async function fixMissingPage( + projectPath: string, + result: LintResult, + llmConfig: LlmConfig, +): Promise { + const pp = normalizePath(projectPath); + const wikiRoot = `${pp}/wiki`; + const conceptName = result.page; + const pageList = await getWikiPageList(pp); + + // Collect excerpts from pages that reference this concept + const refs: string[] = []; + const affectedPages = result.affectedPages ?? []; + for (const page of affectedPages) { + const content = await tryReadFile(`${wikiRoot}/${page}`); + if (content) { + refs.push(`### ${page}\n${content.slice(0, 1500)}`); + } + } + + const slug = conceptName + .toLowerCase() + .replace(/[^a-z0-9一-鿿]+/g, "-") + .replace(/^-|-$/g, ""); + + const langDirective = buildLanguageDirective(refs.join("\n").slice(0, 2000)); + + const prompt = [ + "You are a wiki fixer. The concept or entity below is referenced by multiple wiki pages but has no dedicated page.", + "Create a new wiki page that consolidates the references into a coherent article.", + "", + langDirective, + "", + `Concept: ${conceptName}`, + "", + "References from existing pages:", + ...refs, + "", + "Existing pages in the wiki:", + pageList.map((p) => ` - ${p}`).join("\n"), + "", + `Output the new page using this exact format:`, + "", + `---FILE: entities/${slug}.md---`, + "---", + `type: entity`, + `title: "${conceptName}"`, + `sources: []`, + `created: ${new Date().toISOString().slice(0, 10)}`, + `updated: ${new Date().toISOString().slice(0, 10)}`, + "---", + "(page body with [[wikilinks]] to existing pages where appropriate)", + "---END FILE---", + ].join("\n"); + + const activity = useActivityStore.getState(); + const activityId = activity.addItem({ + type: "lint", + title: `Fix: create missing page "${conceptName}"`, + status: "running", + detail: "Creating missing page...", + filesWritten: [], + }); + + let raw = ""; + let hadError = false; + + await streamChat(llmConfig, [{ role: "user", content: prompt }], { + onToken: (token) => { + raw += token; + }, + onDone: () => {}, + onError: (err) => { + hadError = true; + activity.updateItem(activityId, { + status: "error", + detail: `LLM error: ${err.message}`, + }); + }, + }); + + if (hadError || !raw.trim()) { + return { + success: false, + detail: "LLM did not produce output", + filesWritten: [], + }; + } + + const written = await writeFileBlocks(pp, raw); + activity.updateItem(activityId, { + status: "done", + detail: `Created page for "${conceptName}"`, + filesWritten: written, + }); + + return { + success: true, + detail: `Created page for "${conceptName}"`, + filesWritten: written, + }; +} + +// ── FILE block parser + writer (reused from ingest pattern) ──────────────────── + +async function writeFileBlocks( + projectPath: string, + raw: string, +): Promise { + const blocks = parseFileBlocks(raw); + const pp = normalizePath(projectPath); + const wikiRoot = `${pp}/wiki`; + const written: string[] = []; + + for (const block of blocks) { + const fullPath = `${wikiRoot}/${block.path}`; + const content = sanitizeIngestedFileContent(block.content.trim()); + await writeFile(fullPath, content); + written.push(fullPath); + } + + return written; +} + +interface ParsedFileBlock { + path: string; + content: string; +} + +function parseFileBlocks(raw: string): ParsedFileBlock[] { + const blocks: ParsedFileBlock[] = []; + const normalized = raw.replace(/\r\n/g, "\n"); + const regex = /---FILE:\s*([^\n-]+?)\s*---\n([\s\S]*?)---END FILE---/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(normalized)) !== null) { + const path = match[1].trim(); + const content = match[2]; + // Basic safety: reject paths that escape wiki/ + if (path.startsWith("..") || path.startsWith("/")) continue; + blocks.push({ path, content }); + } + + return blocks; +} + +// ── Public API ───────────────────────────────────────────────────────────────── + +export async function fixLintResult( + projectPath: string, + result: LintResult, + llmConfig: LlmConfig, +): Promise { + switch (result.type) { + case "orphan": + return fixOrphan(projectPath, result); + + case "broken-link": + return fixBrokenLink(projectPath, result, llmConfig); + + case "no-outlinks": + return fixNoOutlinks(projectPath, result, llmConfig); + + case "semantic": { + const subType = getSemanticSubType(result.detail); + switch (subType) { + case "contradiction": + return fixContradiction(projectPath, result, llmConfig); + case "stale": + return fixStale(projectPath, result, llmConfig); + case "missing-page": + return fixMissingPage(projectPath, result, llmConfig); + default: + return { + success: false, + detail: "Cannot auto-fix this suggestion", + filesWritten: [], + }; + } + } + + default: + return { + success: false, + detail: `Unknown type: ${result.type}`, + filesWritten: [], + }; + } +} + +export async function fixAllLintResults( + projectPath: string, + results: readonly LintResult[], + llmConfig: LlmConfig, +): Promise { + const fixable = results.filter(isFixable); + const fixResults: FixResult[] = []; + + for (const result of fixable) { + const fixResult = await fixLintResult(projectPath, result, llmConfig); + fixResults.push(fixResult); + } + + return fixResults; +}