@danielblomma/cortex-mcp 2.2.4 → 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.
@@ -0,0 +1,321 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ classifyPatternEvidence,
5
+ contextReferenceTimeMs,
6
+ runLocalPatternEvidence,
7
+ runPatternEvidence,
8
+ } from "../dist/patternEvidence.js";
9
+
10
+ function chunk(id, fileId, startLine, endLine) {
11
+ return {
12
+ id,
13
+ file_id: fileId,
14
+ name: id,
15
+ kind: "function",
16
+ signature: "",
17
+ body: "",
18
+ description: "",
19
+ start_line: startLine,
20
+ end_line: endLine,
21
+ language: "typescript",
22
+ exported: false,
23
+ updated_at: "2026-07-12T00:00:00.000Z",
24
+ source_of_truth: false,
25
+ trust_level: 60,
26
+ status: "active",
27
+ };
28
+ }
29
+
30
+ function document(id, filePath, kind, content = "") {
31
+ return {
32
+ id,
33
+ path: filePath,
34
+ kind,
35
+ updated_at: "2026-07-12T00:00:00.000Z",
36
+ source_of_truth: false,
37
+ trust_level: kind === "DOC" ? 80 : 60,
38
+ status: "active",
39
+ excerpt: content,
40
+ content,
41
+ };
42
+ }
43
+
44
+ function contextData({ documents, chunks = [] }) {
45
+ return {
46
+ documents,
47
+ chunks,
48
+ rules: [],
49
+ adrs: [],
50
+ modules: [],
51
+ projects: [],
52
+ relations: [],
53
+ ranking: { semantic: 0.4, graph: 0.25, trust: 0.2, recency: 0.15 },
54
+ source: "cache",
55
+ };
56
+ }
57
+
58
+ test("classifies helper, error, and config evidence in local-first order with line citations", () => {
59
+ const target = {
60
+ input: "src/features/auth/controller.ts",
61
+ entity_id: "file:src/features/auth/controller.ts",
62
+ entity_type: "File",
63
+ path: "src/features/auth/controller.ts",
64
+ };
65
+ const results = [
66
+ {
67
+ id: target.entity_id,
68
+ entity_type: "File",
69
+ kind: "CODE",
70
+ title: target.path,
71
+ path: target.path,
72
+ excerpt: "target must not cite itself",
73
+ score: 0.99,
74
+ },
75
+ {
76
+ id: "chunk:helper",
77
+ entity_type: "Chunk",
78
+ kind: "function",
79
+ title: "normalizeUser",
80
+ path: "src\\features\\auth\\controller.ts",
81
+ excerpt: "file-local helper shape",
82
+ score: 0.9,
83
+ matched_rules: ["rule.repo_local_pattern_review", "rule.repo_local_pattern_review"],
84
+ },
85
+ {
86
+ id: "chunk:error",
87
+ entity_type: "Chunk",
88
+ kind: "function",
89
+ title: "toAuthError",
90
+ path: "src/features/auth/errors.ts",
91
+ excerpt: "module-local error handling",
92
+ score: 0.8,
93
+ },
94
+ {
95
+ id: "chunk:config",
96
+ entity_type: "Chunk",
97
+ kind: "function",
98
+ title: "parseFeatureEnv",
99
+ path: "src/features/config/env.ts",
100
+ excerpt: "feature-local config parsing",
101
+ score: 0.7,
102
+ },
103
+ {
104
+ id: "file:docs/conventions.md",
105
+ entity_type: "File",
106
+ kind: "DOC",
107
+ title: "docs/conventions.md",
108
+ path: "docs/conventions.md",
109
+ excerpt: "repository fallback",
110
+ score: 0.6,
111
+ },
112
+ ];
113
+ const chunks = [
114
+ chunk("chunk:helper", "file:src/features/auth/controller.ts", 10, 18),
115
+ chunk("chunk:error", "file:src/features/auth/errors.ts", 4, 12),
116
+ chunk("chunk:config", "file:src/features/config/env.ts", 20, 31),
117
+ ];
118
+
119
+ const classified = classifyPatternEvidence({ target, results, chunks, topK: 3 });
120
+
121
+ assert.deepEqual(classified.tiers.map((tier) => tier.name), [
122
+ "same_file",
123
+ "same_module",
124
+ "same_feature_area",
125
+ "repo_wide",
126
+ ]);
127
+ assert.deepEqual(classified.tiers.map((tier) => tier.evidence.map((item) => item.id)), [
128
+ ["chunk:helper"],
129
+ ["chunk:error"],
130
+ ["chunk:config"],
131
+ ["file:docs/conventions.md"],
132
+ ]);
133
+ assert.equal(classified.tiers[0].evidence[0].path, "src/features/auth/controller.ts");
134
+ assert.equal(classified.tiers[0].evidence[0].start_line, 10);
135
+ assert.equal(classified.tiers[0].evidence[0].end_line, 18);
136
+ assert.deepEqual(classified.tiers[0].evidence[0].matched_rules, ["rule.repo_local_pattern_review"]);
137
+ assert.equal(classified.localPatternFound, true);
138
+ assert.equal(classified.fallbackUsed, false);
139
+ });
140
+
141
+ test("reports repository fallback without claiming a local pattern", () => {
142
+ const classified = classifyPatternEvidence({
143
+ target: {
144
+ input: "src/isolated.ts",
145
+ entity_id: "file:src/isolated.ts",
146
+ entity_type: "File",
147
+ path: "src/isolated.ts",
148
+ },
149
+ results: [
150
+ {
151
+ id: "file:docs/general-practices.md",
152
+ entity_type: "File",
153
+ kind: "DOC",
154
+ title: "docs/general-practices.md",
155
+ path: "docs/general-practices.md",
156
+ excerpt: "general fallback only",
157
+ },
158
+ ],
159
+ chunks: [],
160
+ topK: 3,
161
+ });
162
+
163
+ assert.equal(classified.localPatternFound, false);
164
+ assert.equal(classified.fallbackUsed, true);
165
+ assert.deepEqual(classified.tiers.slice(0, 3).flatMap((tier) => tier.evidence), []);
166
+ assert.equal(classified.tiers[3].evidence.length, 1);
167
+ });
168
+
169
+ test("filters chunk evidence when indexed line bounds are missing or invalid", () => {
170
+ const target = {
171
+ input: "src/a.ts",
172
+ entity_id: "file:src/a.ts",
173
+ entity_type: "File",
174
+ path: "src/a.ts",
175
+ };
176
+ const results = [
177
+ {
178
+ id: "chunk:missing",
179
+ entity_type: "Chunk",
180
+ kind: "function",
181
+ title: "missing",
182
+ path: "src/b.ts",
183
+ excerpt: "missing metadata",
184
+ },
185
+ {
186
+ id: "chunk:invalid",
187
+ entity_type: "Chunk",
188
+ kind: "function",
189
+ title: "invalid",
190
+ path: "src/c.ts",
191
+ excerpt: "invalid metadata",
192
+ },
193
+ ];
194
+
195
+ const classified = classifyPatternEvidence({
196
+ target,
197
+ results,
198
+ chunks: [chunk("chunk:invalid", "file:src/c.ts", 0, 0)],
199
+ topK: 3,
200
+ });
201
+
202
+ assert.deepEqual(classified.tiers.flatMap((tier) => tier.evidence), []);
203
+ assert.equal(classified.localPatternFound, false);
204
+ assert.equal(classified.fallbackUsed, false);
205
+ });
206
+
207
+ test("retrieves each locality tier before cutoff and stays deterministic", async () => {
208
+ const target = document("file:src/auth/handler.ts", "src/auth/handler.ts", "CODE");
209
+ const localFile = document("file:src/auth/config.ts", "src/auth/config.ts", "CODE");
210
+ const localChunk = {
211
+ ...chunk("chunk:local-config", localFile.id, 7, 14),
212
+ name: "parseLocalSetting",
213
+ body: "environment",
214
+ description: "module-local environment parsing",
215
+ };
216
+ const repoDocuments = Array.from({ length: 60 }, (_, index) =>
217
+ document(
218
+ `file:docs/pattern-${index}.md`,
219
+ `docs/pattern-${index}.md`,
220
+ "DOC",
221
+ "environment variable parsing configuration pattern",
222
+ ));
223
+ const data = contextData({ documents: [target, localFile, ...repoDocuments], chunks: [localChunk] });
224
+ const input = {
225
+ target: target.path,
226
+ query: "environment variable parsing configuration pattern",
227
+ top_k: 2,
228
+ include_deprecated: false,
229
+ };
230
+
231
+ const first = await runPatternEvidence(input, { data });
232
+ const second = await runPatternEvidence(input, { data });
233
+ const moduleTier = first.tiers.find((tier) => tier.name === "same_module");
234
+
235
+ assert.ok(moduleTier.evidence.some((evidence) => evidence.id === localChunk.id));
236
+ assert.equal(first.local_pattern_found, true);
237
+ assert.equal(first.ranking_reference_time, "2026-07-12T00:00:00.000Z");
238
+ assert.deepEqual(second, first);
239
+ });
240
+
241
+ test("runtime response exposes repository-only fallback and warning", async () => {
242
+ const target = document("file:src/isolated.ts", "src/isolated.ts", "CODE");
243
+ const fallback = document(
244
+ "file:docs/general-practices.md",
245
+ "docs/general-practices.md",
246
+ "DOC",
247
+ "general retry convention",
248
+ );
249
+ const result = await runPatternEvidence({
250
+ target: target.path,
251
+ query: "general retry convention",
252
+ top_k: 2,
253
+ include_deprecated: false,
254
+ }, {
255
+ data: contextData({ documents: [target, fallback] }),
256
+ });
257
+
258
+ assert.equal(result.local_pattern_found, false);
259
+ assert.equal(result.fallback_used, true);
260
+ assert.match(result.warning, /No applicable file-local, module-local, or feature-local pattern/);
261
+ assert.equal(result.tiers[3].evidence[0].id, fallback.id);
262
+ });
263
+
264
+ test("reference time calculation stays bounded for large indexes", () => {
265
+ const documents = Array.from({ length: 200_000 }, (_, index) => ({
266
+ updated_at: index === 199_999 ? "2026-07-13T00:00:00.000Z" : "2026-07-12T00:00:00.000Z",
267
+ }));
268
+ const data = contextData({ documents });
269
+
270
+ assert.equal(contextReferenceTimeMs(data), Date.parse("2026-07-13T00:00:00.000Z"));
271
+ });
272
+
273
+ test("equal-score evidence is stable across reversed source order", async () => {
274
+ const target = document("file:src/isolated.ts", "src/isolated.ts", "CODE");
275
+ const a = document("file:docs/a.md", "docs/a.md", "DOC", "shared convention");
276
+ const b = document("file:docs/b.md", "docs/b.md", "DOC", "shared convention");
277
+ const input = {
278
+ target: target.path,
279
+ query: "shared convention",
280
+ top_k: 1,
281
+ include_deprecated: false,
282
+ };
283
+
284
+ const forward = await runPatternEvidence(input, {
285
+ data: contextData({ documents: [target, a, b] }),
286
+ });
287
+ const reversed = await runPatternEvidence(input, {
288
+ data: contextData({ documents: [target, b, a] }),
289
+ });
290
+
291
+ assert.deepEqual(reversed, forward);
292
+ assert.equal(forward.tiers[3].evidence[0].id, a.id);
293
+ });
294
+
295
+ test("local-only pattern evidence uses lexical search without network fetch", async () => {
296
+ const target = document("file:src/a.ts", "src/a.ts", "CODE");
297
+ const fallback = document("file:docs/pattern.md", "docs/pattern.md", "DOC", "shared retry convention");
298
+ const originalFetch = globalThis.fetch;
299
+ let fetchCalls = 0;
300
+ globalThis.fetch = async () => {
301
+ fetchCalls += 1;
302
+ throw new Error("network access is forbidden in local review");
303
+ };
304
+
305
+ try {
306
+ const result = await runLocalPatternEvidence({
307
+ target: target.path,
308
+ query: "shared retry convention",
309
+ top_k: 1,
310
+ include_deprecated: false,
311
+ }, {
312
+ data: contextData({ documents: [target, fallback] }),
313
+ });
314
+
315
+ assert.equal(fetchCalls, 0);
316
+ assert.equal(result.semantic_engine, "lexical-only");
317
+ assert.equal(result.fallback_used, true);
318
+ } finally {
319
+ globalThis.fetch = originalFetch;
320
+ }
321
+ });
@@ -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));