@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.
Files changed (53) hide show
  1. package/package.json +40 -0
  2. package/src/compile/backlinks.test.ts +112 -0
  3. package/src/compile/backlinks.ts +80 -0
  4. package/src/compile/cache.test.ts +126 -0
  5. package/src/compile/cache.ts +125 -0
  6. package/src/compile/compiler.test.ts +278 -0
  7. package/src/compile/compiler.ts +305 -0
  8. package/src/compile/diff.test.ts +164 -0
  9. package/src/compile/diff.ts +121 -0
  10. package/src/compile/index-manager.test.ts +227 -0
  11. package/src/compile/index-manager.ts +148 -0
  12. package/src/compile/prompts.ts +124 -0
  13. package/src/constants.ts +40 -0
  14. package/src/errors.ts +66 -0
  15. package/src/hash.test.ts +21 -0
  16. package/src/hash.ts +24 -0
  17. package/src/index.ts +22 -0
  18. package/src/ingest/extractors/file.test.ts +129 -0
  19. package/src/ingest/extractors/file.ts +136 -0
  20. package/src/ingest/extractors/github.test.ts +47 -0
  21. package/src/ingest/extractors/github.ts +135 -0
  22. package/src/ingest/extractors/interface.ts +26 -0
  23. package/src/ingest/extractors/pdf.ts +130 -0
  24. package/src/ingest/extractors/web.test.ts +242 -0
  25. package/src/ingest/extractors/web.ts +163 -0
  26. package/src/ingest/extractors/youtube.test.ts +44 -0
  27. package/src/ingest/extractors/youtube.ts +166 -0
  28. package/src/ingest/ingest.test.ts +187 -0
  29. package/src/ingest/ingest.ts +179 -0
  30. package/src/ingest/normalize.test.ts +120 -0
  31. package/src/ingest/normalize.ts +83 -0
  32. package/src/ingest/router.test.ts +154 -0
  33. package/src/ingest/router.ts +119 -0
  34. package/src/lint/lint.test.ts +253 -0
  35. package/src/lint/lint.ts +43 -0
  36. package/src/lint/rules.ts +178 -0
  37. package/src/providers/anthropic.ts +107 -0
  38. package/src/providers/index.ts +4 -0
  39. package/src/providers/ollama.ts +101 -0
  40. package/src/providers/openai.ts +67 -0
  41. package/src/providers/router.ts +62 -0
  42. package/src/query/query.test.ts +165 -0
  43. package/src/query/query.ts +136 -0
  44. package/src/schemas.ts +193 -0
  45. package/src/search/engine.test.ts +230 -0
  46. package/src/search/engine.ts +390 -0
  47. package/src/skills/loader.ts +163 -0
  48. package/src/skills/runner.ts +139 -0
  49. package/src/skills/schema.ts +28 -0
  50. package/src/skills/skills.test.ts +134 -0
  51. package/src/types.ts +136 -0
  52. package/src/vault.test.ts +141 -0
  53. 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
+ });
@@ -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
+ }