@kibhq/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/compile/backlinks.test.ts +112 -0
- package/src/compile/backlinks.ts +80 -0
- package/src/compile/cache.test.ts +126 -0
- package/src/compile/cache.ts +125 -0
- package/src/compile/compiler.test.ts +278 -0
- package/src/compile/compiler.ts +305 -0
- package/src/compile/diff.test.ts +164 -0
- package/src/compile/diff.ts +121 -0
- package/src/compile/index-manager.test.ts +227 -0
- package/src/compile/index-manager.ts +148 -0
- package/src/compile/prompts.ts +124 -0
- package/src/constants.ts +40 -0
- package/src/errors.ts +66 -0
- package/src/hash.test.ts +21 -0
- package/src/hash.ts +24 -0
- package/src/index.ts +22 -0
- package/src/ingest/extractors/file.test.ts +129 -0
- package/src/ingest/extractors/file.ts +136 -0
- package/src/ingest/extractors/github.test.ts +47 -0
- package/src/ingest/extractors/github.ts +135 -0
- package/src/ingest/extractors/interface.ts +26 -0
- package/src/ingest/extractors/pdf.ts +130 -0
- package/src/ingest/extractors/web.test.ts +242 -0
- package/src/ingest/extractors/web.ts +163 -0
- package/src/ingest/extractors/youtube.test.ts +44 -0
- package/src/ingest/extractors/youtube.ts +166 -0
- package/src/ingest/ingest.test.ts +187 -0
- package/src/ingest/ingest.ts +179 -0
- package/src/ingest/normalize.test.ts +120 -0
- package/src/ingest/normalize.ts +83 -0
- package/src/ingest/router.test.ts +154 -0
- package/src/ingest/router.ts +119 -0
- package/src/lint/lint.test.ts +253 -0
- package/src/lint/lint.ts +43 -0
- package/src/lint/rules.ts +178 -0
- package/src/providers/anthropic.ts +107 -0
- package/src/providers/index.ts +4 -0
- package/src/providers/ollama.ts +101 -0
- package/src/providers/openai.ts +67 -0
- package/src/providers/router.ts +62 -0
- package/src/query/query.test.ts +165 -0
- package/src/query/query.ts +136 -0
- package/src/schemas.ts +193 -0
- package/src/search/engine.test.ts +230 -0
- package/src/search/engine.ts +390 -0
- package/src/skills/loader.ts +163 -0
- package/src/skills/runner.ts +139 -0
- package/src/skills/schema.ts +28 -0
- package/src/skills/skills.test.ts +134 -0
- package/src/types.ts +136 -0
- package/src/vault.test.ts +141 -0
- package/src/vault.ts +251 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { SourceType } from "../types.js";
|
|
2
|
+
|
|
3
|
+
interface NormalizeInput {
|
|
4
|
+
title: string;
|
|
5
|
+
content: string;
|
|
6
|
+
sourceType: SourceType;
|
|
7
|
+
originalUrl?: string;
|
|
8
|
+
metadata?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalize extracted content into a consistent raw markdown format with frontmatter.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeSource(input: NormalizeInput): string {
|
|
15
|
+
const now = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
|
16
|
+
const wordCount = countWords(input.content);
|
|
17
|
+
|
|
18
|
+
const frontmatter = [
|
|
19
|
+
"---",
|
|
20
|
+
`title: "${escapeFrontmatter(input.title)}"`,
|
|
21
|
+
`source_type: ${input.sourceType}`,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
if (input.originalUrl) {
|
|
25
|
+
frontmatter.push(`url: "${input.originalUrl}"`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (input.metadata?.author) {
|
|
29
|
+
frontmatter.push(`author: "${escapeFrontmatter(String(input.metadata.author))}"`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (input.metadata?.date) {
|
|
33
|
+
frontmatter.push(`date: "${input.metadata.date}"`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
frontmatter.push(`ingested: "${now}"`);
|
|
37
|
+
frontmatter.push(`word_count: ${wordCount}`);
|
|
38
|
+
frontmatter.push("---");
|
|
39
|
+
|
|
40
|
+
return `${frontmatter.join("\n")}\n\n${cleanMarkdown(input.content)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a filesystem-safe slug from a title.
|
|
45
|
+
*/
|
|
46
|
+
export function slugify(title: string): string {
|
|
47
|
+
return title
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
50
|
+
.replace(/\s+/g, "-")
|
|
51
|
+
.replace(/-+/g, "-")
|
|
52
|
+
.replace(/^-|-$/g, "")
|
|
53
|
+
.slice(0, 80);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Count words in a string.
|
|
58
|
+
*/
|
|
59
|
+
export function countWords(text: string): number {
|
|
60
|
+
return text
|
|
61
|
+
.replace(/```[\s\S]*?```/g, "") // strip code blocks
|
|
62
|
+
.replace(/`[^`]*`/g, "") // strip inline code
|
|
63
|
+
.replace(/---[\s\S]*?---/g, "") // strip frontmatter
|
|
64
|
+
.replace(/[#*_[\]()>|]/g, " ") // strip markdown syntax
|
|
65
|
+
.split(/\s+/)
|
|
66
|
+
.filter((w) => w.length > 0).length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function escapeFrontmatter(str: string): string {
|
|
70
|
+
return str.replace(/"/g, '\\"').replace(/\n/g, " ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cleanMarkdown(content: string): string {
|
|
74
|
+
return (
|
|
75
|
+
content
|
|
76
|
+
// Normalize line endings
|
|
77
|
+
.replace(/\r\n/g, "\n")
|
|
78
|
+
// Remove excessive blank lines (3+ → 2)
|
|
79
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
80
|
+
// Trim
|
|
81
|
+
.trim()
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { detectSourceType } from "./router.js";
|
|
3
|
+
|
|
4
|
+
describe("detectSourceType", () => {
|
|
5
|
+
describe("web URLs", () => {
|
|
6
|
+
test("generic https URL → web", () => {
|
|
7
|
+
expect(detectSourceType("https://example.com/article")).toBe("web");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("http URL → web", () => {
|
|
11
|
+
expect(detectSourceType("http://blog.example.com/post")).toBe("web");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("URL with query params → web", () => {
|
|
15
|
+
expect(detectSourceType("https://example.com/page?id=123")).toBe("web");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("URL with fragment → web", () => {
|
|
19
|
+
expect(detectSourceType("https://docs.example.com/guide#section")).toBe("web");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("YouTube URLs", () => {
|
|
24
|
+
test("youtube.com/watch → youtube", () => {
|
|
25
|
+
expect(detectSourceType("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("youtube");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("youtu.be short URL → youtube", () => {
|
|
29
|
+
expect(detectSourceType("https://youtu.be/dQw4w9WgXcQ")).toBe("youtube");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("m.youtube.com → youtube", () => {
|
|
33
|
+
expect(detectSourceType("https://m.youtube.com/watch?v=abc123")).toBe("youtube");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("youtube.com without www → youtube", () => {
|
|
37
|
+
expect(detectSourceType("https://youtube.com/watch?v=abc123")).toBe("youtube");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("youtube playlist → youtube", () => {
|
|
41
|
+
expect(detectSourceType("https://www.youtube.com/playlist?list=PLrAXtmErZgOe")).toBe(
|
|
42
|
+
"youtube",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("GitHub URLs", () => {
|
|
48
|
+
test("github.com repo → github", () => {
|
|
49
|
+
expect(detectSourceType("https://github.com/anthropics/claude-code")).toBe("github");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("github.com repo with path → github", () => {
|
|
53
|
+
expect(detectSourceType("https://github.com/anthropics/claude-code/tree/main/src")).toBe(
|
|
54
|
+
"github",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("github.com profile only (1 part) → web", () => {
|
|
59
|
+
expect(detectSourceType("https://github.com/anthropics")).toBe("web");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("github.com root → web", () => {
|
|
63
|
+
expect(detectSourceType("https://github.com")).toBe("web");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("PDF URLs", () => {
|
|
68
|
+
test("URL ending in .pdf → pdf", () => {
|
|
69
|
+
expect(detectSourceType("https://example.com/paper.pdf")).toBe("pdf");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("arxiv PDF URL → pdf", () => {
|
|
73
|
+
expect(detectSourceType("https://arxiv.org/pdf/1706.03762")).toBe("pdf");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("arxiv abstract (not PDF) → web", () => {
|
|
77
|
+
expect(detectSourceType("https://arxiv.org/abs/1706.03762")).toBe("web");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("image URLs", () => {
|
|
82
|
+
test("URL ending in .png → image", () => {
|
|
83
|
+
expect(detectSourceType("https://example.com/diagram.png")).toBe("image");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("URL ending in .jpg → image", () => {
|
|
87
|
+
expect(detectSourceType("https://example.com/photo.jpg")).toBe("image");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("URL ending in .webp → image", () => {
|
|
91
|
+
expect(detectSourceType("https://example.com/hero.webp")).toBe("image");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("local file paths", () => {
|
|
96
|
+
test(".md → file", () => {
|
|
97
|
+
expect(detectSourceType("./notes/paper.md")).toBe("file");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test(".txt → file", () => {
|
|
101
|
+
expect(detectSourceType("/home/user/doc.txt")).toBe("file");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test(".pdf → pdf", () => {
|
|
105
|
+
expect(detectSourceType("./papers/attention.pdf")).toBe("pdf");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test(".png → image", () => {
|
|
109
|
+
expect(detectSourceType("/tmp/whiteboard.png")).toBe("image");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test(".jpg → image", () => {
|
|
113
|
+
expect(detectSourceType("photo.jpg")).toBe("image");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test(".ts → file", () => {
|
|
117
|
+
expect(detectSourceType("./src/index.ts")).toBe("file");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test(".py → file", () => {
|
|
121
|
+
expect(detectSourceType("script.py")).toBe("file");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test(".html → file", () => {
|
|
125
|
+
expect(detectSourceType("page.html")).toBe("file");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test(".json → file", () => {
|
|
129
|
+
expect(detectSourceType("data.json")).toBe("file");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("no extension → file", () => {
|
|
133
|
+
expect(detectSourceType("Makefile")).toBe("file");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("unknown extension → file", () => {
|
|
137
|
+
expect(detectSourceType("data.xyz")).toBe("file");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("edge cases", () => {
|
|
142
|
+
test("trims whitespace", () => {
|
|
143
|
+
expect(detectSourceType(" https://example.com ")).toBe("web");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("case insensitive for file extensions", () => {
|
|
147
|
+
expect(detectSourceType("PAPER.PDF")).toBe("pdf");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("case insensitive for image extensions", () => {
|
|
151
|
+
expect(detectSourceType("PHOTO.PNG")).toBe("image");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { SourceType } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect the source type from a URI string (URL or file path).
|
|
5
|
+
*/
|
|
6
|
+
export function detectSourceType(uri: string): SourceType {
|
|
7
|
+
// Normalize
|
|
8
|
+
const trimmed = uri.trim();
|
|
9
|
+
|
|
10
|
+
// URL-based detection
|
|
11
|
+
if (isUrl(trimmed)) {
|
|
12
|
+
const url = new URL(trimmed);
|
|
13
|
+
const hostname = url.hostname.toLowerCase();
|
|
14
|
+
const pathname = url.pathname.toLowerCase();
|
|
15
|
+
|
|
16
|
+
// YouTube
|
|
17
|
+
if (
|
|
18
|
+
hostname === "youtube.com" ||
|
|
19
|
+
hostname === "www.youtube.com" ||
|
|
20
|
+
hostname === "m.youtube.com" ||
|
|
21
|
+
hostname === "youtu.be"
|
|
22
|
+
) {
|
|
23
|
+
return "youtube";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// GitHub
|
|
27
|
+
if (hostname === "github.com" || hostname === "www.github.com") {
|
|
28
|
+
// Only match repo URLs (owner/repo), not arbitrary github pages
|
|
29
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
30
|
+
if (parts.length >= 2) {
|
|
31
|
+
return "github";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// PDF (URL ending in .pdf or common academic PDF hosts)
|
|
36
|
+
if (pathname.endsWith(".pdf")) {
|
|
37
|
+
return "pdf";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ArXiv — these serve PDFs at /pdf/ paths
|
|
41
|
+
if (hostname === "arxiv.org" && pathname.startsWith("/pdf/")) {
|
|
42
|
+
return "pdf";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Image URLs
|
|
46
|
+
if (isImagePath(pathname)) {
|
|
47
|
+
return "image";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Default: web page
|
|
51
|
+
return "web";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Local file-based detection
|
|
55
|
+
const lower = trimmed.toLowerCase();
|
|
56
|
+
|
|
57
|
+
if (lower.endsWith(".pdf")) {
|
|
58
|
+
return "pdf";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isImagePath(lower)) {
|
|
62
|
+
return "image";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
lower.endsWith(".md") ||
|
|
67
|
+
lower.endsWith(".txt") ||
|
|
68
|
+
lower.endsWith(".rst") ||
|
|
69
|
+
lower.endsWith(".org") ||
|
|
70
|
+
lower.endsWith(".html") ||
|
|
71
|
+
lower.endsWith(".htm") ||
|
|
72
|
+
lower.endsWith(".json") ||
|
|
73
|
+
lower.endsWith(".csv") ||
|
|
74
|
+
lower.endsWith(".xml") ||
|
|
75
|
+
lower.endsWith(".yaml") ||
|
|
76
|
+
lower.endsWith(".yml") ||
|
|
77
|
+
lower.endsWith(".toml")
|
|
78
|
+
) {
|
|
79
|
+
return "file";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Source code files
|
|
83
|
+
if (
|
|
84
|
+
lower.endsWith(".ts") ||
|
|
85
|
+
lower.endsWith(".js") ||
|
|
86
|
+
lower.endsWith(".py") ||
|
|
87
|
+
lower.endsWith(".go") ||
|
|
88
|
+
lower.endsWith(".rs") ||
|
|
89
|
+
lower.endsWith(".java") ||
|
|
90
|
+
lower.endsWith(".c") ||
|
|
91
|
+
lower.endsWith(".cpp") ||
|
|
92
|
+
lower.endsWith(".h") ||
|
|
93
|
+
lower.endsWith(".rb") ||
|
|
94
|
+
lower.endsWith(".sh") ||
|
|
95
|
+
lower.endsWith(".sql")
|
|
96
|
+
) {
|
|
97
|
+
return "file";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// If no extension or unrecognized, treat as file
|
|
101
|
+
return "file";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isUrl(str: string): boolean {
|
|
105
|
+
return str.startsWith("http://") || str.startsWith("https://");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isImagePath(path: string): boolean {
|
|
109
|
+
return (
|
|
110
|
+
path.endsWith(".png") ||
|
|
111
|
+
path.endsWith(".jpg") ||
|
|
112
|
+
path.endsWith(".jpeg") ||
|
|
113
|
+
path.endsWith(".gif") ||
|
|
114
|
+
path.endsWith(".webp") ||
|
|
115
|
+
path.endsWith(".svg") ||
|
|
116
|
+
path.endsWith(".bmp") ||
|
|
117
|
+
path.endsWith(".tiff")
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { writeFile as fsWriteFile, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ingestSource } from "../ingest/ingest.js";
|
|
6
|
+
import type { Manifest } from "../types.js";
|
|
7
|
+
import { initVault, loadManifest, saveManifest, writeWiki } from "../vault.js";
|
|
8
|
+
import { lintVault } from "./lint.js";
|
|
9
|
+
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function makeTempVault() {
|
|
17
|
+
tempDir = await mkdtemp(join(tmpdir(), "kib-lint-test-"));
|
|
18
|
+
await initVault(tempDir, { name: "test" });
|
|
19
|
+
return tempDir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function articleMd(opts: { title: string; slug: string; category: string; body?: string }) {
|
|
23
|
+
return `---
|
|
24
|
+
title: ${opts.title}
|
|
25
|
+
slug: ${opts.slug}
|
|
26
|
+
category: ${opts.category}
|
|
27
|
+
tags: []
|
|
28
|
+
summary: ""
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# ${opts.title}
|
|
32
|
+
|
|
33
|
+
${opts.body ?? "Some content."}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("lint engine", () => {
|
|
37
|
+
test("reports no issues for healthy vault", async () => {
|
|
38
|
+
const root = await makeTempVault();
|
|
39
|
+
|
|
40
|
+
// Create articles with cross-links
|
|
41
|
+
await writeWiki(
|
|
42
|
+
root,
|
|
43
|
+
"concepts/alpha.md",
|
|
44
|
+
articleMd({
|
|
45
|
+
title: "Alpha",
|
|
46
|
+
slug: "alpha",
|
|
47
|
+
category: "concept",
|
|
48
|
+
body: "See [[beta]].",
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
await writeWiki(
|
|
52
|
+
root,
|
|
53
|
+
"concepts/beta.md",
|
|
54
|
+
articleMd({
|
|
55
|
+
title: "Beta",
|
|
56
|
+
slug: "beta",
|
|
57
|
+
category: "concept",
|
|
58
|
+
body: "See [[alpha]].",
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Update manifest to include articles with backlinks
|
|
63
|
+
const manifest = await loadManifest(root);
|
|
64
|
+
manifest.articles["alpha"] = {
|
|
65
|
+
hash: "a",
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
lastUpdated: new Date().toISOString(),
|
|
68
|
+
derivedFrom: [],
|
|
69
|
+
backlinks: ["beta"],
|
|
70
|
+
forwardLinks: ["beta"],
|
|
71
|
+
tags: [],
|
|
72
|
+
summary: "",
|
|
73
|
+
wordCount: 10,
|
|
74
|
+
category: "concept",
|
|
75
|
+
};
|
|
76
|
+
manifest.articles["beta"] = {
|
|
77
|
+
hash: "b",
|
|
78
|
+
createdAt: new Date().toISOString(),
|
|
79
|
+
lastUpdated: new Date().toISOString(),
|
|
80
|
+
derivedFrom: [],
|
|
81
|
+
backlinks: ["alpha"],
|
|
82
|
+
forwardLinks: ["alpha"],
|
|
83
|
+
tags: [],
|
|
84
|
+
summary: "",
|
|
85
|
+
wordCount: 10,
|
|
86
|
+
category: "concept",
|
|
87
|
+
};
|
|
88
|
+
await saveManifest(root, manifest);
|
|
89
|
+
|
|
90
|
+
const result = await lintVault(root);
|
|
91
|
+
expect(result.errors).toBe(0);
|
|
92
|
+
// May have warnings (orphan detection depends on exact backlink setup)
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("detects orphan articles", async () => {
|
|
96
|
+
const root = await makeTempVault();
|
|
97
|
+
|
|
98
|
+
await writeWiki(
|
|
99
|
+
root,
|
|
100
|
+
"concepts/orphan.md",
|
|
101
|
+
articleMd({ title: "Orphan", slug: "orphan", category: "concept" }),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Add to manifest with no backlinks
|
|
105
|
+
const manifest = await loadManifest(root);
|
|
106
|
+
manifest.articles["orphan"] = {
|
|
107
|
+
hash: "o",
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
lastUpdated: new Date().toISOString(),
|
|
110
|
+
derivedFrom: [],
|
|
111
|
+
backlinks: [],
|
|
112
|
+
forwardLinks: [],
|
|
113
|
+
tags: [],
|
|
114
|
+
summary: "",
|
|
115
|
+
wordCount: 10,
|
|
116
|
+
category: "concept",
|
|
117
|
+
};
|
|
118
|
+
await saveManifest(root, manifest);
|
|
119
|
+
|
|
120
|
+
const result = await lintVault(root, { ruleFilter: "orphan" });
|
|
121
|
+
expect(result.warnings).toBeGreaterThan(0);
|
|
122
|
+
expect(result.diagnostics.some((d) => d.rule === "orphan")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("detects broken wikilinks", async () => {
|
|
126
|
+
const root = await makeTempVault();
|
|
127
|
+
|
|
128
|
+
await writeWiki(
|
|
129
|
+
root,
|
|
130
|
+
"concepts/test.md",
|
|
131
|
+
articleMd({
|
|
132
|
+
title: "Test",
|
|
133
|
+
slug: "test",
|
|
134
|
+
category: "concept",
|
|
135
|
+
body: "See [[nonexistent-article]].",
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const manifest = await loadManifest(root);
|
|
140
|
+
manifest.articles["test"] = {
|
|
141
|
+
hash: "t",
|
|
142
|
+
createdAt: new Date().toISOString(),
|
|
143
|
+
lastUpdated: new Date().toISOString(),
|
|
144
|
+
derivedFrom: [],
|
|
145
|
+
backlinks: [],
|
|
146
|
+
forwardLinks: ["nonexistent-article"],
|
|
147
|
+
tags: [],
|
|
148
|
+
summary: "",
|
|
149
|
+
wordCount: 10,
|
|
150
|
+
category: "concept",
|
|
151
|
+
};
|
|
152
|
+
await saveManifest(root, manifest);
|
|
153
|
+
|
|
154
|
+
const result = await lintVault(root, { ruleFilter: "broken-link" });
|
|
155
|
+
expect(result.errors).toBeGreaterThan(0);
|
|
156
|
+
expect(
|
|
157
|
+
result.diagnostics.some(
|
|
158
|
+
(d) => d.rule === "broken-link" && d.message.includes("nonexistent-article"),
|
|
159
|
+
),
|
|
160
|
+
).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("detects stale sources", async () => {
|
|
164
|
+
const root = await makeTempVault();
|
|
165
|
+
|
|
166
|
+
// Ingest a source (it won't be compiled)
|
|
167
|
+
const testFile = join(root, "article.md");
|
|
168
|
+
await fsWriteFile(testFile, "# Test\n\nContent.");
|
|
169
|
+
await ingestSource(root, testFile);
|
|
170
|
+
|
|
171
|
+
const result = await lintVault(root, { ruleFilter: "stale" });
|
|
172
|
+
expect(result.warnings).toBeGreaterThan(0);
|
|
173
|
+
expect(result.diagnostics.some((d) => d.rule === "stale")).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("detects missing frontmatter", async () => {
|
|
177
|
+
const root = await makeTempVault();
|
|
178
|
+
|
|
179
|
+
// Write article without frontmatter
|
|
180
|
+
await writeWiki(root, "concepts/nofm.md", "# No Frontmatter\n\nJust content.");
|
|
181
|
+
|
|
182
|
+
const result = await lintVault(root, { ruleFilter: "frontmatter" });
|
|
183
|
+
expect(result.errors).toBeGreaterThan(0);
|
|
184
|
+
expect(
|
|
185
|
+
result.diagnostics.some(
|
|
186
|
+
(d) => d.rule === "frontmatter" && d.message.includes("Missing YAML"),
|
|
187
|
+
),
|
|
188
|
+
).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("detects missing required frontmatter fields", async () => {
|
|
192
|
+
const root = await makeTempVault();
|
|
193
|
+
|
|
194
|
+
// Write article with partial frontmatter (missing slug)
|
|
195
|
+
await writeWiki(root, "concepts/partial.md", "---\ntitle: Partial\n---\n\nContent.");
|
|
196
|
+
|
|
197
|
+
const result = await lintVault(root, { ruleFilter: "frontmatter" });
|
|
198
|
+
expect(result.errors).toBeGreaterThan(0);
|
|
199
|
+
expect(
|
|
200
|
+
result.diagnostics.some((d) => d.rule === "frontmatter" && d.message.includes("slug")),
|
|
201
|
+
).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("filters by specific rule", async () => {
|
|
205
|
+
const root = await makeTempVault();
|
|
206
|
+
|
|
207
|
+
// Set up conditions that would trigger multiple rules
|
|
208
|
+
await writeWiki(root, "concepts/test.md", "# No Frontmatter");
|
|
209
|
+
|
|
210
|
+
// Ingest but don't compile
|
|
211
|
+
const testFile = join(root, "source.md");
|
|
212
|
+
await fsWriteFile(testFile, "# Source");
|
|
213
|
+
await ingestSource(root, testFile);
|
|
214
|
+
|
|
215
|
+
// Only run frontmatter check
|
|
216
|
+
const result = await lintVault(root, { ruleFilter: "frontmatter" });
|
|
217
|
+
expect(result.diagnostics.every((d) => d.rule === "frontmatter")).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("handles empty vault", async () => {
|
|
221
|
+
const root = await makeTempVault();
|
|
222
|
+
const result = await lintVault(root);
|
|
223
|
+
expect(result.diagnostics).toHaveLength(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("outputs skip orphan for output category", async () => {
|
|
227
|
+
const root = await makeTempVault();
|
|
228
|
+
|
|
229
|
+
await writeWiki(
|
|
230
|
+
root,
|
|
231
|
+
"outputs/query-result.md",
|
|
232
|
+
articleMd({ title: "Query Result", slug: "query-result", category: "output" }),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const manifest = await loadManifest(root);
|
|
236
|
+
manifest.articles["query-result"] = {
|
|
237
|
+
hash: "q",
|
|
238
|
+
createdAt: new Date().toISOString(),
|
|
239
|
+
lastUpdated: new Date().toISOString(),
|
|
240
|
+
derivedFrom: [],
|
|
241
|
+
backlinks: [], // No backlinks — but it's an output, so should NOT be orphan
|
|
242
|
+
forwardLinks: [],
|
|
243
|
+
tags: [],
|
|
244
|
+
summary: "",
|
|
245
|
+
wordCount: 10,
|
|
246
|
+
category: "output",
|
|
247
|
+
};
|
|
248
|
+
await saveManifest(root, manifest);
|
|
249
|
+
|
|
250
|
+
const result = await lintVault(root, { ruleFilter: "orphan" });
|
|
251
|
+
expect(result.diagnostics.filter((d) => d.rule === "orphan")).toHaveLength(0);
|
|
252
|
+
});
|
|
253
|
+
});
|
package/src/lint/lint.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { LintDiagnostic, Manifest } from "../types.js";
|
|
2
|
+
import { loadManifest } from "../vault.js";
|
|
3
|
+
import { ALL_RULES } from "./rules.js";
|
|
4
|
+
|
|
5
|
+
export interface LintOptions {
|
|
6
|
+
/** Run only a specific rule */
|
|
7
|
+
ruleFilter?: string;
|
|
8
|
+
/** Callback for progress updates */
|
|
9
|
+
onProgress?: (msg: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LintResult {
|
|
13
|
+
diagnostics: LintDiagnostic[];
|
|
14
|
+
errors: number;
|
|
15
|
+
warnings: number;
|
|
16
|
+
infos: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run lint checks on the wiki.
|
|
21
|
+
*/
|
|
22
|
+
export async function lintVault(root: string, options: LintOptions = {}): Promise<LintResult> {
|
|
23
|
+
const manifest = await loadManifest(root);
|
|
24
|
+
|
|
25
|
+
const rules = options.ruleFilter
|
|
26
|
+
? ALL_RULES.filter((r) => r.name === options.ruleFilter)
|
|
27
|
+
: ALL_RULES;
|
|
28
|
+
|
|
29
|
+
const allDiagnostics: LintDiagnostic[] = [];
|
|
30
|
+
|
|
31
|
+
for (const rule of rules) {
|
|
32
|
+
options.onProgress?.(`Running ${rule.name} check...`);
|
|
33
|
+
const diagnostics = await rule.fn(root, manifest);
|
|
34
|
+
allDiagnostics.push(...diagnostics);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
diagnostics: allDiagnostics,
|
|
39
|
+
errors: allDiagnostics.filter((d) => d.severity === "error").length,
|
|
40
|
+
warnings: allDiagnostics.filter((d) => d.severity === "warning").length,
|
|
41
|
+
infos: allDiagnostics.filter((d) => d.severity === "info").length,
|
|
42
|
+
};
|
|
43
|
+
}
|