@danielblomma/cortex-mcp 2.1.0 → 2.1.4

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 (34) hide show
  1. package/README.md +19 -0
  2. package/bin/cortex.mjs +2 -0
  3. package/package.json +3 -2
  4. package/scaffold/mcp/build.mjs +92 -0
  5. package/scaffold/mcp/package-lock.json +3 -3
  6. package/scaffold/mcp/package.json +5 -5
  7. package/scaffold/mcp/src/embed.ts +182 -66
  8. package/scaffold/mcp/src/embedScheduler.ts +509 -0
  9. package/scaffold/mcp/src/embeddings.ts +31 -30
  10. package/scaffold/mcp/src/graphCsv.ts +65 -0
  11. package/scaffold/mcp/src/jsonl.ts +69 -14
  12. package/scaffold/mcp/src/loadGraph.ts +276 -40
  13. package/scaffold/mcp/src/lruCache.ts +41 -0
  14. package/scaffold/mcp/src/searchCore.ts +1 -1
  15. package/scaffold/mcp/src/searchResults.ts +3 -3
  16. package/scaffold/mcp/src/types.ts +6 -1
  17. package/scaffold/mcp/tests/embed-scheduler.test.mjs +474 -0
  18. package/scaffold/mcp/tests/embedding-jsonl.test.mjs +39 -0
  19. package/scaffold/mcp/tests/graph-bulk-load.test.mjs +258 -0
  20. package/scaffold/mcp/tests/graph-csv.test.mjs +118 -0
  21. package/scaffold/mcp/tests/lru-cache.test.mjs +37 -0
  22. package/scaffold/mcp/tests/vector-index.test.mjs +109 -0
  23. package/scaffold/mcp/tsconfig.json +3 -1
  24. package/scaffold/scripts/bootstrap.sh +34 -2
  25. package/scaffold/scripts/dashboard.mjs +1 -1
  26. package/scaffold/scripts/ingest-parsers.mjs +387 -0
  27. package/scaffold/scripts/ingest-worker.mjs +52 -0
  28. package/scaffold/scripts/ingest.mjs +550 -378
  29. package/scaffold/scripts/parsers/csharp.mjs +2 -1
  30. package/scaffold/scripts/parsers/javascript/ast.mjs +160 -8
  31. package/scaffold/scripts/parsers/javascript/chunks.mjs +129 -22
  32. package/scaffold/scripts/parsers/javascript/imports.mjs +38 -4
  33. package/scaffold/scripts/parsers/vbnet.mjs +2 -1
  34. package/scaffold/scripts/status.sh +50 -14
