claude-local-docs 1.0.13 → 1.0.15

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 (60) hide show
  1. package/.mcp.json +2 -1
  2. package/README.md +124 -58
  3. package/commands/fetch-docs.md +54 -28
  4. package/commands/index-codebase.md +53 -0
  5. package/dist/code-indexer.d.ts +14 -0
  6. package/dist/code-indexer.js +519 -0
  7. package/dist/code-indexer.js.map +1 -0
  8. package/dist/code-search.d.ts +14 -0
  9. package/dist/code-search.js +155 -0
  10. package/dist/code-search.js.map +1 -0
  11. package/dist/code-store.d.ts +39 -0
  12. package/dist/code-store.js +206 -0
  13. package/dist/code-store.js.map +1 -0
  14. package/dist/code.test.d.ts +7 -0
  15. package/dist/code.test.js +197 -0
  16. package/dist/code.test.js.map +1 -0
  17. package/dist/discovery.js +56 -4
  18. package/dist/discovery.js.map +1 -1
  19. package/dist/docs.test.d.ts +7 -0
  20. package/dist/docs.test.js +105 -0
  21. package/dist/docs.test.js.map +1 -0
  22. package/dist/file-walker.d.ts +34 -0
  23. package/dist/file-walker.js +199 -0
  24. package/dist/file-walker.js.map +1 -0
  25. package/dist/index.js +321 -22
  26. package/dist/index.js.map +1 -1
  27. package/dist/indexer.js +4 -23
  28. package/dist/indexer.js.map +1 -1
  29. package/dist/integration.test.d.ts +3 -2
  30. package/dist/integration.test.js +461 -11
  31. package/dist/integration.test.js.map +1 -1
  32. package/dist/reranker.d.ts +2 -2
  33. package/dist/reranker.js +10 -12
  34. package/dist/reranker.js.map +1 -1
  35. package/dist/rrf.d.ts +17 -0
  36. package/dist/rrf.js +25 -0
  37. package/dist/rrf.js.map +1 -0
  38. package/dist/search.d.ts +2 -0
  39. package/dist/search.js +30 -52
  40. package/dist/search.js.map +1 -1
  41. package/dist/sfc-extractor.d.ts +14 -0
  42. package/dist/sfc-extractor.js +70 -0
  43. package/dist/sfc-extractor.js.map +1 -0
  44. package/dist/store.d.ts +2 -0
  45. package/dist/store.js +39 -24
  46. package/dist/store.js.map +1 -1
  47. package/dist/tei-client.d.ts +70 -0
  48. package/dist/tei-client.js +153 -0
  49. package/dist/tei-client.js.map +1 -0
  50. package/dist/types.d.ts +49 -0
  51. package/dist/types.js +4 -1
  52. package/dist/types.js.map +1 -1
  53. package/dist/unit.test.d.ts +8 -0
  54. package/dist/unit.test.js +1241 -0
  55. package/dist/unit.test.js.map +1 -0
  56. package/docker-compose.nvidia.yml +7 -0
  57. package/docker-compose.yml +9 -0
  58. package/package.json +8 -2
  59. package/scripts/ensure-tei.sh +93 -19
  60. package/start-tei.sh +17 -3
