@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,231 @@
1
+ import { loadContextData } from "../../graph.js";
2
+ import { normalizeRepoPath, runLocalPatternEvidence } from "../../patternEvidence.js";
3
+ import { compareText } from "../../searchResults.js";
4
+ import type { ContextData, PatternEvidenceParams, ToolPayload } from "../../types.js";
5
+
6
+ type PatternEvidenceRunner = (input: PatternEvidenceParams) => Promise<ToolPayload>;
7
+
8
+ // Loads context data lazily on first use and shares it across all targets in
9
+ // one review, instead of re-reading the index from disk per target. Load
10
+ // failures surface per target, matching the per-file error handling below.
11
+ export function createLocalPatternRunner(
12
+ loadData: () => Promise<ContextData> = loadContextData,
13
+ ): PatternEvidenceRunner {
14
+ let shared: Promise<ContextData> | undefined;
15
+ return async (params) => {
16
+ shared ??= loadData();
17
+ return runLocalPatternEvidence(params, { data: await shared });
18
+ };
19
+ }
20
+
21
+ export const PATTERN_REVIEW_QUESTION =
22
+ "Does this change follow the established pattern for this kind of problem in this repository, or does it introduce a second way to solve something that already has a local convention?";
23
+
24
+ const PATTERN_EVIDENCE_ORDER = ["same_file", "same_module", "same_feature_area", "repo_wide"] as const;
25
+
26
+ // Canonicalizes like the pattern evidence engine, then rejects paths that
27
+ // must never appear in review citations (absolute, drive-letter, or
28
+ // parent-escaping paths).
29
+ function normalizeReviewPath(value: string): string | null {
30
+ const normalized = normalizeRepoPath(value);
31
+ if (
32
+ !normalized ||
33
+ normalized.startsWith("/") ||
34
+ /^[A-Za-z]:\//.test(normalized) ||
35
+ normalized.split("/").includes("..")
36
+ ) {
37
+ return null;
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ function safeIdentifier(value: unknown): string | null {
43
+ if (typeof value !== "string" || value.length === 0 || value.length > 1000) return null;
44
+ if (/(?:^|:)\/|[A-Za-z]:[\\/]|(?:^|[:/])\.\.(?:[:/]|$)/u.test(value)) return null;
45
+ return value;
46
+ }
47
+
48
+ function emptyTiers(): ToolPayload[] {
49
+ return PATTERN_EVIDENCE_ORDER.map((name) => ({ name, evidence: [] }));
50
+ }
51
+
52
+ function sanitizeEvidenceTiers(value: unknown): { tiers: ToolPayload[]; dropped: number } {
53
+ const inputTiers = Array.isArray(value) ? value : [];
54
+ let dropped = 0;
55
+ const tiers = PATTERN_EVIDENCE_ORDER.map((name) => {
56
+ const source = inputTiers.find((candidate) =>
57
+ candidate && typeof candidate === "object" && (candidate as Record<string, unknown>).name === name
58
+ ) as Record<string, unknown> | undefined;
59
+ const rawEvidence = Array.isArray(source?.evidence) ? source.evidence : [];
60
+ const evidence: ToolPayload[] = [];
61
+ for (const candidate of rawEvidence) {
62
+ if (!candidate || typeof candidate !== "object") {
63
+ dropped += 1;
64
+ continue;
65
+ }
66
+ const row = candidate as Record<string, unknown>;
67
+ const citationPath = typeof row.path === "string" ? normalizeReviewPath(row.path) : null;
68
+ const id = safeIdentifier(row.id);
69
+ if (!citationPath || !id) {
70
+ dropped += 1;
71
+ continue;
72
+ }
73
+ evidence.push({
74
+ id,
75
+ entity_type: typeof row.entity_type === "string" ? row.entity_type.slice(0, 100) : "",
76
+ kind: typeof row.kind === "string" ? row.kind.slice(0, 100) : "",
77
+ title: typeof row.title === "string" ? row.title.slice(0, 500) : id,
78
+ path: citationPath,
79
+ start_line: Number.isInteger(row.start_line) ? row.start_line : null,
80
+ end_line: Number.isInteger(row.end_line) ? row.end_line : null,
81
+ excerpt: typeof row.excerpt === "string" ? row.excerpt.slice(0, 1000) : "",
82
+ score: typeof row.score === "number" && Number.isFinite(row.score) ? row.score : null,
83
+ matched_rules: Array.isArray(row.matched_rules)
84
+ ? row.matched_rules.filter((item): item is string => typeof item === "string").slice(0, 50)
85
+ : [],
86
+ });
87
+ }
88
+ return { name, evidence };
89
+ });
90
+ return { tiers, dropped };
91
+ }
92
+
93
+ function emptyTarget(file: string, status: "not_indexed" | "error", query?: string): ToolPayload {
94
+ return {
95
+ path: file,
96
+ status,
97
+ review_question: PATTERN_REVIEW_QUESTION,
98
+ query: query ?? null,
99
+ query_source: query ? "explicit" : null,
100
+ local_pattern_found: false,
101
+ fallback_used: false,
102
+ evidence_order: [...PATTERN_EVIDENCE_ORDER],
103
+ tiers: emptyTiers(),
104
+ warning: null,
105
+ message: status === "not_indexed"
106
+ ? "Target is not present as a file-backed entity in the current Cortex index. Run cortex update before reviewing it."
107
+ : "Pattern evidence could not be produced for this target.",
108
+ citations_dropped: 0,
109
+ };
110
+ }
111
+
112
+ function disabledPatternReview(requested: number, limit: number, topK: number): ToolPayload {
113
+ return {
114
+ enabled: false,
115
+ non_blocking: true,
116
+ affects_policy_summary: false,
117
+ review_question: PATTERN_REVIEW_QUESTION,
118
+ limit,
119
+ top_k_per_tier: topK,
120
+ targets: [],
121
+ summary: {
122
+ requested,
123
+ eligible: 0,
124
+ analyzed: 0,
125
+ local_evidence: 0,
126
+ repo_fallback: 0,
127
+ no_evidence: 0,
128
+ not_indexed: 0,
129
+ errors: 0,
130
+ omitted: 0,
131
+ invalid_paths: 0,
132
+ },
133
+ };
134
+ }
135
+
136
+ export async function buildPatternReviewContext(input: {
137
+ files: string[];
138
+ enabled: boolean;
139
+ query?: string;
140
+ topK: number;
141
+ limit: number;
142
+ runner?: PatternEvidenceRunner;
143
+ }): Promise<ToolPayload> {
144
+ if (!input.enabled) {
145
+ return disabledPatternReview(input.files.length, input.limit, input.topK);
146
+ }
147
+
148
+ const normalized = input.files.map(normalizeReviewPath);
149
+ const invalidPaths = normalized.filter((value) => value === null).length;
150
+ const eligible = [...new Set(normalized.filter((value): value is string => value !== null))]
151
+ .sort(compareText);
152
+ const selected = eligible.slice(0, input.limit);
153
+ const runner = input.runner ?? createLocalPatternRunner();
154
+ const targets: ToolPayload[] = [];
155
+ const counts = {
156
+ local_evidence: 0,
157
+ repo_fallback: 0,
158
+ no_evidence: 0,
159
+ not_indexed: 0,
160
+ errors: 0,
161
+ };
162
+
163
+ for (const file of selected) {
164
+ try {
165
+ const evidence = await runner({
166
+ target: file,
167
+ query: input.query,
168
+ top_k: input.topK,
169
+ include_deprecated: false,
170
+ });
171
+ const sanitized = sanitizeEvidenceTiers(evidence.tiers);
172
+ const localPatternFound = sanitized.tiers.slice(0, 3).some((tier) =>
173
+ Array.isArray(tier.evidence) && tier.evidence.length > 0
174
+ );
175
+ const repoEvidence = sanitized.tiers[3].evidence;
176
+ const fallbackUsed = !localPatternFound && Array.isArray(repoEvidence) && repoEvidence.length > 0;
177
+ const status = localPatternFound
178
+ ? "local_evidence"
179
+ : fallbackUsed
180
+ ? "repo_fallback"
181
+ : "no_evidence";
182
+ counts[status] += 1;
183
+ const warning = status === "local_evidence"
184
+ ? typeof evidence.warning === "string" ? "Pattern evidence completed with local runtime warnings." : null
185
+ : status === "repo_fallback"
186
+ ? "No applicable local pattern evidence was found; repository-wide fallback evidence is provided."
187
+ : "No applicable local pattern evidence was found.";
188
+ targets.push({
189
+ path: file,
190
+ status,
191
+ review_question: PATTERN_REVIEW_QUESTION,
192
+ query: typeof evidence.query === "string" ? evidence.query.slice(0, 1000) : input.query ?? null,
193
+ query_source: evidence.query_source === "explicit" || evidence.query_source === "derived_from_target"
194
+ ? evidence.query_source
195
+ : input.query ? "explicit" : null,
196
+ local_pattern_found: localPatternFound,
197
+ fallback_used: fallbackUsed,
198
+ evidence_order: [...PATTERN_EVIDENCE_ORDER],
199
+ tiers: sanitized.tiers,
200
+ warning,
201
+ message: null,
202
+ citations_dropped: sanitized.dropped,
203
+ });
204
+ } catch (error) {
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ const notIndexed = /not found in indexed context|not file-backed/u.test(message);
207
+ const status = notIndexed ? "not_indexed" : "error";
208
+ counts[notIndexed ? "not_indexed" : "errors"] += 1;
209
+ targets.push(emptyTarget(file, status, input.query));
210
+ }
211
+ }
212
+
213
+ return {
214
+ enabled: true,
215
+ non_blocking: true,
216
+ affects_policy_summary: false,
217
+ review_question: PATTERN_REVIEW_QUESTION,
218
+ query: input.query ?? null,
219
+ limit: input.limit,
220
+ top_k_per_tier: input.topK,
221
+ targets,
222
+ summary: {
223
+ requested: input.files.length,
224
+ eligible: eligible.length,
225
+ analyzed: selected.length,
226
+ ...counts,
227
+ omitted: Math.max(0, eligible.length - selected.length),
228
+ invalid_paths: invalidPaths,
229
+ },
230
+ };
231
+ }
@@ -4,6 +4,7 @@ import { readFileSync, statSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
6
  import { walkProjectFiles } from "./walk.js";
7
+ import { resolveChangedReviewFiles } from "../reviews/changed-files.js";
7
8
  import type { EnterpriseConfig } from "../../core/config.js";
8
9
  import type { TelemetryCollector } from "../../core/telemetry/collector.js";
9
10
  import type { AuditWriter } from "../../core/audit/writer.js";
@@ -16,6 +17,7 @@ import { queueViolation } from "../violations/push.js";
16
17
  import { getReviewPushContext, queueReviewResult } from "../reviews/push.js";
17
18
  import { partitionReviewPolicies } from "../reviews/policy-selection.js";
18
19
  import { recordQueuedReviewTrustState } from "../reviews/trust-state.js";
20
+ import { buildPatternReviewContext } from "../reviews/pattern-context.js";
19
21
  import { pushWorkflowSnapshot } from "../workflow/push.js";
20
22
  import { OUTBOUND_DATA_BOUNDARY } from "../privacy/boundary.js";
21
23
  import {
@@ -41,6 +43,25 @@ type ToolPayload = Record<string, unknown>;
41
43
 
42
44
  const VALID_ROLES = new Set<Role>(["admin", "developer", "readonly"]);
43
45
 
46
+ export function buildContextReviewAuditInput(input: {
47
+ scope: "all" | "changed";
48
+ include_passed: boolean;
49
+ include_pattern_evidence: boolean;
50
+ pattern_query?: string;
51
+ pattern_top_k: number;
52
+ pattern_limit: number;
53
+ }): Record<string, unknown> {
54
+ return {
55
+ scope: input.scope,
56
+ include_passed: input.include_passed,
57
+ include_pattern_evidence: input.include_pattern_evidence,
58
+ pattern_query_present: Boolean(input.pattern_query),
59
+ pattern_query_length: input.pattern_query?.length ?? 0,
60
+ pattern_top_k: input.pattern_top_k,
61
+ pattern_limit: input.pattern_limit,
62
+ };
63
+ }
64
+
44
65
  function snapshotReviewedFiles(
45
66
  projectRoot: string,
46
67
  changedFiles: string[] | undefined,
@@ -828,12 +849,20 @@ export function registerEnterpriseTools(
828
849
  description:
829
850
  "Run enterprise policy validators against the current project. " +
830
851
  "Checks enforced policies (test coverage, file size, external API calls, code review) " +
831
- "and returns pass/fail results with actionable details.",
852
+ "and returns pass/fail results plus non-blocking repo-local pattern evidence.",
832
853
  inputSchema: z.object({
833
854
  scope: z.enum(["all", "changed"]).default("changed")
834
855
  .describe("'changed' validates only git-modified files; 'all' validates everything"),
835
856
  include_passed: z.boolean().default(true)
836
857
  .describe("Include passing validators in results"),
858
+ include_pattern_evidence: z.boolean().default(true)
859
+ .describe("Include bounded, local-only repo pattern context without changing pass/fail"),
860
+ pattern_query: z.string().trim().min(1).max(1000).optional()
861
+ .describe("Optional shared pattern query; omitted queries are derived per target"),
862
+ pattern_top_k: z.number().int().min(1).max(5).default(2)
863
+ .describe("Maximum evidence items returned per locality tier and target"),
864
+ pattern_limit: z.number().int().min(1).max(25).default(10)
865
+ .describe("Maximum review targets analyzed for pattern evidence"),
837
866
  }),
838
867
  },
839
868
  async (input) => {
@@ -844,6 +873,10 @@ export function registerEnterpriseTools(
844
873
  const parsed = z.object({
845
874
  scope: z.enum(["all", "changed"]).default("changed"),
846
875
  include_passed: z.boolean().default(true),
876
+ include_pattern_evidence: z.boolean().default(true),
877
+ pattern_query: z.string().trim().min(1).max(1000).optional(),
878
+ pattern_top_k: z.number().int().min(1).max(5).default(2),
879
+ pattern_limit: z.number().int().min(1).max(25).default(10),
847
880
  }).parse(input ?? {});
848
881
 
849
882
  const projectRoot = process.env.CORTEX_PROJECT_ROOT?.trim() || process.cwd();
@@ -859,40 +892,7 @@ export function registerEnterpriseTools(
859
892
  // project review; no git dependency.
860
893
  let changedFiles: string[] | undefined;
861
894
  if (parsed.scope === "changed") {
862
- // Use `git rev-parse --is-inside-work-tree` to distinguish
863
- // "valid repo, nothing changed" (→ empty list, reviewer sees
864
- // nothing to scan) from "not a repo / git broken" (→ walk the
865
- // project so scope=changed still returns meaningful results
866
- // when run outside a git checkout).
867
- let inGitRepo = false;
868
- try {
869
- const { execSync } = await import("node:child_process");
870
- execSync("git rev-parse --is-inside-work-tree", {
871
- cwd: projectRoot,
872
- encoding: "utf8",
873
- timeout: 3000,
874
- stdio: ["ignore", "pipe", "ignore"],
875
- });
876
- inGitRepo = true;
877
- } catch {
878
- inGitRepo = false;
879
- }
880
-
881
- if (inGitRepo) {
882
- try {
883
- const { execSync } = await import("node:child_process");
884
- const output = execSync("git diff --name-only HEAD 2>/dev/null || git diff --name-only", {
885
- cwd: projectRoot,
886
- encoding: "utf8",
887
- timeout: 5000,
888
- });
889
- changedFiles = output.split("\n").map((f) => f.trim()).filter(Boolean);
890
- } catch {
891
- changedFiles = [];
892
- }
893
- } else {
894
- changedFiles = walkProjectFiles(projectRoot);
895
- }
895
+ changedFiles = resolveChangedReviewFiles(projectRoot) ?? walkProjectFiles(projectRoot);
896
896
  } else {
897
897
  changedFiles = walkProjectFiles(projectRoot);
898
898
  }
@@ -914,6 +914,14 @@ export function registerEnterpriseTools(
914
914
  projectRoot,
915
915
  changedFiles,
916
916
  }, config.validators);
917
+ const patternReview = await buildPatternReviewContext({
918
+ files: changedFiles ?? [],
919
+ enabled: parsed.include_pattern_evidence,
920
+ query: parsed.pattern_query,
921
+ topK: parsed.pattern_top_k,
922
+ limit: parsed.pattern_limit,
923
+ });
924
+ const patternSummary = patternReview.summary as Record<string, unknown>;
917
925
 
918
926
  // Filter out passed if requested
919
927
  const results = parsed.include_passed
@@ -944,7 +952,7 @@ export function registerEnterpriseTools(
944
952
  recordToolActivity({
945
953
  timestamp: now,
946
954
  tool: "context.review",
947
- input: parsed as Record<string, unknown>,
955
+ input: buildContextReviewAuditInput(parsed),
948
956
  result_count: output.results.length,
949
957
  entities_returned: output.results.map((r) => r.policy_id),
950
958
  rules_applied: output.results.filter((r) => !r.pass).map((r) => r.policy_id),
@@ -958,6 +966,10 @@ export function registerEnterpriseTools(
958
966
  failed: output.summary.failed,
959
967
  warnings: output.results.filter((r) => !r.pass && r.severity === "warning").length,
960
968
  skipped: skippedPolicies.length,
969
+ pattern_analyzed: patternSummary.analyzed ?? 0,
970
+ pattern_local_evidence: patternSummary.local_evidence ?? 0,
971
+ pattern_repo_fallback: patternSummary.repo_fallback ?? 0,
972
+ pattern_omitted: patternSummary.omitted ?? 0,
961
973
  },
962
974
  });
963
975
 
@@ -1018,6 +1030,7 @@ export function registerEnterpriseTools(
1018
1030
  workflow_source: trustState.workflow.source,
1019
1031
  delivery: trustState.delivery,
1020
1032
  trust_warnings: trustState.trust_warnings,
1033
+ pattern_review: patternReview,
1021
1034
  });
1022
1035
  },
1023
1036
  );
@@ -98,12 +98,10 @@ export const PATHS = {
98
98
  transformsConfigRelations: path.join(CACHE_DIR, "relations.transforms_config.jsonl")
99
99
  };
100
100
 
101
- // Graph degree mostly measures how many rules constrain an entity (docs are
102
- // hubs, leaf code is not), so it gets low weight. Tuned together with the
103
- // midrank-percentile graph_score in searchResults.ts.
101
+ // Tuned together with the midrank-percentile graph_score in searchResults.ts.
104
102
  export const DEFAULT_RANKING: RankingWeights = {
105
- semantic: 0.55,
106
- graph: 0.1,
103
+ semantic: 0.4,
104
+ graph: 0.25,
107
105
  trust: 0.2,
108
106
  recency: 0.15
109
107
  };