diff --git a/src/lib/lint-fixes.test.ts b/src/lib/lint-fixes.test.ts index e1785dd03..66c81e2a5 100644 --- a/src/lib/lint-fixes.test.ts +++ b/src/lib/lint-fixes.test.ts @@ -12,6 +12,7 @@ import { appendWikilink, ensureBrokenLinkStub, rewriteWikilinkTarget, + stubPageType, stubRelativePathFromBrokenTarget, } from "./lint-fixes" @@ -91,4 +92,62 @@ describe("ensureBrokenLinkStub", () => { it("keeps explicit wiki subdirectories when building stub paths", () => { expect(stubRelativePathFromBrokenTarget("concepts/Foo Bar")).toBe("concepts/foo-bar.md") }) + + it("writes the folder-derived type into a knowledge-folder stub", async () => { + fsMocks.fileExists.mockResolvedValue(false) + + await ensureBrokenLinkStub("/project", "concepts/Foo Bar") + + expect(fsMocks.writeFile).toHaveBeenCalledWith( + "/project/wiki/concepts/foo-bar.md", + expect.stringContaining("type: concept"), + ) + }) + + it("writes the folder-derived type for entities stubs", async () => { + fsMocks.fileExists.mockResolvedValue(false) + + await ensureBrokenLinkStub("/project", "entities/Foo Bar") + + expect(fsMocks.writeFile).toHaveBeenCalledWith( + "/project/wiki/entities/foo-bar.md", + expect.stringContaining("type: entity"), + ) + }) + + it("keeps type: query for single-segment stubs under queries/", async () => { + fsMocks.fileExists.mockResolvedValue(false) + + await ensureBrokenLinkStub("/project", "Foo Bar") + + expect(fsMocks.writeFile).toHaveBeenCalledWith( + "/project/wiki/queries/foo-bar.md", + expect.stringContaining("type: query"), + ) + }) + + it("uses the folder name as type for unrecognized directories", async () => { + fsMocks.fileExists.mockResolvedValue(false) + + await ensureBrokenLinkStub("/project", "standards/Foo Bar") + + expect(fsMocks.writeFile).toHaveBeenCalledWith( + "/project/wiki/standards/foo-bar.md", + expect.stringContaining("type: standards"), + ) + }) +}) + +describe("stubPageType", () => { + it("derives the type from a known knowledge folder", () => { + expect(stubPageType("concepts/foo-bar.md")).toBe("concept") + }) + + it("keeps query for single-segment stubs under queries/", () => { + expect(stubPageType("queries/foo-bar.md")).toBe("query") + }) + + it("falls back to the folder name for unrecognized directories", () => { + expect(stubPageType("standards/foo-bar.md")).toBe("standards") + }) }) diff --git a/src/lib/lint-fixes.ts b/src/lib/lint-fixes.ts index f10228d61..8f6887169 100644 --- a/src/lib/lint-fixes.ts +++ b/src/lib/lint-fixes.ts @@ -1,6 +1,7 @@ import { createDirectory, fileExists, writeFile } from "@/commands/fs" import { getFileName, normalizePath } from "@/lib/path-utils" import { makeQuerySlug } from "@/lib/wiki-filename" +import { inferWikiTypeFromPath } from "@/lib/wiki-page-types" export function lintLinkTarget(target: string): string { return normalizePath(target) @@ -65,6 +66,14 @@ function stubTitleFromBrokenTarget(brokenTarget: string): string { .trim() || "Missing Page" } +/** Derive the stub's frontmatter type from its destination folder so pages + * written into knowledge folders stay visible in the graph and pass schema + * routing. Only single-segment targets land in `queries/`, where `query` + * remains correct. */ +export function stubPageType(relativePath: string): string { + return inferWikiTypeFromPath(`wiki/${relativePath}`) ?? "query" +} + export async function ensureBrokenLinkStub( projectPath: string, brokenTarget: string, @@ -81,7 +90,7 @@ export async function ensureBrokenLinkStub( const date = new Date().toISOString().slice(0, 10) const content = [ "---", - "type: query", + `type: ${stubPageType(relativePath)}`, `title: "${title.replace(/"/g, '\\"')}"`, `created: ${date}`, `updated: ${date}`, diff --git a/src/lib/sweep-reviews-build-wiki-index.test.ts b/src/lib/sweep-reviews-build-wiki-index.test.ts index c6adcbe60..1c5c6dc85 100644 --- a/src/lib/sweep-reviews-build-wiki-index.test.ts +++ b/src/lib/sweep-reviews-build-wiki-index.test.ts @@ -26,6 +26,10 @@ async function loadBuildWikiIndex() { return mod.buildWikiIndex } +async function loadSweepModule() { + return import("./sweep-reviews") +} + function mdFile(name: string): FileNode { return { name, path: `/project/wiki/${name}`, is_dir: false } } @@ -41,6 +45,7 @@ describe("buildWikiIndex", () => { const index = await buildWikiIndex("/project") expect(index.byTitle.has("attention mechanism")).toBe(false) + expect(index.byTitleSlug.has("attention-mechanism")).toBe(false) expect(index.pages[0].title).toBeNull() }) @@ -53,4 +58,36 @@ describe("buildWikiIndex", () => { expect(index.byTitle.has("real title")).toBe(true) }) + + it("indexes the frontmatter title's slug for timestamped filenames", async () => { + const buildWikiIndex = await loadBuildWikiIndex() + mockListDirectory.mockResolvedValue([mdFile("clash-detection-2026-09-06-143052.md")]) + mockReadFile.mockResolvedValue("---\ntitle: Clash Detection\n---\n# Clash Detection\n") + + const index = await buildWikiIndex("/project") + + expect(index.byTitleSlug.has("clash-detection")).toBe(true) + }) +}) + +describe("pageExists", () => { + it("resolves a candidate name against a timestamped page via its title slug", async () => { + const { buildWikiIndex, pageExists } = await loadSweepModule() + mockListDirectory.mockResolvedValue([mdFile("clash-detection-2026-09-06-143052.md")]) + mockReadFile.mockResolvedValue("---\ntitle: Clash Detection\n---\n# Clash Detection\n") + + const index = await buildWikiIndex("/project") + + expect(pageExists("clash-detection", index)).toBe(true) + }) + + it("does not match unrelated candidate names via the slug index", async () => { + const { buildWikiIndex, pageExists } = await loadSweepModule() + mockListDirectory.mockResolvedValue([mdFile("clash-detection-2026-09-06-143052.md")]) + mockReadFile.mockResolvedValue("---\ntitle: Clash Detection\n---\n# Clash Detection\n") + + const index = await buildWikiIndex("/project") + + expect(pageExists("completely-unrelated", index)).toBe(false) + }) }) diff --git a/src/lib/sweep-reviews.ts b/src/lib/sweep-reviews.ts index 4279504e8..d5c4c12e4 100644 --- a/src/lib/sweep-reviews.ts +++ b/src/lib/sweep-reviews.ts @@ -18,6 +18,7 @@ import { streamChat } from "@/lib/llm-client" import type { FileNode } from "@/types/wiki" import { normalizePath } from "@/lib/path-utils" import { normalizeReviewTitle } from "@/lib/review-utils" +import { makeQuerySlug } from "@/lib/wiki-filename" import { hasUsableLlm } from "@/lib/has-usable-llm" import { getTaskLlmConfig } from "@/lib/llm-task-routing" import { parseFrontmatter } from "@/lib/frontmatter" @@ -32,6 +33,7 @@ interface WikiPageSummary { interface WikiIndex { byId: Set byTitle: Set + byTitleSlug: Set pages: WikiPageSummary[] } @@ -54,6 +56,7 @@ export async function buildWikiIndex(projectPath: string): Promise { const pp = normalizePath(projectPath) const byId = new Set() const byTitle = new Set() + const byTitleSlug = new Set() const pages: WikiPageSummary[] = [] try { @@ -71,6 +74,14 @@ export async function buildWikiIndex(projectPath: string): Promise { if (typeof fmTitle === "string" && fmTitle.trim()) { title = fmTitle.trim() byTitle.add(title.toLowerCase()) + // Timestamped filenames ("clash-detection-2026-09-06-143052") can + // never match a candidate's bare name, so also index the title's + // slug. Skip titles with no letters/digits: makeQuerySlug falls + // back to "query" for those, which would let unrelated garbage + // candidates collide with every such page. + if (/[\p{L}\p{N}]/u.test(title)) { + byTitleSlug.add(makeQuerySlug(title)) + } } } catch { // skip unreadable files @@ -82,7 +93,7 @@ export async function buildWikiIndex(projectPath: string): Promise { // no wiki directory yet } - return { byId, byTitle, pages } + return { byId, byTitle, byTitleSlug, pages } } /** @@ -108,8 +119,12 @@ function extractCandidateNames(item: ReviewItem): string[] { return Array.from(names) } -/** Check if a candidate name matches an existing wiki page */ -function pageExists(name: string, index: WikiIndex): boolean { +/** + * Check if a candidate name matches an existing wiki page. + * + * @internal Exported for unit tests only. + */ +export function pageExists(name: string, index: WikiIndex): boolean { const normalized = name.trim().toLowerCase() if (!normalized) return false @@ -120,6 +135,11 @@ function pageExists(name: string, index: WikiIndex): boolean { // Exact title match (from frontmatter) if (index.byTitle.has(normalized)) return true + // Slug of the frontmatter title — catches pages whose filename carries a + // timestamp suffix ("clash-detection-2026-09-06-143052") so a candidate + // named after the missing page still resolves. + if (index.byTitleSlug.has(makeQuerySlug(normalized))) return true + return false }