@@ -0,0 +1,258 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import ryugraph from "ryugraph";
10
+
11
+ const MCP_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const REPO_ROOT = path.resolve(MCP_DIR, "../..");
13
+ const ONTOLOGY_SRC = path.join(REPO_ROOT, ".context", "ontology.cypher");
14
+ const LOADER = path.join(MCP_DIR, "dist", "loadGraph.js");
15
+
16
+ // Adversarial bodies: quotes, newlines, CRLF, commas, tabs, unicode,
17
+ // backslashes, and empty strings — the cases CSV escaping must survive
18
+ // while staying byte-identical to the prepared-statement loader.
19
+ const NASTY = 'she said "hi",\nthen \tleft\r\nC:\\tmp åäö 日本語 🚀';
20
+
21
+ function jsonl(records) {
22
+ return records.map((r) => JSON.stringify(r)).join("\n") + "\n";
23
+ }
24
+
25
+ function writeFixture(root) {
26
+ const cache = path.join(root, ".context", "cache");
27
+ fs.mkdirSync(cache, { recursive: true });
28
+ fs.writeFileSync(path.join(root, ".context", "config.yaml"), "source_paths:\n - .\n", "utf8");
29
+ fs.copyFileSync(ONTOLOGY_SRC, path.join(root, ".context", "ontology.cypher"));
30
+
31
+ const w = (name, records) => fs.writeFileSync(path.join(cache, name), jsonl(records), "utf8");
32
+
33
+ w("entities.file.jsonl", [
34
+ { id: "file:a.ts", path: "src/a.ts", kind: "CODE", excerpt: NASTY, checksum: "c1", updated_at: "2026-01-01T00:00:00Z", source_of_truth: false, trust_level: 80, status: "active" },
35
+ { id: "file:b.ts", path: "src/b.ts", kind: "CODE", excerpt: "", checksum: "", updated_at: "2026-01-02T00:00:00Z", source_of_truth: false, trust_level: 80, status: "active" }
36
+ ]);
37
+ w("entities.rule.jsonl", [
38
+ { id: "rule:r1", title: 'Rule "one"', body: NASTY, scope: "global", priority: 3, updated_at: "2026-01-01T00:00:00Z", source_of_truth: true, trust_level: 95, status: "active" }
39
+ ]);
40
+ w("entities.adr.jsonl", [
41
+ { id: "adr:first", path: "docs/adr-1.md", title: "First", body: "decided,\nyes", decision_date: "2026-01-01", supersedes_id: "", source_of_truth: true, trust_level: 95, status: "active" },
42
+ { id: "adr:second", path: "docs/adr-2.md", title: "Second", body: "", decision_date: "2026-02-01", supersedes_id: "adr:first", source_of_truth: true, trust_level: 95, status: "active" }
43
+ ]);
44
+ w("entities.chunk.jsonl", [
45
+ { id: "chunk:a.ts#1", file_id: "file:a.ts", name: "doThing", kind: "function", signature: "doThing(x)", body: NASTY, description: "does, things\n", start_line: 1, end_line: 9, language: "typescript", exported: true, checksum: "k1", updated_at: "2026-01-01T00:00:00Z", source_of_truth: false, trust_level: 80, status: "active" },
46
+ { id: "chunk:b.ts#1", file_id: "file:b.ts", name: "empty", kind: "function", signature: "", body: "", description: "", start_line: 2, end_line: 2, language: "typescript", exported: false, checksum: "k2", updated_at: "2026-01-02T00:00:00Z", source_of_truth: false, trust_level: 80, status: "active" }
47
+ ]);
48
+ w("entities.module.jsonl", [
49
+ { id: "module:src", path: "src", name: "src", summary: "the, source\nmodule", file_count: 2, exported_symbols: "doThing", updated_at: "2026-01-01T00:00:00Z", source_of_truth: false, trust_level: 75, status: "active" },
50
+ { id: "module:src.sub", path: "src/sub", name: "sub", summary: "nested", file_count: 0, exported_symbols: "", updated_at: "2026-01-01T00:00:00Z", source_of_truth: false, trust_level: 75, status: "active" }
51
+ ]);
52
+ w("entities.project.jsonl", [
53
+ { id: "project:root", path: ".", name: "root", kind: "project", language: "typescript", target_framework: "", summary: "root proj", file_count: 2, updated_at: "2026-01-01T00:00:00Z", source_of_truth: false, trust_level: 80, status: "active" },
54
+ { id: "project:sub", path: "sub", name: "sub", kind: "project", language: "typescript", target_framework: "", summary: "sub, \"proj\"", file_count: 0, updated_at: "2026-01-01T00:00:00Z", source_of_truth: false, trust_level: 80, status: "active" }
55
+ ]);
56
+
57
+ // Edges, including dangling references that BOTH loaders must skip:
58
+ // file:ghost / chunk:ghost do not exist as nodes.
59
+ w("relations.constrains.jsonl", [
60
+ { from: "rule:r1", to: "file:a.ts", note: 'constrains, "tightly"' },
61
+ { from: "rule:r1", to: "file:ghost", note: "dangling" }
62
+ ]);
63
+ w("relations.implements.jsonl", [
64
+ { from: "file:a.ts", to: "rule:r1", note: "implements\nit" }
65
+ ]);
66
+ w("relations.supersedes.jsonl", [
67
+ { from: "adr:second", to: "adr:first", reason: "newer,\nbetter" }
68
+ ]);
69
+ w("relations.defines.jsonl", [
70
+ { from: "file:a.ts", to: "chunk:a.ts#1" },
71
+ { from: "file:b.ts", to: "chunk:b.ts#1" },
72
+ { from: "file:a.ts", to: "chunk:ghost" }
73
+ ]);
74
+ w("relations.calls.jsonl", [
75
+ { from: "chunk:a.ts#1", to: "chunk:b.ts#1", call_type: "direct" },
76
+ { from: "chunk:a.ts#1", to: "chunk:ghost", call_type: "direct" }
77
+ ]);
78
+ w("relations.imports.jsonl", [
79
+ { from: "chunk:a.ts#1", to: "file:b.ts", import_name: "./b" }
80
+ ]);
81
+ w("relations.contains.jsonl", [
82
+ { from: "module:src", to: "file:a.ts" },
83
+ { from: "module:src", to: "file:b.ts" }
84
+ ]);
85
+ w("relations.exports.jsonl", [
86
+ { from: "module:src", to: "chunk:a.ts#1" }
87
+ ]);
88
+ w("relations.includes_file.jsonl", [
89
+ { from: "project:root", to: "file:a.ts" },
90
+ { from: "project:root", to: "file:b.ts" }
91
+ ]);
92
+ // The remaining relation tables the COPY path also bulk-loads. Each carries a
93
+ // nasty note and at least one dangling edge so a typo in any single mapping
94
+ // (wrong endpoint set, wrong note column, missing filter) breaks equivalence.
95
+ w("relations.calls_sql.jsonl", [
96
+ { from: "file:a.ts", to: "chunk:b.ts#1", note: 'sql, "ref"\nline2' },
97
+ { from: "file:a.ts", to: "chunk:ghost", note: "dangling" }
98
+ ]);
99
+ w("relations.uses_config_key.jsonl", [
100
+ { from: "file:a.ts", to: "chunk:a.ts#1", note: "cfg key" }
101
+ ]);
102
+ w("relations.uses_resource_key.jsonl", [
103
+ { from: "file:b.ts", to: "chunk:a.ts#1", note: "res, key" }
104
+ ]);
105
+ w("relations.uses_setting_key.jsonl", [
106
+ { from: "file:a.ts", to: "chunk:b.ts#1", note: "setting\nkey" }
107
+ ]);
108
+ w("relations.contains_module.jsonl", [
109
+ { from: "module:src", to: "module:src.sub" },
110
+ { from: "module:src", to: "module:ghost" }
111
+ ]);
112
+ w("relations.references_project.jsonl", [
113
+ { from: "project:root", to: "project:sub", note: 'refs, "sub"' },
114
+ { from: "project:root", to: "project:ghost", note: "dangling" }
115
+ ]);
116
+ w("relations.uses_resource.jsonl", [
117
+ { from: "file:a.ts", to: "file:b.ts", note: "uses res" }
118
+ ]);
119
+ w("relations.uses_setting.jsonl", [
120
+ { from: "file:b.ts", to: "file:a.ts", note: "uses, setting" }
121
+ ]);
122
+ w("relations.uses_config.jsonl", [
123
+ { from: "file:a.ts", to: "file:b.ts", note: "uses cfg" },
124
+ { from: "file:a.ts", to: "file:ghost", note: "dangling" }
125
+ ]);
126
+ w("relations.transforms_config.jsonl", [
127
+ { from: "file:b.ts", to: "file:a.ts", note: "transforms\nconfig" }
128
+ ]);
129
+ }
130
+
131
+ const NODE_LABELS = ["File", "Rule", "ADR", "Chunk", "Module", "Project"];
132
+ const REL_TYPES = [
133
+ "CONSTRAINS", "IMPLEMENTS", "SUPERSEDES", "DEFINES", "CALLS",
134
+ "IMPORTS", "CONTAINS", "EXPORTS", "INCLUDES_FILE",
135
+ "CALLS_SQL", "USES_CONFIG_KEY", "USES_RESOURCE_KEY", "USES_SETTING_KEY",
136
+ "CONTAINS_MODULE", "REFERENCES_PROJECT", "USES_RESOURCE", "USES_SETTING",
137
+ "USES_CONFIG", "TRANSFORMS_CONFIG"
138
+ ];
139
+
140
+ function stable(value) {
141
+ return JSON.stringify(value, (_key, v) =>
142
+ typeof v === "bigint" ? Number(v) : v
143
+ );
144
+ }
145
+
146
+ function stripInternal(obj) {
147
+ const out = {};
148
+ for (const key of Object.keys(obj).sort()) {
149
+ if (key.startsWith("_")) continue;
150
+ out[key] = obj[key];
151
+ }
152
+ return out;
153
+ }
154
+
155
+ async function getAll(conn, query) {
156
+ const result = await conn.query(query);
157
+ const resolved = Array.isArray(result) ? result[result.length - 1] : result;
158
+ return resolved.getAll();
159
+ }
160
+
161
+ async function dumpGraph(dbPath) {
162
+ const db = new ryugraph.Database(dbPath);
163
+ const conn = new ryugraph.Connection(db);
164
+ const dump = { nodes: {}, rels: {} };
165
+
166
+ for (const label of NODE_LABELS) {
167
+ const rows = await getAll(conn, `MATCH (n:${label}) RETURN n ORDER BY n.id;`);
168
+ dump.nodes[label] = rows.map((row) => stripInternal(row.n)).sort((a, b) => a.id.localeCompare(b.id));
169
+ }
170
+ for (const type of REL_TYPES) {
171
+ const rows = await getAll(
172
+ conn,
173
+ `MATCH (a)-[r:${type}]->(b) RETURN a.id AS from, b.id AS to, r AS rel ORDER BY a.id, b.id;`
174
+ );
175
+ dump.rels[type] = rows
176
+ .map((row) => ({ from: row.from, to: row.to, props: stripInternal(row.rel) }))
177
+ .sort((a, b) => (a.from + a.to).localeCompare(b.from + b.to));
178
+ }
179
+ return dump;
180
+ }
181
+
182
+ function runLoader(root, { bulk }) {
183
+ return execFileSync("node", [LOADER], {
184
+ cwd: MCP_DIR,
185
+ env: {
186
+ ...process.env,
187
+ CORTEX_PROJECT_ROOT: root,
188
+ ...(bulk ? {} : { CORTEX_GRAPH_BULK_LOAD: "never" })
189
+ },
190
+ encoding: "utf8"
191
+ });
192
+ }
193
+
194
+ test("graph bulk COPY load produces a byte-identical graph to row-by-row inserts", async () => {
195
+ if (!fs.existsSync(ONTOLOGY_SRC)) {
196
+ // The loader needs a real ontology; skip rather than fail in stripped checkouts.
197
+ return;
198
+ }
199
+
200
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-graph-eq-"));
201
+ const rowRoot = path.join(base, "row");
202
+ // Put the bulk fixture under a directory whose name contains a double quote
203
+ // (where the OS permits it) so the COPY path literal is exercised against
204
+ // P3b: if the quote were not escaped, COPY would throw and silently fall back
205
+ // to row-by-row, failing the "loaded via COPY bulk import" assertion below.
206
+ const bulkRoot = path.join(base, process.platform === "win32" ? "bulk" : 'bu"lk');
207
+ fs.mkdirSync(rowRoot, { recursive: true });
208
+ fs.mkdirSync(bulkRoot, { recursive: true });
209
+
210
+ try {
211
+ writeFixture(rowRoot);
212
+ writeFixture(bulkRoot);
213
+
214
+ const rowOut = runLoader(rowRoot, { bulk: false });
215
+ const bulkOut = runLoader(bulkRoot, { bulk: true });
216
+
217
+ // The two paths must actually have been exercised.
218
+ assert.doesNotMatch(rowOut, /loaded via COPY bulk import/, "row run should not use bulk");
219
+ assert.match(bulkOut, /loaded via COPY bulk import/, "bulk run should use COPY");
220
+
221
+ const rowDump = await dumpGraph(path.join(rowRoot, ".context", "db", "graph.ryu"));
222
+ const bulkDump = await dumpGraph(path.join(bulkRoot, ".context", "db", "graph.ryu"));
223
+
224
+ for (const label of NODE_LABELS) {
225
+ assert.equal(
226
+ stable(bulkDump.nodes[label]),
227
+ stable(rowDump.nodes[label]),
228
+ `node table ${label} differs between bulk and row-by-row`
229
+ );
230
+ }
231
+ for (const type of REL_TYPES) {
232
+ assert.equal(
233
+ stable(bulkDump.rels[type]),
234
+ stable(rowDump.rels[type]),
235
+ `rel table ${type} differs between bulk and row-by-row`
236
+ );
237
+ }
238
+
239
+ // Spot-check the dangling-edge filtering actually dropped edges across a
240
+ // spread of the relation tables (node-pair filters of every endpoint kind).
241
+ assert.equal(bulkDump.rels.DEFINES.length, 2, "ghost DEFINES edge should be filtered");
242
+ assert.equal(bulkDump.rels.CALLS.length, 1, "ghost CALLS edge should be filtered");
243
+ assert.equal(bulkDump.rels.CALLS_SQL.length, 1, "ghost CALLS_SQL edge should be filtered");
244
+ assert.equal(bulkDump.rels.CONTAINS_MODULE.length, 1, "ghost CONTAINS_MODULE edge should be filtered");
245
+ assert.equal(bulkDump.rels.REFERENCES_PROJECT.length, 1, "ghost REFERENCES_PROJECT edge should be filtered");
246
+ assert.equal(bulkDump.rels.USES_CONFIG.length, 1, "ghost USES_CONFIG edge should be filtered");
247
+ // The SUPERSEDES reason and the new note columns must round-trip exactly.
248
+ assert.equal(bulkDump.rels.SUPERSEDES[0].props.reason, "newer,\nbetter");
249
+ assert.equal(bulkDump.rels.CALLS_SQL[0].props.note, 'sql, "ref"\nline2');
250
+ // And that the nasty body survived round-trip exactly.
251
+ const chunkA = bulkDump.nodes.Chunk.find((n) => n.id === "chunk:a.ts#1");
252
+ assert.equal(chunkA.body, NASTY, "adversarial chunk body must round-trip exactly");
253
+ const chunkB = bulkDump.nodes.Chunk.find((n) => n.id === "chunk:b.ts#1");
254
+ assert.equal(chunkB.body, "", "empty chunk body must stay an empty string, not null");
255
+ } finally {
256
+ fs.rmSync(base, { recursive: true, force: true });
257
+ }
258
+ });
@@ -0,0 +1,118 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { toCsvCell, toCsvRow, writeCsv, toCopyPathLiteral, CSV_NULL_SENTINEL, CSV_COPY_OPTIONS } from "../dist/graphCsv.js";
8
+
9
+ test("toCsvCell: quotes plain strings", () => {
10
+ assert.equal(toCsvCell("hello"), '"hello"');
11
+ });
12
+
13
+ test("toCsvCell: doubles internal quotes", () => {
14
+ assert.equal(toCsvCell('she said "hi"'), '"she said ""hi"""');
15
+ });
16
+
17
+ test("toCsvCell: preserves newlines, CRLF, commas, and tabs verbatim", () => {
18
+ assert.equal(toCsvCell("a\nb"), '"a\nb"');
19
+ assert.equal(toCsvCell("a\r\nb"), '"a\r\nb"');
20
+ assert.equal(toCsvCell("a,b,,c"), '"a,b,,c"');
21
+ assert.equal(toCsvCell("x\ty"), '"x\ty"');
22
+ });
23
+
24
+ test("toCsvCell: empty string becomes an empty quoted field, not the null sentinel", () => {
25
+ assert.equal(toCsvCell(""), '""');
26
+ });
27
+
28
+ test("toCsvCell: booleans render as true/false, numbers via String()", () => {
29
+ assert.equal(toCsvCell(true), '"true"');
30
+ assert.equal(toCsvCell(false), '"false"');
31
+ assert.equal(toCsvCell(0), '"0"');
32
+ assert.equal(toCsvCell(42), '"42"');
33
+ assert.equal(toCsvCell(-1), '"-1"');
34
+ });
35
+
36
+ test("toCsvCell: null and undefined collapse to empty field", () => {
37
+ assert.equal(toCsvCell(null), '""');
38
+ assert.equal(toCsvCell(undefined), '""');
39
+ });
40
+
41
+ test("toCsvCell: preserves unicode and backslashes", () => {
42
+ assert.equal(toCsvCell("åäö 日本語 🚀"), '"åäö 日本語 🚀"');
43
+ assert.equal(toCsvCell("C:\\path\\n"), '"C:\\path\\n"');
44
+ });
45
+
46
+ test("toCsvRow: joins cells with commas", () => {
47
+ assert.equal(toCsvRow(["a", 1, true]), '"a","1","true"');
48
+ assert.equal(toCsvRow(['a"b', "c,d"]), '"a""b","c,d"');
49
+ });
50
+
51
+ test("writeCsv: streams iterable rows with byte-equivalent CSV output", () => {
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-csv-"));
53
+ const filePath = path.join(dir, "rows.csv");
54
+ try {
55
+ function* rows() {
56
+ yield ["a", 1, true];
57
+ yield ['she said "hi"', "a\nb", ""];
58
+ yield [null, undefined, false];
59
+ }
60
+
61
+ const rowCount = writeCsv(filePath, ["c1", "c2", "c3"], rows());
62
+ const expected = [
63
+ toCsvRow(["c1", "c2", "c3"]),
64
+ toCsvRow(["a", 1, true]),
65
+ toCsvRow(['she said "hi"', "a\nb", ""]),
66
+ toCsvRow([null, undefined, false]),
67
+ ""
68
+ ].join("\n");
69
+
70
+ assert.equal(rowCount, 3);
71
+ assert.equal(fs.readFileSync(filePath, "utf8"), expected);
72
+ } finally {
73
+ fs.rmSync(dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("writeCsv: empty iterables write only the header and return zero rows", () => {
78
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-csv-empty-"));
79
+ const filePath = path.join(dir, "rows.csv");
80
+ try {
81
+ const rowCount = writeCsv(filePath, ["only"], []);
82
+
83
+ assert.equal(rowCount, 0);
84
+ assert.equal(fs.readFileSync(filePath, "utf8"), `${toCsvRow(["only"])}\n`);
85
+ } finally {
86
+ fs.rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ test("CSV_NULL_SENTINEL is a single NUL byte (impossible in any text-file-derived cell)", () => {
91
+ assert.equal(CSV_NULL_SENTINEL.length, 1);
92
+ assert.equal(CSV_NULL_SENTINEL.charCodeAt(0), 0);
93
+ });
94
+
95
+ test("CSV_COPY_OPTIONS disables parallel reads and empty-as-null", () => {
96
+ assert.match(CSV_COPY_OPTIONS, /PARALLEL=false/);
97
+ assert.match(CSV_COPY_OPTIONS, /HEADER=true/);
98
+ assert.match(CSV_COPY_OPTIONS, /NULL_STRINGS=\[/);
99
+ });
100
+
101
+ test("toCopyPathLiteral leaves a plain posix path untouched", () => {
102
+ assert.equal(toCopyPathLiteral("/repo/.context/cache/graph-import/File.csv"), "/repo/.context/cache/graph-import/File.csv");
103
+ });
104
+
105
+ test("toCopyPathLiteral backslash-escapes embedded double quotes", () => {
106
+ assert.equal(toCopyPathLiteral('/we"ird/File.csv'), '/we\\"ird/File.csv');
107
+ assert.equal(toCopyPathLiteral('/a"b"c/File.csv'), '/a\\"b\\"c/File.csv');
108
+ });
109
+
110
+ test("toCopyPathLiteral normalizes windows separators to forward slashes", () => {
111
+ assert.equal(toCopyPathLiteral("C:\\repo\\cache\\File.csv"), "C:/repo/cache/File.csv");
112
+ });
113
+
114
+ test("toCopyPathLiteral normalizes separators before escaping quotes", () => {
115
+ // The escaping backslash inserted for the quote must survive (not be turned
116
+ // into a slash): separators are normalized first, then quotes escaped.
117
+ assert.equal(toCopyPathLiteral('C:\\we"ird\\File.csv'), 'C:/we\\"ird/File.csv');
118
+ });
@@ -0,0 +1,37 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { LruCache } from "../dist/lruCache.js";
5
+
6
+ test("LruCache: stores and retrieves values", () => {
7
+ const cache = new LruCache(2);
8
+ cache.set("a", 1);
9
+ assert.equal(cache.get("a"), 1);
10
+ assert.equal(cache.get("missing"), undefined);
11
+ });
12
+
13
+ test("LruCache: evicts the least recently used entry at capacity", () => {
14
+ const cache = new LruCache(2);
15
+ cache.set("a", 1);
16
+ cache.set("b", 2);
17
+ cache.get("a"); // refresh a; b becomes LRU
18
+ cache.set("c", 3);
19
+ assert.equal(cache.get("b"), undefined);
20
+ assert.equal(cache.get("a"), 1);
21
+ assert.equal(cache.get("c"), 3);
22
+ assert.equal(cache.size, 2);
23
+ });
24
+
25
+ test("LruCache: overwriting a key refreshes it without growing", () => {
26
+ const cache = new LruCache(2);
27
+ cache.set("a", 1);
28
+ cache.set("b", 2);
29
+ cache.set("a", 9);
30
+ cache.set("c", 3); // evicts b, not a
31
+ assert.equal(cache.get("a"), 9);
32
+ assert.equal(cache.get("b"), undefined);
33
+ });
34
+
35
+ test("LruCache: rejects invalid capacity", () => {
36
+ assert.throws(() => new LruCache(0), /positive integer/);
37
+ });
@@ -0,0 +1,109 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { cosineSimilarity } from "../dist/searchCore.js";
5
+ import { buildSearchResults } from "../dist/searchResults.js";
6
+
7
+ // Reference cosine over float64 number[] — what the index used to store.
8
+ function cosineFloat64(a, b) {
9
+ let dot = 0;
10
+ let normA = 0;
11
+ let normB = 0;
12
+ for (let i = 0; i < a.length; i += 1) {
13
+ dot += a[i] * b[i];
14
+ normA += a[i] * a[i];
15
+ normB += b[i] * b[i];
16
+ }
17
+ if (normA === 0 || normB === 0) return 0;
18
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
19
+ }
20
+
21
+ // Deterministic 384-dim vectors rounded to 6 decimals, mirroring the
22
+ // canonical JSONL form the embedder writes.
23
+ function makeVector(seed, dim = 384) {
24
+ const out = [];
25
+ let state = seed;
26
+ for (let i = 0; i < dim; i += 1) {
27
+ state = (state * 1103515245 + 12345) & 0x7fffffff;
28
+ const raw = (state / 0x7fffffff) * 2 - 1; // [-1, 1)
29
+ out.push(Number(raw.toFixed(6)));
30
+ }
31
+ return out;
32
+ }
33
+
34
+ test("cosineSimilarity (float32) matches the float64 reference within 1e-6 and after 4-decimal rounding", () => {
35
+ for (let pair = 0; pair < 50; pair += 1) {
36
+ const a = makeVector(pair * 7 + 1);
37
+ const b = makeVector(pair * 13 + 2);
38
+ const ref = cosineFloat64(a, b);
39
+ const got = cosineSimilarity(Float32Array.from(a), Float32Array.from(b));
40
+ assert.ok(
41
+ Math.abs(got - ref) < 1e-6,
42
+ `pair ${pair}: |${got} - ${ref}| = ${Math.abs(got - ref)} exceeds 1e-6`
43
+ );
44
+ // The API rounds embedding_score to 4 decimals — float32 cannot change it.
45
+ assert.equal(Number(got.toFixed(4)), Number(ref.toFixed(4)), `pair ${pair} differs at 4 decimals`);
46
+ }
47
+ });
48
+
49
+ test("cosineSimilarity handles identical and zero vectors", () => {
50
+ const v = Float32Array.from(makeVector(99));
51
+ assert.ok(Math.abs(cosineSimilarity(v, v) - 1) < 1e-6);
52
+ const zero = new Float32Array(384);
53
+ assert.equal(cosineSimilarity(v, zero), 0);
54
+ assert.equal(cosineSimilarity(new Float32Array(0), new Float32Array(0)), 0);
55
+ });
56
+
57
+ function makeEntity(id, overrides = {}) {
58
+ return {
59
+ id,
60
+ entity_type: "Chunk",
61
+ kind: "function",
62
+ label: id,
63
+ path: `src/${id}.ts`,
64
+ text: `text for ${id}`,
65
+ status: "active",
66
+ source_of_truth: false,
67
+ trust_level: 50,
68
+ updated_at: "2026-01-01T00:00:00Z",
69
+ snippet: "",
70
+ matched_rules: [],
71
+ ...overrides
72
+ };
73
+ }
74
+
75
+ test("buildSearchResults ranks via Float32Array vectors end-to-end", () => {
76
+ const query = Float32Array.from(makeVector(1));
77
+ const near = Float32Array.from(makeVector(1)); // identical → cosine 1
78
+ const far = Float32Array.from(makeVector(500)); // unrelated
79
+
80
+ const candidates = [makeEntity("near"), makeEntity("far")];
81
+ const results = buildSearchResults({
82
+ candidates,
83
+ degreeByEntity: new Map(),
84
+ queryTokens: ["text"],
85
+ queryPhrase: "text",
86
+ ranking: { semantic: 0.55, graph: 0.1, trust: 0.2, recency: 0.15 },
87
+ includeScores: true,
88
+ includeMatchedRules: false,
89
+ includeContent: false,
90
+ queryVector: query,
91
+ embeddingVectors: new Map([
92
+ ["near", near],
93
+ ["far", far]
94
+ ]),
95
+ topK: 2,
96
+ minLexicalRelevance: 0,
97
+ minVectorRelevance: 0,
98
+ semanticScorer: () => 0.5,
99
+ vectorScorer: cosineSimilarity,
100
+ recencyScorer: () => 0.5,
101
+ legacyDataAccessBooster: () => 0
102
+ });
103
+
104
+ assert.equal(results.length, 2);
105
+ assert.equal(results[0].id, "near", "identical-vector entity should rank first");
106
+ // embedding_score is reported rounded to 4 decimals; near==query → 1.0000.
107
+ const nearScore = results.find((r) => r.id === "near").embedding_score;
108
+ assert.equal(nearScore, 1, "identical float32 vectors score 1.0000");
109
+ });
@@ -9,5 +9,7 @@
9
9
  "esModuleInterop": true,
10
10
  "skipLibCheck": true
11
11
  },
12
- "include": ["src/**/*.ts"]
12
+ "include": [
13
+ "src/**/*.ts"
14
+ ]
13
15
  }
@@ -23,10 +23,42 @@ info "pipeline: deps -> ingest -> embeddings -> graph -> status"
23
23
 
24
24
  mkdir -p "$MCP_DIR/.npm-cache"
25
25
 
26
+ # Runs `npm install` for a prefix only when its package-lock.json changed
27
+ # since the last successful install (or node_modules is missing). A warm
28
+ # no-op install still costs npm tens of seconds per prefix; the lockfile-hash
29
+ # marker makes repeat bootstraps and updates skip it entirely.
30
+ install_deps_if_changed() {
31
+ local prefix="$1" cache="$2"
32
+ local lock="$prefix/package-lock.json"
33
+ local marker="$prefix/node_modules/.cortex-lock-hash"
34
+ local current=""
35
+ if [ -f "$lock" ]; then
36
+ current=$(node -e '
37
+ const crypto = require("node:crypto");
38
+ const fs = require("node:fs");
39
+ // Node version is part of the key: native deps must reinstall after a
40
+ // runtime switch even when the lockfile is unchanged.
41
+ const hash = crypto.createHash("sha256");
42
+ hash.update(process.version);
43
+ hash.update(fs.readFileSync(process.argv[1]));
44
+ console.log(hash.digest("hex"));
45
+ ' "$lock" 2>/dev/null || true)
46
+ fi
47
+ if [ -n "$current" ] && [ -d "$prefix/node_modules" ] && [ -f "$marker" ] \
48
+ && [ "$(cat "$marker" 2>/dev/null)" = "$current" ]; then
49
+ info "dependencies up to date in $prefix (lockfile unchanged)"
50
+ return 0
51
+ fi
52
+ NPM_CONFIG_CACHE="$cache" npm --prefix "$prefix" install --no-fund --no-update-notifier --loglevel=warn
53
+ if [ -n "$current" ]; then
54
+ printf '%s' "$current" > "$marker" || true
55
+ fi
56
+ }
57
+
26
58
  step "Installing MCP dependencies"
27
59
  info "note: upstream RyuGraph dependencies may print deprecation warnings during install"
28
- NPM_CONFIG_CACHE="$MCP_DIR/.npm-cache" npm --prefix "$MCP_DIR" install --no-fund --no-update-notifier --loglevel=warn
29
- NPM_CONFIG_CACHE="$REPO_ROOT/.context/scripts/parsers/.npm-cache" npm --prefix "$REPO_ROOT/.context/scripts/parsers" install --no-fund --no-update-notifier --loglevel=warn
60
+ install_deps_if_changed "$MCP_DIR" "$MCP_DIR/.npm-cache"
61
+ install_deps_if_changed "$REPO_ROOT/.context/scripts/parsers" "$REPO_ROOT/.context/scripts/parsers/.npm-cache"
30
62
 
31
63
  source "$SCRIPT_DIR/lib/enterprise-check.sh"
32
64
 
@@ -17,7 +17,7 @@ const CONFIG_PATH = path.join(CONTEXT_DIR, "config.yaml");
17
17
  const SUPPORTED_TEXT_EXTENSIONS = new Set([
18
18
  ".md", ".mdx", ".txt", ".adoc", ".rst",
19
19
  ".yaml", ".yml", ".json", ".toml", ".csv",
20
- ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
20
+ ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
21
21
  ".py", ".go", ".java", ".cs", ".rb", ".rs", ".php", ".swift", ".kt",
22
22
  ".sql", ".sh", ".bash", ".zsh", ".ps1",
23
23
  ".c", ".h", ".cpp", ".hpp", ".cc", ".hh"