@danielblomma/cortex-mcp 2.2.5 → 2.4.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.
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
 
6
6
  const QUERY_MODULE = fileURLToPath(new URL("../dist/cli/query.js", import.meta.url));
7
+ const PROJECT_ROOT = fileURLToPath(new URL("../..", import.meta.url));
7
8
 
8
9
  function runQuery(args) {
9
10
  const script = [
@@ -20,7 +21,11 @@ function runQuery(args) {
20
21
  pathToFileURL(QUERY_MODULE).href,
21
22
  JSON.stringify(args),
22
23
  ],
23
- { encoding: "utf8" },
24
+ {
25
+ cwd: PROJECT_ROOT,
26
+ encoding: "utf8",
27
+ env: { ...process.env, CORTEX_PROJECT_ROOT: PROJECT_ROOT },
28
+ },
24
29
  );
25
30
  }
26
31
 
@@ -105,6 +110,73 @@ test("explain --json enables scores and matched rules", () => {
105
110
  assert.ok(Array.isArray(parsed.data.results));
106
111
  });
107
112
 
113
+ test("pattern-evidence --json emits ordered cited evidence tiers", () => {
114
+ const parsed = runJson([
115
+ "pattern-evidence",
116
+ "mcp/src/cli/query.ts",
117
+ "--query",
118
+ "CLI argument parsing error handling",
119
+ "--top-k",
120
+ "2",
121
+ "--json",
122
+ ]);
123
+
124
+ assert.equal(parsed.ok, true);
125
+ assert.equal(parsed.command, "pattern-evidence");
126
+ assert.equal(parsed.input.target, "mcp/src/cli/query.ts");
127
+ assert.equal(parsed.input.top_k, 2);
128
+ assert.deepEqual(parsed.data.evidence_order, [
129
+ "same_file",
130
+ "same_module",
131
+ "same_feature_area",
132
+ "repo_wide",
133
+ ]);
134
+ assert.equal(typeof parsed.data.local_pattern_found, "boolean");
135
+ assert.equal(parsed.data.tiers.length, 4);
136
+ for (const tier of parsed.data.tiers) {
137
+ assert.ok(Array.isArray(tier.evidence));
138
+ for (const evidence of tier.evidence) {
139
+ assert.equal(typeof evidence.path, "string");
140
+ assert.ok(evidence.path.length > 0);
141
+ if (evidence.entity_type === "Chunk") {
142
+ assert.equal(Number.isInteger(evidence.start_line), true);
143
+ assert.equal(Number.isInteger(evidence.end_line), true);
144
+ }
145
+ }
146
+ }
147
+ });
148
+
149
+ test("pattern-evidence --json rejects targets that are not file-backed", () => {
150
+ const parsed = runJson(["pattern-evidence", "rule.source_of_truth", "--json"], 1);
151
+
152
+ assert.equal(parsed.ok, false);
153
+ assert.equal(parsed.command, "pattern-evidence");
154
+ assert.match(parsed.error.message, /not file-backed/);
155
+ });
156
+
157
+ test("pattern-evidence derives a query from the target when --query is omitted", () => {
158
+ const parsed = runJson(["pattern-evidence", "mcp/src/cli/query.ts", "--top-k", "1", "--json"]);
159
+
160
+ assert.equal(parsed.ok, true);
161
+ assert.equal(parsed.data.query_source, "derived_from_target");
162
+ assert.equal(typeof parsed.data.query, "string");
163
+ assert.ok(parsed.data.query.length > 0);
164
+ });
165
+
166
+ test("pattern-evidence rejects malformed --top-k values", () => {
167
+ const parsed = runJson([
168
+ "pattern-evidence",
169
+ "mcp/src/cli/query.ts",
170
+ "--top-k",
171
+ "2junk",
172
+ "--json",
173
+ ], 1);
174
+
175
+ assert.equal(parsed.ok, false);
176
+ assert.equal(parsed.command, "pattern-evidence");
177
+ assert.match(parsed.error.message, /must be an integer/);
178
+ });
179
+
108
180
  test("json validation errors emit an error envelope", () => {
109
181
  const parsed = runJson(["impact", "--json"], 1);
110
182
 
@@ -1,6 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { buildSearchResults, buildSearchResultsWithStats } from "../dist/searchResults.js";
4
+ import { expandQueryTokens, semanticScore, structuralSearchBoost, tokenize } from "../dist/searchCore.js";
4
5
 
5
6
  function makeEntity(id, entityType, overrides = {}) {
6
7
  return {
@@ -166,6 +167,172 @@ test("ranking is preserved when score fields are omitted from the response", ()
166
167
  assert.equal("score" in results[0], false);
167
168
  });
168
169
 
170
+ test("tokenization exposes camel-case code identifiers for lexical matching", () => {
171
+ const tokens = tokenize("buildSearchResults parseRankingFromConfig parseJavaScriptCode parseCSharpParser C# VB.NET");
172
+
173
+ assert.ok(tokens.includes("build"));
174
+ assert.ok(tokens.includes("search"));
175
+ assert.ok(tokens.includes("results"));
176
+ assert.ok(tokens.includes("parse"));
177
+ assert.ok(tokens.includes("ranking"));
178
+ assert.ok(tokens.includes("config"));
179
+ assert.ok(tokens.includes("javascript"));
180
+ assert.ok(tokens.includes("csharp"));
181
+ assert.ok(tokens.includes("vbnet"));
182
+ assert.equal(tokens.includes("java"), false);
183
+ });
184
+
185
+ test("query aliases do not dilute lexical semantic scoring", () => {
186
+ assert.equal(semanticScore(expandQueryTokens(["dashboard"]), "", "status"), 0.85);
187
+ assert.equal(semanticScore(expandQueryTokens(["import"]), "", "imports"), 0.85);
188
+ });
189
+
190
+ test("structural search boost favors matching path and symbol fields", () => {
191
+ const query = "How does the JavaScript and TypeScript parser collect calls and imports?";
192
+ const queryTokens = expandQueryTokens(Array.from(new Set(tokenize(query))));
193
+ const jsImports = makeEntity("js-imports", "Chunk", {
194
+ label: "collectImports",
195
+ path: "scaffold/scripts/parsers/javascript/imports.mjs"
196
+ });
197
+ const cppParser = makeEntity("cpp-parser", "Chunk", {
198
+ label: "parseCode",
199
+ path: "scaffold/scripts/parsers/cpp-treesitter.mjs"
200
+ });
201
+ const csharpParser = makeEntity("csharp-parser", "Chunk", {
202
+ label: "CSharpParser",
203
+ path: "scaffold/scripts/parsers/dotnet/CSharpParser/Program.cs"
204
+ });
205
+
206
+ assert.ok(
207
+ structuralSearchBoost(jsImports, queryTokens, query.toLowerCase()) >
208
+ structuralSearchBoost(cppParser, queryTokens, query.toLowerCase())
209
+ );
210
+ assert.ok(
211
+ structuralSearchBoost(jsImports, queryTokens, query.toLowerCase()) >
212
+ structuralSearchBoost(csharpParser, queryTokens, query.toLowerCase())
213
+ );
214
+ });
215
+
216
+ test("low-semantic graph hubs cannot outrank strong semantic code matches on secondary signals alone", () => {
217
+ const candidates = [
218
+ makeEntity("hub", "Chunk", {
219
+ text: "broad weak query",
220
+ trust_level: 100
221
+ }),
222
+ makeEntity("leaf", "Chunk", {
223
+ text: "precise strong query",
224
+ trust_level: 10
225
+ })
226
+ ];
227
+
228
+ const results = buildSearchResults({
229
+ candidates,
230
+ degreeByEntity: new Map([
231
+ ["hub", 100],
232
+ ["leaf", 0]
233
+ ]),
234
+ queryTokens: ["query"],
235
+ queryPhrase: "query",
236
+ ranking: { semantic: 0.4, graph: 0.25, trust: 0.2, recency: 0.15 },
237
+ includeScores: true,
238
+ includeMatchedRules: false,
239
+ includeContent: false,
240
+ queryVector: null,
241
+ embeddingVectors: new Map(),
242
+ topK: 2,
243
+ minLexicalRelevance: 0,
244
+ minVectorRelevance: 0,
245
+ semanticScorer: (_tokens, _phrase, text) => (text.includes("strong") ? 0.7 : 0.2),
246
+ vectorScorer: () => 0,
247
+ recencyScorer: () => 1,
248
+ structuralSearchBooster: () => 0,
249
+ legacyDataAccessBooster: () => 0
250
+ });
251
+
252
+ assert.equal(results[0].id, "leaf");
253
+ });
254
+
255
+ test("diverse top-k selection admits a near-tie from another path", () => {
256
+ const candidates = [
257
+ makeEntity("a1", "Chunk", { path: "src/a.ts", text: "a1 query" }),
258
+ makeEntity("a2", "Chunk", { path: "src/a.ts", text: "a2 query" }),
259
+ makeEntity("a3", "Chunk", { path: "src/a.ts", text: "a3 query" }),
260
+ makeEntity("b1", "Chunk", { path: "src/b.ts", text: "b1 query" })
261
+ ];
262
+ const semanticById = new Map([
263
+ ["a1", 0.9],
264
+ ["a2", 0.89],
265
+ ["a3", 0.88],
266
+ ["b1", 0.86]
267
+ ]);
268
+
269
+ const results = buildSearchResults({
270
+ candidates,
271
+ degreeByEntity: new Map(),
272
+ queryTokens: ["query"],
273
+ queryPhrase: "query",
274
+ ranking: { semantic: 1, graph: 0, trust: 0, recency: 0 },
275
+ includeScores: true,
276
+ includeMatchedRules: false,
277
+ includeContent: false,
278
+ queryVector: null,
279
+ embeddingVectors: new Map(),
280
+ topK: 3,
281
+ minLexicalRelevance: 0,
282
+ minVectorRelevance: 0,
283
+ semanticScorer: (_tokens, _phrase, text) => semanticById.get(text.split(" ")[0]) ?? 0,
284
+ vectorScorer: () => 0,
285
+ recencyScorer: () => 0,
286
+ structuralSearchBooster: () => 0,
287
+ legacyDataAccessBooster: () => 0
288
+ });
289
+
290
+ assert.deepEqual(results.map((result) => result.id), ["a1", "a2", "b1"]);
291
+ assert.equal(results.some((result) => result.id === "a3"), false);
292
+ assert.deepEqual(results.map((result) => result.score), [0.9, 0.89, 0.86]);
293
+ });
294
+
295
+ test("strong test evidence can outrank a slightly weaker graph hub", () => {
296
+ const candidates = [
297
+ makeEntity("hub", "Chunk", {
298
+ path: "src/hub.ts",
299
+ text: "implementation query",
300
+ trust_level: 50
301
+ }),
302
+ makeEntity("test-evidence", "File", {
303
+ path: "tests/context-regressions.test.mjs",
304
+ text: "regression evidence query",
305
+ trust_level: 50
306
+ })
307
+ ];
308
+
309
+ const results = buildSearchResults({
310
+ candidates,
311
+ degreeByEntity: new Map([
312
+ ["hub", 100],
313
+ ["test-evidence", 0]
314
+ ]),
315
+ queryTokens: ["query"],
316
+ queryPhrase: "query",
317
+ ranking: { semantic: 0.4, graph: 0.25, trust: 0.2, recency: 0.15 },
318
+ includeScores: true,
319
+ includeMatchedRules: false,
320
+ includeContent: false,
321
+ queryVector: null,
322
+ embeddingVectors: new Map(),
323
+ topK: 2,
324
+ minLexicalRelevance: 0,
325
+ minVectorRelevance: 0,
326
+ semanticScorer: (_tokens, _phrase, text) => (text.includes("regression") ? 0.5 : 0.49),
327
+ vectorScorer: () => 0,
328
+ recencyScorer: () => 1,
329
+ structuralSearchBooster: () => 0,
330
+ legacyDataAccessBooster: () => 0
331
+ });
332
+
333
+ assert.equal(results[0].id, "test-evidence");
334
+ });
335
+
169
336
  test("search results can consume a repeatable candidate generator with stats", () => {
170
337
  let passes = 0;
171
338
  const candidateSource = function* () {
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { execSync } from "node:child_process";
5
+ import { execFileSync, execSync } from "node:child_process";
6
6
 
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = path.dirname(__filename);
@@ -286,12 +286,11 @@ function getLocalCliVersion() {
286
286
  }
287
287
 
288
288
  try {
289
- const output = execSync("cortex --version", {
289
+ const output = execFileSync("cortex", ["--version"], {
290
290
  cwd: REPO_ROOT,
291
291
  stdio: ["ignore", "pipe", "ignore"],
292
292
  encoding: "utf8",
293
293
  timeout: 1500,
294
- shell: true,
295
294
  }).trim();
296
295
  if (parseVersion(output)) {
297
296
  return output;
@@ -331,13 +330,12 @@ function getVersionStatus() {
331
330
  } else {
332
331
  try {
333
332
  const npmCache = path.join(CACHE_DIR, "npm-cache");
334
- const latestRaw = execSync("npm view github:DanielBlomma/cortex version --json", {
333
+ const latestRaw = execFileSync("npm", ["view", "github:DanielBlomma/cortex", "version", "--json"], {
335
334
  cwd: REPO_ROOT,
336
335
  stdio: ["ignore", "pipe", "pipe"],
337
336
  encoding: "utf8",
338
337
  timeout: VERSION_LOOKUP_TIMEOUT_MS,
339
338
  env: { ...process.env, NPM_CONFIG_CACHE: npmCache },
340
- shell: true,
341
339
  }).trim();
342
340
  const parsedLatest = JSON.parse(latestRaw);
343
341
  const latest = Array.isArray(parsedLatest)
@@ -352,20 +350,30 @@ function getVersionStatus() {
352
350
  latest,
353
351
  message: "unsupported latest version format",
354
352
  };
355
- } else if (compareVersions(latestParsed, localParsed) > 0) {
356
- value = {
357
- state: "update-available",
358
- local,
359
- latest,
360
- message: null,
361
- };
362
353
  } else {
363
- value = {
364
- state: "current",
365
- local,
366
- latest,
367
- message: null,
368
- };
354
+ const comparison = compareVersions(latestParsed, localParsed);
355
+ if (comparison > 0) {
356
+ value = {
357
+ state: "update-available",
358
+ local,
359
+ latest,
360
+ message: null,
361
+ };
362
+ } else if (comparison < 0) {
363
+ value = {
364
+ state: "local-newer",
365
+ local,
366
+ latest,
367
+ message: "local build is newer than published version",
368
+ };
369
+ } else {
370
+ value = {
371
+ state: "current",
372
+ local,
373
+ latest,
374
+ message: null,
375
+ };
376
+ }
369
377
  }
370
378
  } catch (error) {
371
379
  value = {
@@ -690,6 +698,9 @@ function render(data, isTTY) {
690
698
  if (data.version.state === "update-available") {
691
699
  lines.push(sideBorder(
692
700
  `Version: ${bold(col("UPDATE AVAILABLE", C.red))} ${col(`${data.version.local} -> ${data.version.latest}`, C.red)}`, w));
701
+ } else if (data.version.state === "local-newer") {
702
+ lines.push(sideBorder(
703
+ `Version: ${col(data.version.local, C.yellow)} ${dim(`Published: ${data.version.latest} local newer`)}`, w));
693
704
  } else if (data.version.state === "current") {
694
705
  lines.push(sideBorder(
695
706
  `Version: ${col(data.version.local, C.green)} ${dim(`Latest: ${data.version.latest}`)}`, w));
@@ -180,15 +180,33 @@ fi
180
180
 
181
181
  # Quick runtime import check
182
182
  if [[ -f "$MCP_DIR/dist/server.js" ]] && [[ -d "$MCP_DIR/node_modules" ]]; then
183
- MCP_CHECK=$(cd "$REPO_ROOT" && timeout 10 node -e '
184
- const start = Date.now();
185
- try {
186
- require("./.context/mcp/dist/graph.js");
187
- console.log("ok " + (Date.now() - start));
188
- } catch(e) {
189
- console.log("fail " + e.message);
183
+ MCP_CHECK=$(cd "$REPO_ROOT" && node -e '
184
+ const { spawnSync } = require("node:child_process");
185
+ const timeoutMs = Number(process.env.CORTEX_DOCTOR_GRAPH_TIMEOUT_MS || 30000);
186
+ const probe = [
187
+ "const start = Date.now();",
188
+ "try {",
189
+ " require(\"./.context/mcp/dist/graph.js\");",
190
+ " console.log(\"ok \" + (Date.now() - start));",
191
+ "} catch (e) {",
192
+ " console.log(\"fail \" + (e && e.message ? e.message : String(e)));",
193
+ " process.exitCode = 1;",
194
+ "}"
195
+ ].join("\n");
196
+ const result = spawnSync(process.execPath, ["-e", probe], {
197
+ encoding: "utf8",
198
+ stdio: ["ignore", "pipe", "pipe"],
199
+ timeout: timeoutMs
200
+ });
201
+ if (result.error && result.error.code === "ETIMEDOUT") {
202
+ console.log("fail timeout after " + timeoutMs + "ms");
203
+ } else if (result.error) {
204
+ console.log("fail " + result.error.message);
205
+ } else {
206
+ const lines = String(result.stdout || "").trim().split(/\r?\n/).filter(Boolean);
207
+ console.log(lines[lines.length - 1] || ("fail exit " + (result.status ?? "unknown")));
190
208
  }
191
- ' 2>/dev/null || echo "fail timeout")
209
+ ' 2>/dev/null || echo "fail graph check crashed")
192
210
  if [[ "$MCP_CHECK" == ok* ]]; then
193
211
  MS="${MCP_CHECK#ok }"
194
212
  pass "Graph module loads (${MS}ms)"
@@ -457,11 +457,11 @@ function parseRules(rulesText) {
457
457
  function walkDirectory(directoryPath, files) {
458
458
  const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
459
459
  for (const entry of entries) {
460
- if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) {
460
+ const absolutePath = path.join(directoryPath, entry.name);
461
+ if (entry.isDirectory() && shouldSkipDirectory(absolutePath, entry.name)) {
461
462
  continue;
462
463
  }
463
464
 
464
- const absolutePath = path.join(directoryPath, entry.name);
465
465
  if (entry.isDirectory()) {
466
466
  walkDirectory(absolutePath, files);
467
467
  continue;
@@ -473,10 +473,40 @@ function walkDirectory(directoryPath, files) {
473
473
  }
474
474
  }
475
475
 
476
+ function shouldSkipDirectory(absolutePath, entryName) {
477
+ if (entryName === "bin" && path.resolve(path.dirname(absolutePath)) === REPO_ROOT) {
478
+ return false;
479
+ }
480
+ return SKIP_DIRECTORIES.has(entryName);
481
+ }
482
+
483
+ function normalizeSourcePrefix(sourcePath) {
484
+ const source = toPosixPath(sourcePath).replace(/^\.\/+/, "").replace(/\/+$/, "");
485
+ return source === "." ? "" : source;
486
+ }
487
+
488
+ function normalizeRelativePath(relPath) {
489
+ return toPosixPath(relPath).replace(/^\.\/+/, "").replace(/\/+$/, "");
490
+ }
491
+
492
+ function hasSkippedDirectorySegment(relPath) {
493
+ const parts = normalizeRelativePath(relPath).split("/").filter(Boolean);
494
+ return parts.some((part, index) => {
495
+ if (part === "bin" && index === 0) {
496
+ return false;
497
+ }
498
+ return SKIP_DIRECTORIES.has(part);
499
+ });
500
+ }
501
+
476
502
  function hasSourcePrefix(relPath, sourcePaths) {
503
+ const normalizedRelPath = normalizeRelativePath(relPath);
504
+ if (!normalizedRelPath || hasSkippedDirectorySegment(normalizedRelPath)) {
505
+ return false;
506
+ }
477
507
  return sourcePaths.some((sourcePath) => {
478
- const source = toPosixPath(sourcePath).replace(/\/+$/, "");
479
- return relPath === source || relPath.startsWith(`${source}/`);
508
+ const source = normalizeSourcePrefix(sourcePath);
509
+ return source === "" || normalizedRelPath === source || normalizedRelPath.startsWith(`${source}/`);
480
510
  });
481
511
  }
482
512
 
@@ -627,6 +657,9 @@ function collectCandidateFiles(sourcePaths, mode) {
627
657
  }
628
658
 
629
659
  for (const sourcePath of sourcePaths) {
660
+ if (hasSkippedDirectorySegment(sourcePath)) {
661
+ continue;
662
+ }
630
663
  const absoluteSourcePath = path.resolve(REPO_ROOT, sourcePath);
631
664
  if (!fs.existsSync(absoluteSourcePath)) {
632
665
  continue;
@@ -198,11 +198,12 @@ try {
198
198
 
199
199
  node -e '
200
200
  const path = require("node:path");
201
- const { execSync } = require("node:child_process");
201
+ const { execFileSync, execSync } = require("node:child_process");
202
202
 
203
203
  const repoRoot = process.argv[1];
204
204
  const cacheDir = process.argv[2];
205
205
  const localVersionEnv = process.argv[3] || "";
206
+ const VERSION_LOOKUP_TIMEOUT_MS = Number(process.env.CORTEX_VERSION_LOOKUP_TIMEOUT_MS || 8000);
206
207
 
207
208
  function parseVersion(value) {
208
209
  const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
@@ -225,7 +226,7 @@ function getLocalVersion() {
225
226
  }
226
227
 
227
228
  try {
228
- const output = execSync("cortex --version", {
229
+ const output = execFileSync("cortex", ["--version"], {
229
230
  cwd: repoRoot,
230
231
  stdio: ["ignore", "pipe", "ignore"],
231
232
  encoding: "utf8",
@@ -282,11 +283,11 @@ try {
282
283
  const npmCache = path.join(cacheDir, "npm-cache");
283
284
  let latestRaw = "";
284
285
  try {
285
- latestRaw = execSync("npm view github:DanielBlomma/cortex version --json", {
286
+ latestRaw = execFileSync("npm", ["view", "github:DanielBlomma/cortex", "version", "--json"], {
286
287
  cwd: repoRoot,
287
288
  stdio: ["ignore", "pipe", "pipe"],
288
289
  encoding: "utf8",
289
- timeout: 2500,
290
+ timeout: VERSION_LOOKUP_TIMEOUT_MS,
290
291
  env: { ...process.env, NPM_CONFIG_CACHE: npmCache }
291
292
  }).trim();
292
293
  } catch (error) {
@@ -317,13 +318,19 @@ try {
317
318
  process.exit(0);
318
319
  }
319
320
 
320
- const hasUpdate = compareVersions(latestParsed, localParsed) > 0;
321
+ const comparison = compareVersions(latestParsed, localParsed);
321
322
  console.log(`[status] cortex_latest_version=${latestVersion}`);
322
323
 
323
- if (hasUpdate) {
324
+ if (comparison > 0) {
325
+ console.log("[status] cortex_version_state=update-available");
324
326
  console.log("[status] cortex_update_available=yes");
325
327
  console.log("[status] run: npm i -g github:DanielBlomma/cortex");
328
+ } else if (comparison < 0) {
329
+ console.log("[status] cortex_version_state=local-newer");
330
+ console.log("[status] cortex_update_available=no");
331
+ console.log("[status] note: local Cortex is newer than the published version");
326
332
  } else {
333
+ console.log("[status] cortex_version_state=current");
327
334
  console.log("[status] cortex_update_available=no");
328
335
  }
329
336
  } catch (error) {