@@ -0,0 +1,1241 @@
1
+ /**
2
+ * Unit tests — no TEI containers needed.
3
+ * Tests: RRF fusion, file-walker, code-indexer AST chunking, markdown chunking,
4
+ * SFC extraction, JSDoc/decorator/flags, TeiClient, git changes, utilities.
5
+ *
6
+ * Run: npm run test:unit
7
+ */
8
+ import { describe, it, before, after } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // Shared utilities
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+ import { sqlEscapeString } from "./types.js";
17
+ describe("sqlEscapeString", () => {
18
+ it("escapes single quotes", () => {
19
+ assert.equal(sqlEscapeString("it's"), "it''s");
20
+ assert.equal(sqlEscapeString("he said 'hello'"), "he said ''hello''");
21
+ });
22
+ it("returns unchanged strings without quotes", () => {
23
+ assert.equal(sqlEscapeString("hello world"), "hello world");
24
+ assert.equal(sqlEscapeString("src/index.ts"), "src/index.ts");
25
+ });
26
+ it("handles empty string", () => {
27
+ assert.equal(sqlEscapeString(""), "");
28
+ });
29
+ it("handles multiple consecutive quotes", () => {
30
+ assert.equal(sqlEscapeString("'''"), "''''''");
31
+ });
32
+ });
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // truncateAndNormalize
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+ import { truncateAndNormalize, TeiClient, checkAllTeiHealth } from "./tei-client.js";
37
+ describe("truncateAndNormalize", () => {
38
+ it("truncates vectors to specified dimension", () => {
39
+ const vecs = [[1, 2, 3, 4, 5, 6]];
40
+ const result = truncateAndNormalize(vecs, 3);
41
+ assert.equal(result[0].length, 3);
42
+ });
43
+ it("L2-normalizes the truncated vector", () => {
44
+ const vecs = [[3, 4, 0, 0, 0]]; // 3-4-5 triangle
45
+ const result = truncateAndNormalize(vecs, 2);
46
+ // After truncation to [3, 4], norm = 5, normalized = [0.6, 0.8]
47
+ assert.ok(Math.abs(result[0][0] - 0.6) < 1e-6);
48
+ assert.ok(Math.abs(result[0][1] - 0.8) < 1e-6);
49
+ });
50
+ it("handles zero vector without division by zero", () => {
51
+ const vecs = [[0, 0, 0, 0]];
52
+ const result = truncateAndNormalize(vecs, 2);
53
+ assert.deepEqual(result[0], [0, 0]);
54
+ });
55
+ it("processes multiple vectors", () => {
56
+ const vecs = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]];
57
+ const result = truncateAndNormalize(vecs, 2);
58
+ assert.equal(result.length, 3);
59
+ // [1, 0] normalized is still [1, 0]
60
+ assert.ok(Math.abs(result[0][0] - 1.0) < 1e-6);
61
+ assert.ok(Math.abs(result[0][1] - 0.0) < 1e-6);
62
+ });
63
+ it("handles dim larger than vector length (no-op truncation)", () => {
64
+ const vecs = [[1, 0]];
65
+ const result = truncateAndNormalize(vecs, 100);
66
+ assert.equal(result[0].length, 2); // slice(0, 100) of 2-element array = 2
67
+ });
68
+ it("produces unit-length vectors", () => {
69
+ const vecs = [[7, 11, 13, 17, 19]];
70
+ const result = truncateAndNormalize(vecs, 3);
71
+ const norm = Math.sqrt(result[0].reduce((s, v) => s + v * v, 0));
72
+ assert.ok(Math.abs(norm - 1.0) < 1e-6, `Expected unit norm, got ${norm}`);
73
+ });
74
+ });
75
+ // ═══════════════════════════════════════════════════════════════════════════
76
+ // RRF Fusion
77
+ // ═══════════════════════════════════════════════════════════════════════════
78
+ import { reciprocalRankFusion } from "./rrf.js";
79
+ describe("reciprocalRankFusion", () => {
80
+ it("merges two ranked lists with default k=60", () => {
81
+ const list1 = [
82
+ { id: 1, text: "alpha" },
83
+ { id: 2, text: "beta" },
84
+ { id: 3, text: "gamma" },
85
+ ];
86
+ const list2 = [
87
+ { id: 2, text: "beta" },
88
+ { id: 4, text: "delta" },
89
+ { id: 1, text: "alpha" },
90
+ ];
91
+ const fused = reciprocalRankFusion([
92
+ { docs: list1, weight: 1.0 },
93
+ { docs: list2, weight: 1.0 },
94
+ ]);
95
+ assert.ok(fused.length >= 4);
96
+ assert.ok(fused[0].rrfScore > 0);
97
+ // IDs 1 and 2 appear in both lists — should rank highest
98
+ const topIds = new Set(fused.slice(0, 2).map(f => f.id));
99
+ assert.ok(topIds.has(1) || topIds.has(2), "Expected overlapping docs to rank highly");
100
+ });
101
+ it("respects weights (higher weight = more influence)", () => {
102
+ const list1 = [{ id: 10, text: "only-in-list1" }];
103
+ const list2 = [{ id: 20, text: "only-in-list2" }];
104
+ const fused = reciprocalRankFusion([
105
+ { docs: list1, weight: 10.0 },
106
+ { docs: list2, weight: 1.0 },
107
+ ]);
108
+ assert.equal(fused[0].id, 10, "Higher-weighted list should dominate");
109
+ });
110
+ it("handles empty lists", () => {
111
+ const fused = reciprocalRankFusion([
112
+ { docs: [], weight: 1.0 },
113
+ { docs: [], weight: 1.0 },
114
+ ]);
115
+ assert.equal(fused.length, 0);
116
+ });
117
+ it("handles single list", () => {
118
+ const list = [
119
+ { id: 1, text: "alpha" },
120
+ { id: 2, text: "beta" },
121
+ ];
122
+ const fused = reciprocalRankFusion([{ docs: list, weight: 1.0 }]);
123
+ assert.equal(fused.length, 2);
124
+ assert.ok(fused[0].rrfScore > fused[1].rrfScore);
125
+ });
126
+ it("passes through extra fields on RankedDoc", () => {
127
+ const list = [{ id: 1, text: "t", filePath: "src/a.ts", language: "typescript" }];
128
+ const fused = reciprocalRankFusion([{ docs: list, weight: 1.0 }]);
129
+ assert.equal(fused[0].filePath, "src/a.ts");
130
+ assert.equal(fused[0].language, "typescript");
131
+ });
132
+ it("produces results sorted by rrfScore descending", () => {
133
+ const docs = Array.from({ length: 20 }, (_, i) => ({ id: i, text: `doc-${i}` }));
134
+ const fused = reciprocalRankFusion([{ docs, weight: 1.0 }]);
135
+ for (let i = 1; i < fused.length; i++) {
136
+ assert.ok(fused[i - 1].rrfScore >= fused[i].rrfScore, `Not sorted at index ${i}`);
137
+ }
138
+ });
139
+ it("handles 3+ lists (N-way fusion)", () => {
140
+ const list1 = [{ id: 1, text: "a" }, { id: 2, text: "b" }];
141
+ const list2 = [{ id: 2, text: "b" }, { id: 3, text: "c" }];
142
+ const list3 = [{ id: 3, text: "c" }, { id: 1, text: "a" }];
143
+ const fused = reciprocalRankFusion([
144
+ { docs: list1, weight: 1.0 },
145
+ { docs: list2, weight: 1.0 },
146
+ { docs: list3, weight: 1.0 },
147
+ ]);
148
+ assert.equal(fused.length, 3);
149
+ // All 3 docs appear in exactly 2 lists; scores should be close
150
+ assert.ok(fused[0].rrfScore > 0);
151
+ });
152
+ it("accumulates scores for duplicate IDs across lists", () => {
153
+ const list1 = [{ id: 1, text: "a" }]; // rank 0 → score = 1/(60+1)
154
+ const list2 = [{ id: 1, text: "a" }]; // rank 0 → score = 1/(60+1)
155
+ const fusedSingle = reciprocalRankFusion([{ docs: list1, weight: 1.0 }]);
156
+ const fusedDouble = reciprocalRankFusion([
157
+ { docs: list1, weight: 1.0 },
158
+ { docs: list2, weight: 1.0 },
159
+ ]);
160
+ // Score from two lists should be ~2x score from one list
161
+ assert.ok(fusedDouble[0].rrfScore > fusedSingle[0].rrfScore * 1.9);
162
+ });
163
+ it("uses custom k parameter", () => {
164
+ const list = [{ id: 1, text: "a" }];
165
+ const fused1 = reciprocalRankFusion([{ docs: list, weight: 1.0 }], 1);
166
+ const fused60 = reciprocalRankFusion([{ docs: list, weight: 1.0 }], 60);
167
+ // k=1: score = 1/(1+1) = 0.5; k=60: score = 1/(60+1) ≈ 0.0164
168
+ assert.ok(fused1[0].rrfScore > fused60[0].rrfScore);
169
+ });
170
+ });
171
+ // ═══════════════════════════════════════════════════════════════════════════
172
+ // Markdown chunking (indexer.ts)
173
+ // ═══════════════════════════════════════════════════════════════════════════
174
+ import { chunkMarkdown } from "./indexer.js";
175
+ describe("chunkMarkdown", () => {
176
+ it("creates a single chunk for small documents", () => {
177
+ const chunks = chunkMarkdown("Hello world\n\nSome content.", "test-lib");
178
+ assert.equal(chunks.length, 1);
179
+ assert.equal(chunks[0].library, "test-lib");
180
+ assert.ok(chunks[0].text.includes("Hello world"));
181
+ });
182
+ it("splits by headings", () => {
183
+ const md = `# Introduction
184
+
185
+ First section content.
186
+
187
+ # API
188
+
189
+ Second section content.
190
+
191
+ # Examples
192
+
193
+ Third section content.`;
194
+ const chunks = chunkMarkdown(md, "my-lib");
195
+ assert.ok(chunks.length >= 3, `Expected >= 3 chunks, got ${chunks.length}`);
196
+ });
197
+ it("tracks heading hierarchy in headingPath", () => {
198
+ const md = `# Getting Started
199
+
200
+ ## Installation
201
+
202
+ Install with npm.
203
+
204
+ ## Configuration
205
+
206
+ Configure it.
207
+
208
+ # API
209
+
210
+ ## Methods
211
+
212
+ Some methods.`;
213
+ const chunks = chunkMarkdown(md, "my-lib");
214
+ const installChunk = chunks.find(c => c.text.includes("Install with npm"));
215
+ assert.ok(installChunk, "Should find installation chunk");
216
+ const path = JSON.parse(installChunk.headingPath);
217
+ assert.deepEqual(path, ["Getting Started", "Installation"]);
218
+ });
219
+ it("prepends heading path as context prefix", () => {
220
+ const md = `# Guide
221
+
222
+ ## Setup
223
+
224
+ Do the setup.`;
225
+ const chunks = chunkMarkdown(md, "my-lib");
226
+ const setupChunk = chunks.find(c => c.text.includes("Do the setup"));
227
+ assert.ok(setupChunk, "Should find setup chunk");
228
+ assert.ok(setupChunk.text.startsWith("[Guide > Setup]"), "Should have heading prefix");
229
+ });
230
+ it("pops heading stack on same or deeper level", () => {
231
+ const md = `# A
232
+
233
+ ## B
234
+
235
+ Content B.
236
+
237
+ ## C
238
+
239
+ Content C.`;
240
+ const chunks = chunkMarkdown(md, "lib");
241
+ const chunkC = chunks.find(c => c.text.includes("Content C"));
242
+ assert.ok(chunkC);
243
+ const path = JSON.parse(chunkC.headingPath);
244
+ // "C" should replace "B" (both level 2), not nest under it
245
+ assert.deepEqual(path, ["A", "C"]);
246
+ });
247
+ it("never splits inside code fences", () => {
248
+ // Create content with a code fence that would span a split boundary
249
+ const longCode = "x = 1\n".repeat(200);
250
+ const md = `# Code Example
251
+
252
+ \`\`\`python
253
+ ${longCode}
254
+ \`\`\`
255
+
256
+ More text after code.`;
257
+ const chunks = chunkMarkdown(md, "lib");
258
+ // Verify no chunk starts or ends mid-fence
259
+ for (const chunk of chunks) {
260
+ const backtickCount = (chunk.text.match(/```/g) || []).length;
261
+ // If a chunk has a code fence opener, it should also have a closer (even count)
262
+ if (backtickCount > 0) {
263
+ assert.equal(backtickCount % 2, 0, "Code fence should not be split across chunks");
264
+ }
265
+ }
266
+ });
267
+ it("handles empty markdown", () => {
268
+ const chunks = chunkMarkdown("", "lib");
269
+ assert.equal(chunks.length, 0);
270
+ });
271
+ it("handles whitespace-only markdown", () => {
272
+ const chunks = chunkMarkdown(" \n\n \n", "lib");
273
+ assert.equal(chunks.length, 0);
274
+ });
275
+ it("handles markdown with no headings", () => {
276
+ const chunks = chunkMarkdown("Just some plain text.\n\nAnother paragraph.", "lib");
277
+ assert.equal(chunks.length, 1);
278
+ const path = JSON.parse(chunks[0].headingPath);
279
+ assert.deepEqual(path, []);
280
+ // No heading prefix when headingPath is empty
281
+ assert.ok(!chunks[0].text.startsWith("["));
282
+ });
283
+ it("handles tilde code fences", () => {
284
+ const md = `# Section
285
+
286
+ ~~~typescript
287
+ const x = 1;
288
+ const y = 2;
289
+ ~~~
290
+
291
+ After fence.`;
292
+ const chunks = chunkMarkdown(md, "lib");
293
+ const chunk = chunks.find(c => c.text.includes("const x = 1"));
294
+ assert.ok(chunk, "Should include tilde-fenced code");
295
+ assert.ok(chunk.text.includes("const y = 2"), "Code block should not be split");
296
+ });
297
+ });
298
+ // ═══════════════════════════════════════════════════════════════════════════
299
+ // File Walker
300
+ // ═══════════════════════════════════════════════════════════════════════════
301
+ import { walkProjectFiles, computeFileHash, computeContentHash, detectLanguage, getGitChangedFiles } from "./file-walker.js";
302
+ describe("detectLanguage", () => {
303
+ it("detects TypeScript files", () => {
304
+ assert.equal(detectLanguage("foo.ts"), "typescript");
305
+ assert.equal(detectLanguage("bar.tsx"), "typescript");
306
+ assert.equal(detectLanguage("baz.mts"), "typescript");
307
+ assert.equal(detectLanguage("qux.cts"), "typescript");
308
+ });
309
+ it("detects JavaScript files", () => {
310
+ assert.equal(detectLanguage("foo.js"), "javascript");
311
+ assert.equal(detectLanguage("bar.jsx"), "javascript");
312
+ assert.equal(detectLanguage("baz.mjs"), "javascript");
313
+ assert.equal(detectLanguage("qux.cjs"), "javascript");
314
+ });
315
+ it("detects .d.ts as typescript", () => {
316
+ assert.equal(detectLanguage("types.d.ts"), "typescript");
317
+ assert.equal(detectLanguage("global.d.ts"), "typescript");
318
+ });
319
+ it("detects SFC framework files", () => {
320
+ assert.equal(detectLanguage("App.vue"), "vue");
321
+ assert.equal(detectLanguage("Counter.svelte"), "svelte");
322
+ assert.equal(detectLanguage("Layout.astro"), "astro");
323
+ });
324
+ it("returns null for unsupported extensions", () => {
325
+ assert.equal(detectLanguage("foo.py"), null);
326
+ assert.equal(detectLanguage("bar.rs"), null);
327
+ assert.equal(detectLanguage("README.md"), null);
328
+ });
329
+ it("is case-insensitive for extensions", () => {
330
+ assert.equal(detectLanguage("foo.TS"), "typescript");
331
+ assert.equal(detectLanguage("bar.Vue"), "vue");
332
+ assert.equal(detectLanguage("baz.JSX"), "javascript");
333
+ });
334
+ it("handles paths with directories", () => {
335
+ assert.equal(detectLanguage("src/components/App.tsx"), "typescript");
336
+ assert.equal(detectLanguage("pages/index.vue"), "vue");
337
+ });
338
+ });
339
+ describe("computeContentHash", () => {
340
+ it("returns consistent SHA-256 for same content", () => {
341
+ const hash1 = computeContentHash("hello");
342
+ const hash2 = computeContentHash("hello");
343
+ assert.equal(hash1, hash2);
344
+ assert.equal(hash1.length, 64);
345
+ assert.match(hash1, /^[a-f0-9]+$/);
346
+ });
347
+ it("returns different hash for different content", () => {
348
+ const hash1 = computeContentHash("hello");
349
+ const hash2 = computeContentHash("world");
350
+ assert.notEqual(hash1, hash2);
351
+ });
352
+ it("accepts Buffer input", () => {
353
+ const hash = computeContentHash(Buffer.from("hello"));
354
+ assert.equal(hash.length, 64);
355
+ // Should match the string version
356
+ assert.equal(hash, computeContentHash("hello"));
357
+ });
358
+ it("handles empty string", () => {
359
+ const hash = computeContentHash("");
360
+ assert.equal(hash.length, 64);
361
+ });
362
+ });
363
+ describe("walkProjectFiles", () => {
364
+ let tempDir;
365
+ before(async () => {
366
+ tempDir = await mkdtemp(join(tmpdir(), "file-walker-test-"));
367
+ await mkdir(join(tempDir, "src"), { recursive: true });
368
+ await mkdir(join(tempDir, "node_modules", "pkg"), { recursive: true });
369
+ await mkdir(join(tempDir, "dist"), { recursive: true });
370
+ await mkdir(join(tempDir, "lib"), { recursive: true });
371
+ await mkdir(join(tempDir, "components"), { recursive: true });
372
+ await writeFile(join(tempDir, "src", "index.ts"), "export const hello = 42;");
373
+ await writeFile(join(tempDir, "src", "utils.js"), "module.exports = {};");
374
+ await writeFile(join(tempDir, "src", "README.md"), "# Docs");
375
+ await writeFile(join(tempDir, "node_modules", "pkg", "index.js"), "module.exports = {};");
376
+ await writeFile(join(tempDir, "dist", "index.js"), "var hello = 42;");
377
+ await writeFile(join(tempDir, "lib", "helper.ts"), "export function help() {}");
378
+ await writeFile(join(tempDir, "components", "App.vue"), '<script lang="ts">\nexport default {};\n</script>');
379
+ await writeFile(join(tempDir, "components", "Counter.svelte"), '<script>\nlet count = 0;\n</script>');
380
+ await writeFile(join(tempDir, "package.json"), "{}");
381
+ });
382
+ after(async () => {
383
+ await rm(tempDir, { recursive: true, force: true });
384
+ });
385
+ it("finds JS/TS files and skips node_modules and dist", async () => {
386
+ const files = await walkProjectFiles({ projectRoot: tempDir });
387
+ const paths = files.map(f => f.relativePath).sort();
388
+ assert.ok(paths.includes("src/index.ts"), "Should find src/index.ts");
389
+ assert.ok(paths.includes("src/utils.js"), "Should find src/utils.js");
390
+ assert.ok(paths.includes("lib/helper.ts"), "Should find lib/helper.ts");
391
+ assert.ok(!paths.some(p => p.includes("node_modules")), "Should skip node_modules");
392
+ assert.ok(!paths.some(p => p.startsWith("dist/")), "Should skip dist");
393
+ assert.ok(!paths.some(p => p.endsWith(".md")), "Should skip non-JS/TS files");
394
+ });
395
+ it("finds Vue and Svelte SFC files", async () => {
396
+ const files = await walkProjectFiles({ projectRoot: tempDir });
397
+ const paths = files.map(f => f.relativePath);
398
+ assert.ok(paths.includes("components/App.vue"), "Should find .vue files");
399
+ assert.ok(paths.includes("components/Counter.svelte"), "Should find .svelte files");
400
+ });
401
+ it("respects excludePaths", async () => {
402
+ const files = await walkProjectFiles({
403
+ projectRoot: tempDir,
404
+ excludePaths: ["lib/**"],
405
+ });
406
+ const paths = files.map(f => f.relativePath);
407
+ assert.ok(!paths.includes("lib/helper.ts"), "Should exclude lib/ via pattern");
408
+ assert.ok(paths.includes("src/index.ts"), "Should still include src/");
409
+ });
410
+ it("respects includePaths", async () => {
411
+ const files = await walkProjectFiles({
412
+ projectRoot: tempDir,
413
+ includePaths: ["src/**"],
414
+ });
415
+ const paths = files.map(f => f.relativePath);
416
+ assert.ok(paths.includes("src/index.ts"), "Should include src/ files");
417
+ assert.ok(!paths.includes("lib/helper.ts"), "Should exclude non-src files");
418
+ assert.ok(!paths.includes("components/App.vue"), "Should exclude non-src files");
419
+ });
420
+ it("detects correct language per file", async () => {
421
+ const files = await walkProjectFiles({ projectRoot: tempDir });
422
+ const tsFile = files.find(f => f.relativePath === "src/index.ts");
423
+ const jsFile = files.find(f => f.relativePath === "src/utils.js");
424
+ const vueFile = files.find(f => f.relativePath === "components/App.vue");
425
+ assert.equal(tsFile?.language, "typescript");
426
+ assert.equal(jsFile?.language, "javascript");
427
+ assert.equal(vueFile?.language, "vue");
428
+ });
429
+ it("skips empty files", async () => {
430
+ await writeFile(join(tempDir, "src", "empty.ts"), "");
431
+ const files = await walkProjectFiles({ projectRoot: tempDir });
432
+ assert.ok(!files.some(f => f.relativePath === "src/empty.ts"), "Should skip empty files");
433
+ });
434
+ it("includes sizeBytes for each file", async () => {
435
+ const files = await walkProjectFiles({ projectRoot: tempDir });
436
+ for (const f of files) {
437
+ assert.equal(typeof f.sizeBytes, "number");
438
+ assert.ok(f.sizeBytes > 0, `sizeBytes should be > 0 for ${f.relativePath}`);
439
+ }
440
+ });
441
+ it("walks this project's own src/ directory", async () => {
442
+ const projectRoot = join(import.meta.dirname, "..");
443
+ const files = await walkProjectFiles({ projectRoot, includePaths: ["src/**"] });
444
+ const paths = files.map(f => f.relativePath);
445
+ assert.ok(files.length >= 10, `Expected >=10 src files, got ${files.length}`);
446
+ assert.ok(paths.some(p => p === "src/index.ts"), "Should find src/index.ts");
447
+ assert.ok(paths.some(p => p === "src/store.ts"), "Should find src/store.ts");
448
+ assert.ok(paths.some(p => p === "src/code-indexer.ts"), "Should find src/code-indexer.ts");
449
+ assert.ok(paths.some(p => p === "src/rrf.ts"), "Should find src/rrf.ts");
450
+ assert.ok(paths.every(f => f.startsWith("src/")), "All should be under src/");
451
+ });
452
+ });
453
+ describe("computeFileHash", () => {
454
+ let tempDir;
455
+ before(async () => {
456
+ tempDir = await mkdtemp(join(tmpdir(), "hash-test-"));
457
+ });
458
+ after(async () => {
459
+ await rm(tempDir, { recursive: true, force: true });
460
+ });
461
+ it("returns consistent SHA-256 hash", async () => {
462
+ const filePath = join(tempDir, "test.ts");
463
+ await writeFile(filePath, "const x = 42;");
464
+ const hash1 = await computeFileHash(filePath);
465
+ const hash2 = await computeFileHash(filePath);
466
+ assert.equal(hash1, hash2);
467
+ assert.equal(hash1.length, 64, "SHA-256 hex is 64 chars");
468
+ assert.match(hash1, /^[a-f0-9]+$/);
469
+ });
470
+ it("returns different hash for different content", async () => {
471
+ const file1 = join(tempDir, "a.ts");
472
+ const file2 = join(tempDir, "b.ts");
473
+ await writeFile(file1, "const a = 1;");
474
+ await writeFile(file2, "const b = 2;");
475
+ const hash1 = await computeFileHash(file1);
476
+ const hash2 = await computeFileHash(file2);
477
+ assert.notEqual(hash1, hash2);
478
+ });
479
+ it("matches computeContentHash for same content", async () => {
480
+ const content = "export const x = 42;";
481
+ const filePath = join(tempDir, "match.ts");
482
+ await writeFile(filePath, content);
483
+ const fileHash = await computeFileHash(filePath);
484
+ const contentHash = computeContentHash(content);
485
+ assert.equal(fileHash, contentHash);
486
+ });
487
+ });
488
+ // --- Git Changes ---
489
+ describe("getGitChangedFiles", () => {
490
+ it("returns isGitRepo=false for non-git directory", async () => {
491
+ const tempDir = await mkdtemp(join(tmpdir(), "no-git-test-"));
492
+ try {
493
+ const result = await getGitChangedFiles(tempDir);
494
+ assert.equal(result.isGitRepo, false);
495
+ assert.equal(result.lastCommit, "");
496
+ assert.equal(result.modified.length, 0);
497
+ }
498
+ finally {
499
+ await rm(tempDir, { recursive: true, force: true });
500
+ }
501
+ });
502
+ it("returns isGitRepo=true for this project", async () => {
503
+ const projectRoot = join(import.meta.dirname, "..");
504
+ const result = await getGitChangedFiles(projectRoot);
505
+ assert.equal(result.isGitRepo, true);
506
+ assert.ok(result.lastCommit.length > 0, "Should have a HEAD commit");
507
+ assert.match(result.lastCommit, /^[a-f0-9]{40}$/, "HEAD should be a 40-char hex SHA");
508
+ });
509
+ it("returns arrays for modified, added, deleted", async () => {
510
+ const projectRoot = join(import.meta.dirname, "..");
511
+ const result = await getGitChangedFiles(projectRoot);
512
+ assert.ok(Array.isArray(result.modified));
513
+ assert.ok(Array.isArray(result.added));
514
+ assert.ok(Array.isArray(result.deleted));
515
+ });
516
+ it("detects working tree changes in this project", async () => {
517
+ const projectRoot = join(import.meta.dirname, "..");
518
+ const result = await getGitChangedFiles(projectRoot);
519
+ // This project has uncommitted changes (we're editing it right now)
520
+ const allChanges = result.modified.length + result.added.length + result.deleted.length;
521
+ assert.ok(allChanges > 0, "Should detect uncommitted changes in this project");
522
+ });
523
+ });
524
+ // ═══════════════════════════════════════════════════════════════════════════
525
+ // Code Indexer (AST chunking, no embedding)
526
+ // ═══════════════════════════════════════════════════════════════════════════
527
+ import { chunkCodeFile } from "./code-indexer.js";
528
+ describe("chunkCodeFile", { timeout: 30_000 }, () => {
529
+ it("extracts functions from TypeScript", async () => {
530
+ // Functions must be >100 non-ws chars each to avoid merging
531
+ const pad = "x".repeat(80);
532
+ const source = `
533
+ export function greet(name: string): string {
534
+ const message = "${pad}";
535
+ return "Hello, " + name + message;
536
+ }
537
+
538
+ export function farewell(name: string): string {
539
+ const message = "${pad}";
540
+ return "Goodbye, " + name + message;
541
+ }
542
+ `.trim();
543
+ const chunks = await chunkCodeFile(source, "src/greet.ts", "typescript");
544
+ assert.ok(chunks.length >= 2, `Expected >=2 chunks, got ${chunks.length}`);
545
+ const greetChunk = chunks.find(c => c.entityName === "greet");
546
+ assert.ok(greetChunk, "Should find 'greet' entity");
547
+ assert.equal(greetChunk.entityType, "function");
548
+ assert.equal(greetChunk.filePath, "src/greet.ts");
549
+ assert.equal(greetChunk.language, "typescript");
550
+ assert.ok(greetChunk.text.includes("// File: src/greet.ts"), "Should have contextual header");
551
+ assert.ok(greetChunk.text.includes("Hello"), "Should contain function body");
552
+ });
553
+ it("extracts classes and splits large ones into methods", async () => {
554
+ // Each method needs substantial body so total class > 1500 non-ws chars
555
+ const longBody = ` const data = "${Array(500).fill("x").join("")}";\n return data;`;
556
+ const source = `
557
+ export class UserService {
558
+ private db: any;
559
+
560
+ constructor(db: any) {
561
+ this.db = db;
562
+ }
563
+
564
+ async findUser(id: string): Promise<any> {
565
+ ${longBody}
566
+ }
567
+
568
+ async createUser(data: any): Promise<any> {
569
+ ${longBody}
570
+ }
571
+
572
+ async deleteUser(id: string): Promise<void> {
573
+ ${longBody}
574
+ }
575
+ }
576
+ `.trim();
577
+ const chunks = await chunkCodeFile(source, "src/user.ts", "typescript");
578
+ const classChunk = chunks.find(c => c.entityType === "class");
579
+ assert.ok(classChunk, "Should extract class");
580
+ assert.equal(classChunk.entityName, "UserService");
581
+ const methodChunks = chunks.filter(c => c.entityType === "method");
582
+ assert.ok(methodChunks.length >= 2, `Expected >=2 methods, got ${methodChunks.length}`);
583
+ for (const mc of methodChunks) {
584
+ const scope = JSON.parse(mc.scopeChain);
585
+ assert.ok(scope.includes("UserService"), `Method scope should include class name, got ${mc.scopeChain}`);
586
+ }
587
+ });
588
+ it("extracts arrow functions in const declarations", async () => {
589
+ // Make each function >100 non-ws chars to prevent merging
590
+ const pad = "x".repeat(80);
591
+ const source = `
592
+ export const add = (a: number, b: number): number => {
593
+ const label = "${pad}";
594
+ return a + b;
595
+ };
596
+
597
+ export const subtract = (a: number, b: number): number => {
598
+ const label = "${pad}";
599
+ return a - b;
600
+ };
601
+ `.trim();
602
+ const chunks = await chunkCodeFile(source, "src/math.ts", "typescript");
603
+ const addChunk = chunks.find(c => c.entityName === "add");
604
+ assert.ok(addChunk, "Should find 'add' arrow function");
605
+ assert.equal(addChunk.entityType, "function");
606
+ });
607
+ it("extracts interfaces and type aliases", async () => {
608
+ // Make each entity >100 non-ws chars to prevent merging
609
+ const source = `
610
+ export interface User {
611
+ id: string;
612
+ name: string;
613
+ email: string;
614
+ createdAt: Date;
615
+ updatedAt: Date;
616
+ deletedAt: Date | null;
617
+ avatarUrl: string;
618
+ bio: string;
619
+ location: string;
620
+ website: string;
621
+ }
622
+
623
+ export type UserRole = "admin" | "user" | "guest" | "moderator" | "superadmin" | "editor" | "viewer" | "contributor";
624
+ `.trim();
625
+ const chunks = await chunkCodeFile(source, "src/types.ts", "typescript");
626
+ const iface = chunks.find(c => c.entityType === "interface");
627
+ assert.ok(iface, "Should find interface");
628
+ assert.equal(iface.entityName, "User");
629
+ const typeAlias = chunks.find(c => c.entityType === "type_alias");
630
+ assert.ok(typeAlias, "Should find type alias");
631
+ assert.equal(typeAlias.entityName, "UserRole");
632
+ });
633
+ it("extracts enums", async () => {
634
+ const source = `
635
+ export enum Direction {
636
+ Up = "UP",
637
+ Down = "DOWN",
638
+ Left = "LEFT",
639
+ Right = "RIGHT",
640
+ }
641
+ `.trim();
642
+ const chunks = await chunkCodeFile(source, "src/enums.ts", "typescript");
643
+ const enumChunk = chunks.find(c => c.entityType === "enum");
644
+ assert.ok(enumChunk, "Should find enum");
645
+ assert.equal(enumChunk.entityName, "Direction");
646
+ });
647
+ it("produces contextual headers with file path and camelCase-split name", async () => {
648
+ const source = `
649
+ export function validateEmailAddress(email: string): boolean {
650
+ return email.includes("@") && email.includes(".");
651
+ }
652
+ `.trim();
653
+ const chunks = await chunkCodeFile(source, "src/validators.ts", "typescript");
654
+ const chunk = chunks[0];
655
+ assert.ok(chunk.text.includes("// File: src/validators.ts"), "Header should include file path");
656
+ assert.ok(chunk.text.includes("validate email address") || chunk.text.includes("validate Email Address"), "Header should include camelCase-split name for BM25");
657
+ });
658
+ it("merges small entities (<100 non-ws chars)", async () => {
659
+ const source = `
660
+ import { foo } from "foo";
661
+ import { bar } from "bar";
662
+ import { baz } from "baz";
663
+
664
+ export const VERSION = "1.0.0";
665
+ `.trim();
666
+ const chunks = await chunkCodeFile(source, "src/index.ts", "typescript");
667
+ assert.ok(chunks.length <= 3, `Expected small entities to be merged, got ${chunks.length} chunks`);
668
+ });
669
+ it("handles JavaScript files", async () => {
670
+ const source = `
671
+ function hello() {
672
+ console.log("hello world");
673
+ }
674
+
675
+ module.exports = { hello };
676
+ `.trim();
677
+ const chunks = await chunkCodeFile(source, "lib/hello.js", "javascript");
678
+ assert.ok(chunks.length >= 1);
679
+ const fnChunk = chunks.find(c => c.entityName === "hello");
680
+ assert.ok(fnChunk, "Should extract JS function");
681
+ assert.equal(fnChunk.language, "javascript");
682
+ });
683
+ it("creates module-level chunk for bare code files", async () => {
684
+ const source = `
685
+ // This is a configuration file
686
+ console.log("bootstrap");
687
+ `.trim();
688
+ const chunks = await chunkCodeFile(source, "src/bootstrap.ts", "typescript");
689
+ assert.equal(chunks.length, 1);
690
+ assert.equal(chunks[0].entityType, "module");
691
+ assert.equal(chunks[0].entityName, "");
692
+ });
693
+ it("has 1-based line numbers", async () => {
694
+ const source = `
695
+ export function first() {
696
+ return 1;
697
+ }
698
+
699
+ export function second() {
700
+ return 2;
701
+ }
702
+ `.trim();
703
+ const chunks = await chunkCodeFile(source, "src/lines.ts", "typescript");
704
+ for (const c of chunks) {
705
+ assert.ok(c.lineStart >= 1, `lineStart should be >= 1, got ${c.lineStart}`);
706
+ assert.ok(c.lineEnd >= c.lineStart, `lineEnd should be >= lineStart`);
707
+ }
708
+ });
709
+ it("applies lineOffset parameter", async () => {
710
+ const source = `export function hello(): void { const x = "padding padding padding padding padding padding padding"; }`;
711
+ const withoutOffset = await chunkCodeFile(source, "src/a.ts", "typescript", 0);
712
+ const withOffset = await chunkCodeFile(source, "src/a.ts", "typescript", 10);
713
+ assert.equal(withOffset[0].lineStart, withoutOffset[0].lineStart + 10);
714
+ assert.equal(withOffset[0].lineEnd, withoutOffset[0].lineEnd + 10);
715
+ });
716
+ it("includes signature field", async () => {
717
+ const pad = "x".repeat(80);
718
+ const source = `
719
+ export function fetchData(url: string, options?: RequestInit): Promise<Response> {
720
+ const label = "${pad}";
721
+ return fetch(url, options);
722
+ }
723
+ `.trim();
724
+ const chunks = await chunkCodeFile(source, "src/fetch.ts", "typescript");
725
+ const chunk = chunks.find(c => c.entityName === "fetchData");
726
+ assert.ok(chunk);
727
+ assert.ok(chunk.signature.includes("fetchData"), "Signature should contain function name");
728
+ assert.ok(chunk.signature.includes("url: string"), "Signature should contain parameters");
729
+ assert.ok(!chunk.signature.includes("{"), "Signature should not contain function body");
730
+ });
731
+ it("chunks this project's own store.ts correctly", async () => {
732
+ const source = await readFile(join(import.meta.dirname, "..", "src", "store.ts"), "utf-8");
733
+ const chunks = await chunkCodeFile(source, "src/store.ts", "typescript");
734
+ assert.ok(chunks.length >= 5, `Expected >=5 chunks from store.ts, got ${chunks.length}`);
735
+ // Should find the DocStore class
736
+ const classChunk = chunks.find(c => c.entityName === "DocStore");
737
+ assert.ok(classChunk, "Should extract DocStore class");
738
+ assert.equal(classChunk.entityType, "class");
739
+ // Should find resolveProjectRoot function
740
+ const fnChunk = chunks.find(c => c.entityName === "resolveProjectRoot");
741
+ assert.ok(fnChunk, "Should extract resolveProjectRoot function");
742
+ console.log(` store.ts → ${chunks.length} chunks: ${chunks.map(c => `${c.entityType}:${c.entityName}`).join(", ")}`);
743
+ });
744
+ it("chunks this project's own rrf.ts correctly", async () => {
745
+ const source = await readFile(join(import.meta.dirname, "..", "src", "rrf.ts"), "utf-8");
746
+ const chunks = await chunkCodeFile(source, "src/rrf.ts", "typescript");
747
+ assert.ok(chunks.length >= 1, `Expected >=1 chunk from rrf.ts, got ${chunks.length}`);
748
+ const rrfChunk = chunks.find(c => c.entityName === "reciprocalRankFusion");
749
+ assert.ok(rrfChunk, "Should extract reciprocalRankFusion function");
750
+ assert.equal(rrfChunk.entityType, "function");
751
+ assert.ok(rrfChunk.text.includes("rrfScore"), "Should contain rrfScore in body");
752
+ console.log(` rrf.ts → ${chunks.length} chunks: ${chunks.map(c => `${c.entityType}:${c.entityName}`).join(", ")}`);
753
+ });
754
+ });
755
+ // ═══════════════════════════════════════════════════════════════════════════
756
+ // Enhanced AST: JSDoc, Decorators, Flags (Phase 2)
757
+ // ═══════════════════════════════════════════════════════════════════════════
758
+ describe("Enhanced AST: JSDoc extraction", { timeout: 30_000 }, () => {
759
+ it("extracts JSDoc and prepends to chunk text", async () => {
760
+ const source = `
761
+ /** Validates a JWT token and attaches the user to the request. */
762
+ export async function validateToken(req: Request): Promise<boolean> {
763
+ const token = req.headers.get("Authorization");
764
+ const isValid = token !== null && token.startsWith("Bearer ");
765
+ return isValid;
766
+ }
767
+ `.trim();
768
+ const chunks = await chunkCodeFile(source, "src/auth.ts", "typescript");
769
+ const chunk = chunks.find(c => c.entityName === "validateToken");
770
+ assert.ok(chunk, "Should find validateToken");
771
+ assert.ok(chunk.jsdoc.includes("Validates a JWT token"), "jsdoc field should contain the comment text");
772
+ assert.ok(chunk.text.includes("Validates a JWT token"), "Chunk text should include JSDoc for embedding visibility");
773
+ });
774
+ it("returns empty jsdoc when no JSDoc present", async () => {
775
+ const pad = "x".repeat(80);
776
+ const source = `
777
+ // Regular comment, not JSDoc
778
+ export function noJsDoc(): void {
779
+ const data = "${pad}";
780
+ console.log(data);
781
+ }
782
+ `.trim();
783
+ const chunks = await chunkCodeFile(source, "src/test.ts", "typescript");
784
+ const chunk = chunks.find(c => c.entityName === "noJsDoc");
785
+ assert.ok(chunk, "Should find noJsDoc");
786
+ assert.equal(chunk.jsdoc, "", "jsdoc should be empty for non-JSDoc comments");
787
+ });
788
+ it("extracts multi-line JSDoc", async () => {
789
+ const source = `
790
+ /**
791
+ * Fetches user data from the remote API.
792
+ * Handles authentication and retry logic.
793
+ * @param userId - The unique user identifier
794
+ * @returns The user data or null if not found
795
+ */
796
+ export async function fetchUser(userId: string): Promise<any | null> {
797
+ const result = await fetch("/api/users/" + userId);
798
+ return result.ok ? result.json() : null;
799
+ }
800
+ `.trim();
801
+ const chunks = await chunkCodeFile(source, "src/api.ts", "typescript");
802
+ const chunk = chunks.find(c => c.entityName === "fetchUser");
803
+ assert.ok(chunk);
804
+ assert.ok(chunk.jsdoc.includes("Fetches user data"), "Should extract first line");
805
+ assert.ok(chunk.jsdoc.includes("@param userId"), "Should extract @param tag");
806
+ assert.ok(chunk.jsdoc.includes("@returns"), "Should extract @returns tag");
807
+ });
808
+ it("extracts JSDoc on exported classes", async () => {
809
+ const source = `
810
+ /** Manages database connections and pooling. */
811
+ export class DatabaseManager {
812
+ private pool: any;
813
+
814
+ constructor() {
815
+ this.pool = null;
816
+ }
817
+
818
+ /** Connect to the database. */
819
+ async connect(url: string): Promise<void> {
820
+ this.pool = await createPool(url);
821
+ }
822
+ }
823
+ `.trim();
824
+ const chunks = await chunkCodeFile(source, "src/db.ts", "typescript");
825
+ const classChunk = chunks.find(c => c.entityName === "DatabaseManager");
826
+ assert.ok(classChunk);
827
+ assert.ok(classChunk.jsdoc.includes("Manages database connections"), "Class should have JSDoc");
828
+ });
829
+ });
830
+ describe("Enhanced AST: metadata flags", { timeout: 30_000 }, () => {
831
+ it("detects exported, async, and abstract flags", async () => {
832
+ const pad = "x".repeat(80);
833
+ const source = `
834
+ export async function fetchData(url: string): Promise<string> {
835
+ const result = "${pad}";
836
+ return result;
837
+ }
838
+ `.trim();
839
+ const chunks = await chunkCodeFile(source, "src/fetch.ts", "typescript");
840
+ const chunk = chunks.find(c => c.entityName === "fetchData");
841
+ assert.ok(chunk, "Should find fetchData");
842
+ assert.equal(chunk.isExported, true, "Should be exported");
843
+ assert.equal(chunk.isAsync, true, "Should be async");
844
+ assert.equal(chunk.isAbstract, false, "Should not be abstract");
845
+ });
846
+ it("detects non-exported functions", async () => {
847
+ const pad = "x".repeat(80);
848
+ const source = `
849
+ function internalHelper(x: number): number {
850
+ const data = "${pad}";
851
+ return x * 2;
852
+ }
853
+ `.trim();
854
+ const chunks = await chunkCodeFile(source, "src/helper.ts", "typescript");
855
+ const chunk = chunks.find(c => c.entityName === "internalHelper");
856
+ assert.ok(chunk);
857
+ assert.equal(chunk.isExported, false, "Should not be exported");
858
+ assert.equal(chunk.isAsync, false, "Should not be async");
859
+ });
860
+ it("includes Flags line in context header when flags are set", async () => {
861
+ const pad = "x".repeat(80);
862
+ const source = `
863
+ export async function doWork(): Promise<void> {
864
+ const data = "${pad}";
865
+ console.log(data);
866
+ }
867
+ `.trim();
868
+ const chunks = await chunkCodeFile(source, "src/work.ts", "typescript");
869
+ const chunk = chunks.find(c => c.entityName === "doWork");
870
+ assert.ok(chunk, "Should find doWork");
871
+ assert.ok(chunk.text.includes("// Flags: exported, async"), "Should have Flags header line");
872
+ });
873
+ it("omits Flags line when no flags are set", async () => {
874
+ const pad = "x".repeat(80);
875
+ const source = `
876
+ function plain(): void {
877
+ const data = "${pad}";
878
+ console.log(data);
879
+ }
880
+ `.trim();
881
+ const chunks = await chunkCodeFile(source, "src/plain.ts", "typescript");
882
+ const chunk = chunks.find(c => c.entityName === "plain");
883
+ assert.ok(chunk);
884
+ assert.ok(!chunk.text.includes("// Flags:"), "Should not have Flags line when no flags set");
885
+ });
886
+ it("has correct CodeRow new fields structure", async () => {
887
+ const pad = "x".repeat(80);
888
+ const source = `
889
+ export function sample(): void {
890
+ const data = "${pad}";
891
+ console.log(data);
892
+ }
893
+ `.trim();
894
+ const chunks = await chunkCodeFile(source, "src/sample.ts", "typescript");
895
+ const chunk = chunks[0];
896
+ // Verify new CodeRow fields exist
897
+ assert.equal(typeof chunk.jsdoc, "string");
898
+ assert.equal(typeof chunk.decorators, "string");
899
+ assert.equal(typeof chunk.isExported, "boolean");
900
+ assert.equal(typeof chunk.isAsync, "boolean");
901
+ assert.equal(typeof chunk.isAbstract, "boolean");
902
+ // decorators should be JSON array
903
+ const decs = JSON.parse(chunk.decorators);
904
+ assert.ok(Array.isArray(decs));
905
+ });
906
+ it("stores scopeChain as JSON string", async () => {
907
+ const pad = "x".repeat(80);
908
+ const source = `
909
+ export function topLevel(): void {
910
+ const data = "${pad}";
911
+ console.log(data);
912
+ }
913
+ `.trim();
914
+ const chunks = await chunkCodeFile(source, "src/scope.ts", "typescript");
915
+ const chunk = chunks[0];
916
+ const parsed = JSON.parse(chunk.scopeChain);
917
+ assert.ok(Array.isArray(parsed), "scopeChain should be parseable JSON array");
918
+ });
919
+ });
920
+ // ═══════════════════════════════════════════════════════════════════════════
921
+ // SFC Extraction (Phase 3)
922
+ // ═══════════════════════════════════════════════════════════════════════════
923
+ import { extractScriptBlocks } from "./sfc-extractor.js";
924
+ describe("extractScriptBlocks", () => {
925
+ it("extracts Vue <script lang='ts'> block", () => {
926
+ const source = `<template>
927
+ <div>Hello</div>
928
+ </template>
929
+
930
+ <script lang="ts">
931
+ import { defineComponent } from "vue";
932
+ export default defineComponent({ name: "Hello" });
933
+ </script>
934
+ `;
935
+ const blocks = extractScriptBlocks(source, ".vue");
936
+ assert.equal(blocks.length, 1);
937
+ assert.equal(blocks[0].language, "typescript");
938
+ assert.ok(blocks[0].scriptContent.includes("defineComponent"));
939
+ assert.ok(blocks[0].lineOffset > 0, "lineOffset should account for template lines");
940
+ });
941
+ it("extracts Vue <script setup> block", () => {
942
+ const source = `<template>
943
+ <div>{{ msg }}</div>
944
+ </template>
945
+
946
+ <script setup lang="ts">
947
+ const msg = "hello";
948
+ </script>
949
+ `;
950
+ const blocks = extractScriptBlocks(source, ".vue");
951
+ assert.equal(blocks.length, 1);
952
+ assert.equal(blocks[0].language, "typescript");
953
+ assert.ok(blocks[0].scriptContent.includes("const msg"));
954
+ });
955
+ it("extracts both <script> and <script setup> from Vue", () => {
956
+ const source = `<script lang="ts">
957
+ export default { name: "Dual" };
958
+ </script>
959
+
960
+ <script setup lang="ts">
961
+ const x = 1;
962
+ </script>
963
+
964
+ <template><div /></template>
965
+ `;
966
+ const blocks = extractScriptBlocks(source, ".vue");
967
+ assert.equal(blocks.length, 2, "Should extract both script blocks");
968
+ });
969
+ it("extracts Svelte <script context='module'> block", () => {
970
+ const source = `<script context="module" lang="ts">
971
+ export const prerender = true;
972
+ </script>
973
+
974
+ <script lang="ts">
975
+ let count = 0;
976
+ </script>
977
+
978
+ <div>{count}</div>
979
+ `;
980
+ const blocks = extractScriptBlocks(source, ".svelte");
981
+ assert.equal(blocks.length, 2);
982
+ assert.ok(blocks.some(b => b.scriptContent.includes("prerender")));
983
+ assert.ok(blocks.some(b => b.scriptContent.includes("count")));
984
+ });
985
+ it("extracts Astro --- frontmatter", () => {
986
+ const source = `---
987
+ import Layout from "../layouts/Main.astro";
988
+ const title = "Hello";
989
+ ---
990
+
991
+ <Layout title={title}>
992
+ <h1>Welcome</h1>
993
+ </Layout>
994
+ `;
995
+ const blocks = extractScriptBlocks(source, ".astro");
996
+ assert.ok(blocks.length >= 1, "Should extract frontmatter");
997
+ assert.equal(blocks[0].language, "typescript");
998
+ assert.ok(blocks[0].scriptContent.includes("const title"));
999
+ assert.equal(blocks[0].lineOffset, 1, "Frontmatter starts after opening ---");
1000
+ });
1001
+ it("extracts Astro frontmatter AND script tags", () => {
1002
+ const source = `---
1003
+ const title = "Hello";
1004
+ ---
1005
+
1006
+ <h1>{title}</h1>
1007
+
1008
+ <script>
1009
+ console.log("client-side");
1010
+ </script>
1011
+ `;
1012
+ const blocks = extractScriptBlocks(source, ".astro");
1013
+ assert.equal(blocks.length, 2, "Should extract both frontmatter and script tag");
1014
+ assert.ok(blocks[0].scriptContent.includes("const title"), "First block is frontmatter");
1015
+ assert.ok(blocks[1].scriptContent.includes("console.log"), "Second block is script tag");
1016
+ });
1017
+ it("returns empty for template-only Vue file", () => {
1018
+ const source = `<template><div>Static</div></template>`;
1019
+ const blocks = extractScriptBlocks(source, ".vue");
1020
+ assert.equal(blocks.length, 0);
1021
+ });
1022
+ it("returns empty for unsupported file extension", () => {
1023
+ const blocks = extractScriptBlocks("<script>x</script>", ".html");
1024
+ assert.equal(blocks.length, 0);
1025
+ });
1026
+ it("calculates correct line offsets", () => {
1027
+ const source = `<template>
1028
+ <div>Line 1</div>
1029
+ <div>Line 2</div>
1030
+ <div>Line 3</div>
1031
+ </template>
1032
+
1033
+ <script lang="ts">
1034
+ const x = 1;
1035
+ </script>
1036
+ `;
1037
+ const blocks = extractScriptBlocks(source, ".vue");
1038
+ assert.equal(blocks.length, 1);
1039
+ // Script tag starts after 7 lines (template + blank line + script tag)
1040
+ assert.ok(blocks[0].lineOffset >= 6, `Expected lineOffset >= 6, got ${blocks[0].lineOffset}`);
1041
+ });
1042
+ it("defaults to javascript when no lang attribute", () => {
1043
+ const source = `<script>
1044
+ export default { data() { return {} } };
1045
+ </script>
1046
+ `;
1047
+ const blocks = extractScriptBlocks(source, ".vue");
1048
+ assert.equal(blocks.length, 1);
1049
+ assert.equal(blocks[0].language, "javascript");
1050
+ });
1051
+ it("skips empty script blocks", () => {
1052
+ const source = `<script lang="ts">
1053
+ </script>
1054
+ `;
1055
+ const blocks = extractScriptBlocks(source, ".vue");
1056
+ assert.equal(blocks.length, 0, "Should skip script blocks with only whitespace");
1057
+ });
1058
+ it("handles lang='typescript' (long form)", () => {
1059
+ const source = `<script lang="typescript">
1060
+ const x: number = 1;
1061
+ </script>
1062
+ `;
1063
+ const blocks = extractScriptBlocks(source, ".vue");
1064
+ assert.equal(blocks.length, 1);
1065
+ assert.equal(blocks[0].language, "typescript");
1066
+ });
1067
+ it("handles Svelte with no lang (defaults to javascript)", () => {
1068
+ const source = `<script>
1069
+ let count = 0;
1070
+ function increment() { count += 1; }
1071
+ </script>
1072
+
1073
+ <button on:click={increment}>{count}</button>
1074
+ `;
1075
+ const blocks = extractScriptBlocks(source, ".svelte");
1076
+ assert.equal(blocks.length, 1);
1077
+ assert.equal(blocks[0].language, "javascript");
1078
+ });
1079
+ });
1080
+ describe("SFC + chunkCodeFile integration", { timeout: 30_000 }, () => {
1081
+ it("extracts entities from Vue SFC script block", async () => {
1082
+ const source = `<template>
1083
+ <div>{{ greeting }}</div>
1084
+ </template>
1085
+
1086
+ <script lang="ts">
1087
+ import { defineComponent, ref } from "vue";
1088
+
1089
+ export default defineComponent({
1090
+ name: "Greeting",
1091
+ setup() {
1092
+ const greeting = ref("Hello, World!");
1093
+ return { greeting };
1094
+ }
1095
+ });
1096
+ </script>
1097
+ `;
1098
+ // Use chunkCodeFile with the extracted script content
1099
+ const { extractScriptBlocks: extract } = await import("./sfc-extractor.js");
1100
+ const blocks = extract(source, ".vue");
1101
+ assert.ok(blocks.length > 0);
1102
+ const chunks = await chunkCodeFile(blocks[0].scriptContent, "src/Greeting.vue", blocks[0].language, blocks[0].lineOffset);
1103
+ assert.ok(chunks.length >= 1);
1104
+ // Line numbers should be offset to match position in original .vue file
1105
+ for (const c of chunks) {
1106
+ assert.ok(c.lineStart > blocks[0].lineOffset, `lineStart ${c.lineStart} should be > offset ${blocks[0].lineOffset}`);
1107
+ }
1108
+ });
1109
+ it("handles Astro frontmatter with TypeScript", async () => {
1110
+ const source = `---
1111
+ interface Props {
1112
+ title: string;
1113
+ description: string;
1114
+ }
1115
+
1116
+ const { title, description } = Astro.props;
1117
+ ---
1118
+
1119
+ <html>
1120
+ <head><title>{title}</title></head>
1121
+ <body><p>{description}</p></body>
1122
+ </html>
1123
+ `;
1124
+ const { extractScriptBlocks: extract } = await import("./sfc-extractor.js");
1125
+ const blocks = extract(source, ".astro");
1126
+ assert.ok(blocks.length >= 1);
1127
+ assert.equal(blocks[0].language, "typescript");
1128
+ const chunks = await chunkCodeFile(blocks[0].scriptContent, "src/Page.astro", blocks[0].language, blocks[0].lineOffset);
1129
+ assert.ok(chunks.length >= 1);
1130
+ // Should find the interface
1131
+ const iface = chunks.find(c => c.entityType === "interface");
1132
+ assert.ok(iface, "Should extract Props interface from Astro frontmatter");
1133
+ assert.equal(iface.entityName, "Props");
1134
+ });
1135
+ });
1136
+ // ═══════════════════════════════════════════════════════════════════════════
1137
+ // TeiClient (Phase 1)
1138
+ // ═══════════════════════════════════════════════════════════════════════════
1139
+ describe("TeiClient", () => {
1140
+ it("health check returns unhealthy for non-existent endpoint", async () => {
1141
+ const client = new TeiClient({ baseUrl: "http://localhost:1", timeoutMs: 1000 });
1142
+ const health = await client.checkHealth();
1143
+ assert.equal(health.healthy, false);
1144
+ assert.ok(health.error, "Should have an error message");
1145
+ });
1146
+ it("constructor sets correct defaults", () => {
1147
+ const client = new TeiClient({ baseUrl: "http://localhost:9999" });
1148
+ assert.equal(client.baseUrl, "http://localhost:9999");
1149
+ });
1150
+ it("allows custom configuration", () => {
1151
+ const client = new TeiClient({
1152
+ baseUrl: "http://example.com",
1153
+ timeoutMs: 5000,
1154
+ maxRetries: 5,
1155
+ retryDelayMs: 100,
1156
+ maxBatchSize: 8,
1157
+ });
1158
+ assert.equal(client.baseUrl, "http://example.com");
1159
+ });
1160
+ it("embed throws on non-existent endpoint", async () => {
1161
+ const client = new TeiClient({
1162
+ baseUrl: "http://localhost:1",
1163
+ timeoutMs: 500,
1164
+ maxRetries: 0,
1165
+ });
1166
+ await assert.rejects(() => client.embed(["hello"]), (err) => {
1167
+ assert.ok(err, "Should throw an error");
1168
+ return true;
1169
+ });
1170
+ });
1171
+ it("rerank throws on non-existent endpoint", async () => {
1172
+ const client = new TeiClient({
1173
+ baseUrl: "http://localhost:1",
1174
+ timeoutMs: 500,
1175
+ maxRetries: 0,
1176
+ });
1177
+ await assert.rejects(() => client.rerank("query", ["text1", "text2"]), (err) => {
1178
+ assert.ok(err, "Should throw an error");
1179
+ return true;
1180
+ });
1181
+ });
1182
+ });
1183
+ describe("checkAllTeiHealth", () => {
1184
+ it("reports all unhealthy when TEI is not running", async () => {
1185
+ const result = await checkAllTeiHealth();
1186
+ // TEI is not running during unit tests
1187
+ assert.equal(typeof result.allHealthy, "boolean");
1188
+ assert.equal(typeof result.embed.healthy, "boolean");
1189
+ assert.equal(typeof result.rerank.healthy, "boolean");
1190
+ assert.equal(typeof result.codeEmbed.healthy, "boolean");
1191
+ });
1192
+ });
1193
+ // ═══════════════════════════════════════════════════════════════════════════
1194
+ // CodeStore (metadata operations)
1195
+ // ═══════════════════════════════════════════════════════════════════════════
1196
+ import { CodeStore } from "./code-store.js";
1197
+ describe("CodeStore metadata", { timeout: 30_000 }, () => {
1198
+ let tempDir;
1199
+ let store;
1200
+ before(async () => {
1201
+ tempDir = await mkdtemp(join(tmpdir(), "code-store-test-"));
1202
+ store = new CodeStore(tempDir);
1203
+ });
1204
+ after(async () => {
1205
+ await rm(tempDir, { recursive: true, force: true });
1206
+ });
1207
+ it("loadMetadata returns default for new project", async () => {
1208
+ const meta = await store.loadMetadata();
1209
+ assert.ok(meta);
1210
+ assert.equal(meta.projectRoot, tempDir);
1211
+ assert.ok(Array.isArray(meta.files));
1212
+ assert.equal(meta.files.length, 0);
1213
+ });
1214
+ it("saveMetadata + loadMetadata round-trips correctly", async () => {
1215
+ const meta = await store.loadMetadata();
1216
+ meta.lastFullIndexAt = "2026-01-01T00:00:00Z";
1217
+ meta.lastIndexedCommit = "abc123def456";
1218
+ await store.saveMetadata(meta);
1219
+ // Force re-read by creating a new store instance
1220
+ const store2 = new CodeStore(tempDir);
1221
+ const meta2 = await store2.loadMetadata();
1222
+ assert.equal(meta2.lastFullIndexAt, "2026-01-01T00:00:00Z");
1223
+ assert.equal(meta2.lastIndexedCommit, "abc123def456");
1224
+ });
1225
+ it("getFileHash returns undefined for non-existent file", async () => {
1226
+ const hash = await store.getFileHash("src/nonexistent.ts");
1227
+ assert.equal(hash, undefined);
1228
+ });
1229
+ it("isEmpty returns true for empty store", async () => {
1230
+ const freshDir = await mkdtemp(join(tmpdir(), "empty-store-"));
1231
+ try {
1232
+ const freshStore = new CodeStore(freshDir);
1233
+ const empty = await freshStore.isEmpty();
1234
+ assert.equal(empty, true);
1235
+ }
1236
+ finally {
1237
+ await rm(freshDir, { recursive: true, force: true });
1238
+ }
1239
+ });
1240
+ });
1241
+ //# sourceMappingURL=unit.test.js.map