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
59 changes: 59 additions & 0 deletions src/lib/lint-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
appendWikilink,
ensureBrokenLinkStub,
rewriteWikilinkTarget,
stubPageType,
stubRelativePathFromBrokenTarget,
} from "./lint-fixes"

Expand Down Expand Up @@ -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")
})
})
11 changes: 10 additions & 1 deletion src/lib/lint-fixes.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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}`,
Expand Down
37 changes: 37 additions & 0 deletions src/lib/sweep-reviews-build-wiki-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand All @@ -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()
})

Expand All @@ -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)
})
})
26 changes: 23 additions & 3 deletions src/lib/sweep-reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,6 +33,7 @@ interface WikiPageSummary {
interface WikiIndex {
byId: Set<string>
byTitle: Set<string>
byTitleSlug: Set<string>
pages: WikiPageSummary[]
}

Expand All @@ -54,6 +56,7 @@ export async function buildWikiIndex(projectPath: string): Promise<WikiIndex> {
const pp = normalizePath(projectPath)
const byId = new Set<string>()
const byTitle = new Set<string>()
const byTitleSlug = new Set<string>()
const pages: WikiPageSummary[] = []

try {
Expand All @@ -71,6 +74,14 @@ export async function buildWikiIndex(projectPath: string): Promise<WikiIndex> {
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
Expand All @@ -82,7 +93,7 @@ export async function buildWikiIndex(projectPath: string): Promise<WikiIndex> {
// no wiki directory yet
}

return { byId, byTitle, pages }
return { byId, byTitle, byTitleSlug, pages }
}

/**
Expand All @@ -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

Expand All @@ -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
}

Expand Down