@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.
- package/README.md +54 -0
- package/bin/cortex.mjs +31 -167
- package/package.json +2 -2
- package/scaffold/mcp/package-lock.json +24 -21
- package/scaffold/mcp/src/cli/query.ts +36 -2
- package/scaffold/mcp/src/embed.ts +211 -10
- package/scaffold/mcp/src/enterprise/index.ts +1 -1
- package/scaffold/mcp/src/enterprise/reviews/changed-files.ts +34 -0
- package/scaffold/mcp/src/enterprise/reviews/pattern-context.ts +231 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +49 -36
- package/scaffold/mcp/src/paths.ts +3 -5
- package/scaffold/mcp/src/patternEvidence.ts +347 -0
- package/scaffold/mcp/src/search.ts +35 -9
- package/scaffold/mcp/src/searchCore.ts +254 -12
- package/scaffold/mcp/src/searchResults.ts +96 -9
- package/scaffold/mcp/src/types.ts +7 -0
- package/scaffold/mcp/tests/changed-files.test.mjs +41 -0
- package/scaffold/mcp/tests/embed-entities.test.mjs +109 -1
- package/scaffold/mcp/tests/enterprise-pattern-context.test.mjs +322 -0
- package/scaffold/mcp/tests/paths.test.mjs +11 -3
- package/scaffold/mcp/tests/pattern-evidence.test.mjs +321 -0
- package/scaffold/mcp/tests/query-cli.test.mjs +73 -1
- package/scaffold/mcp/tests/search-graph-score.test.mjs +167 -0
- package/scaffold/scripts/dashboard.mjs +29 -18
- package/scaffold/scripts/doctor.sh +26 -8
- package/scaffold/scripts/ingest.mjs +37 -4
- package/scaffold/scripts/status.sh +13 -6
|
@@ -31,6 +31,31 @@ const MODEL_CACHE_DIR = PATHS.embeddingsModelCache;
|
|
|
31
31
|
const EMBEDDINGS_DIR = path.dirname(EMBEDDINGS_PATH);
|
|
32
32
|
|
|
33
33
|
export const DEFAULT_MODEL_ID = "jinaai/jina-embeddings-v2-base-code";
|
|
34
|
+
export const COMPACT_FILE_TEXT_STRATEGY = "compact_files_v1";
|
|
35
|
+
export const COMPACT_FILE_TEXT_THRESHOLD_CHARS = 32768;
|
|
36
|
+
export const COMPACT_FILE_TEXT_TARGET_CHARS = 16000;
|
|
37
|
+
|
|
38
|
+
const COMPACT_FILE_SIGNAL_BUDGET_CHARS = 4096;
|
|
39
|
+
const COMPACT_FILE_SIGNAL_MAX_LINE_CHARS = 512;
|
|
40
|
+
const COMPACT_FILE_MIN_HEAD_CHARS = 4096;
|
|
41
|
+
const COMPACT_FILE_MIN_TAIL_CHARS = 2048;
|
|
42
|
+
|
|
43
|
+
export type EmbedTextProfile = "full" | "compact-files";
|
|
44
|
+
|
|
45
|
+
type FileEmbeddingTextResult = {
|
|
46
|
+
text: string;
|
|
47
|
+
profile: EmbedTextProfile;
|
|
48
|
+
compacted: boolean;
|
|
49
|
+
original_chars: number;
|
|
50
|
+
text_chars: number;
|
|
51
|
+
omitted_chars: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ParseFileEntitiesOptions = {
|
|
55
|
+
textProfile?: EmbedTextProfile;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type SignatureEntityType = "File" | "Rule" | "ADR" | "Module" | "Project" | "Chunk";
|
|
34
59
|
|
|
35
60
|
export function resolveModelId(): string {
|
|
36
61
|
return (process.env.CORTEX_EMBED_MODEL ?? DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID;
|
|
@@ -48,6 +73,11 @@ type FileEntity = {
|
|
|
48
73
|
updated_at: string;
|
|
49
74
|
text: string;
|
|
50
75
|
signature: string;
|
|
76
|
+
text_profile: EmbedTextProfile;
|
|
77
|
+
text_compacted: boolean;
|
|
78
|
+
text_original_chars: number;
|
|
79
|
+
text_chars: number;
|
|
80
|
+
text_omitted_chars: number;
|
|
51
81
|
};
|
|
52
82
|
|
|
53
83
|
type RuleEntity = {
|
|
@@ -155,6 +185,129 @@ function normalizeText(value: string): string {
|
|
|
155
185
|
return value.replace(/\s+/g, " ").trim();
|
|
156
186
|
}
|
|
157
187
|
|
|
188
|
+
export function resolveEmbedTextProfile(raw = process.env.CORTEX_EMBED_TEXT_PROFILE): EmbedTextProfile {
|
|
189
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
190
|
+
if (!value || value === "full") {
|
|
191
|
+
return "full";
|
|
192
|
+
}
|
|
193
|
+
if (value === "compact-files") {
|
|
194
|
+
return "compact-files";
|
|
195
|
+
}
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Unsupported CORTEX_EMBED_TEXT_PROFILE=${JSON.stringify(raw)}; expected "full" or "compact-files"`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isSignalLine(line: string): boolean {
|
|
202
|
+
const trimmed = line.trim();
|
|
203
|
+
if (!trimmed) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return (
|
|
207
|
+
/^(import|export)\b/.test(trimmed) ||
|
|
208
|
+
/^(abstract\s+|async\s+|public\s+|private\s+|protected\s+|static\s+|readonly\s+|override\s+)*(class|interface|type|enum|function)\b/.test(trimmed) ||
|
|
209
|
+
/^(const|let|var)\s+[$A-Z_a-z][$\w]*\s*=/.test(trimmed) ||
|
|
210
|
+
/^(describe|it|test)\s*\(/.test(trimmed) ||
|
|
211
|
+
/^(@[A-Z_a-z][$\w]*|#[#\s])/.test(trimmed) ||
|
|
212
|
+
/^```[A-Za-z0-9_-]+/.test(trimmed) ||
|
|
213
|
+
/\b(route|router|endpoint|controller|handler|middleware|permission|auth|token|secret|security|todo|fixme)\b/i.test(trimmed)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function collectSignalLines(content: string, budgetChars: number): string {
|
|
218
|
+
const lines: string[] = [];
|
|
219
|
+
const seen = new Set<string>();
|
|
220
|
+
let used = 0;
|
|
221
|
+
|
|
222
|
+
for (const line of content.split(/\r?\n/)) {
|
|
223
|
+
if (!isSignalLine(line)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const normalized = line.trimEnd();
|
|
227
|
+
const signalLine = normalized.length > COMPACT_FILE_SIGNAL_MAX_LINE_CHARS
|
|
228
|
+
? `${normalized.slice(0, COMPACT_FILE_SIGNAL_MAX_LINE_CHARS)} [cortex ${COMPACT_FILE_TEXT_STRATEGY} signal_line_truncated_chars=${normalized.length - COMPACT_FILE_SIGNAL_MAX_LINE_CHARS}]`
|
|
229
|
+
: normalized;
|
|
230
|
+
if (seen.has(signalLine)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const next = used + signalLine.length + 1;
|
|
234
|
+
if (next > budgetChars) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
lines.push(signalLine);
|
|
238
|
+
seen.add(signalLine);
|
|
239
|
+
used = next;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function buildFileEmbeddingText(
|
|
246
|
+
filePath: string,
|
|
247
|
+
excerpt: string,
|
|
248
|
+
content: string,
|
|
249
|
+
profile: EmbedTextProfile = "full"
|
|
250
|
+
): FileEmbeddingTextResult {
|
|
251
|
+
const fullText = `${filePath}\n${excerpt}\n${content}`;
|
|
252
|
+
if (profile === "full" || fullText.length <= COMPACT_FILE_TEXT_THRESHOLD_CHARS) {
|
|
253
|
+
return {
|
|
254
|
+
text: fullText,
|
|
255
|
+
profile,
|
|
256
|
+
compacted: false,
|
|
257
|
+
original_chars: fullText.length,
|
|
258
|
+
text_chars: fullText.length,
|
|
259
|
+
omitted_chars: 0
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const signalText = collectSignalLines(content, COMPACT_FILE_SIGNAL_BUDGET_CHARS);
|
|
264
|
+
const markerPrefix = `[cortex ${COMPACT_FILE_TEXT_STRATEGY} omitted_chars=`;
|
|
265
|
+
const staticChars =
|
|
266
|
+
filePath.length +
|
|
267
|
+
excerpt.length +
|
|
268
|
+
signalText.length +
|
|
269
|
+
markerPrefix.length +
|
|
270
|
+
64;
|
|
271
|
+
const available = Math.max(
|
|
272
|
+
COMPACT_FILE_MIN_HEAD_CHARS + COMPACT_FILE_MIN_TAIL_CHARS,
|
|
273
|
+
COMPACT_FILE_TEXT_TARGET_CHARS - staticChars
|
|
274
|
+
);
|
|
275
|
+
const headChars = Math.max(COMPACT_FILE_MIN_HEAD_CHARS, Math.floor(available * 0.62));
|
|
276
|
+
const tailChars = Math.max(COMPACT_FILE_MIN_TAIL_CHARS, available - headChars);
|
|
277
|
+
const head = content.slice(0, headChars);
|
|
278
|
+
const tail = content.slice(-tailChars);
|
|
279
|
+
const omittedChars = Math.max(0, content.length - head.length - tail.length);
|
|
280
|
+
const marker = `${markerPrefix}${omittedChars}]`;
|
|
281
|
+
const compactText = [
|
|
282
|
+
filePath,
|
|
283
|
+
excerpt,
|
|
284
|
+
head,
|
|
285
|
+
marker,
|
|
286
|
+
signalText ? `[cortex ${COMPACT_FILE_TEXT_STRATEGY} signal_lines]\n${signalText}` : "",
|
|
287
|
+
tail
|
|
288
|
+
].filter((part) => part.length > 0).join("\n");
|
|
289
|
+
|
|
290
|
+
if (compactText.length >= fullText.length) {
|
|
291
|
+
return {
|
|
292
|
+
text: fullText,
|
|
293
|
+
profile,
|
|
294
|
+
compacted: false,
|
|
295
|
+
original_chars: fullText.length,
|
|
296
|
+
text_chars: fullText.length,
|
|
297
|
+
omitted_chars: 0
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
text: compactText,
|
|
303
|
+
profile,
|
|
304
|
+
compacted: true,
|
|
305
|
+
original_chars: fullText.length,
|
|
306
|
+
text_chars: compactText.length,
|
|
307
|
+
omitted_chars: fullText.length - compactText.length
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
158
311
|
function ensureRequiredFiles(): void {
|
|
159
312
|
const required = [
|
|
160
313
|
path.join(CACHE_DIR, "documents.jsonl"),
|
|
@@ -169,7 +322,8 @@ function ensureRequiredFiles(): void {
|
|
|
169
322
|
}
|
|
170
323
|
}
|
|
171
324
|
|
|
172
|
-
export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
|
|
325
|
+
export function parseFileEntities(raw: JsonObject[], options: ParseFileEntitiesOptions = {}): FileEntity[] {
|
|
326
|
+
const textProfile = options.textProfile ?? "full";
|
|
173
327
|
return raw
|
|
174
328
|
.map((item) => {
|
|
175
329
|
const id = asString(item.id);
|
|
@@ -182,7 +336,7 @@ export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
|
|
|
182
336
|
const excerpt = asString(item.excerpt);
|
|
183
337
|
const updatedAt = asString(item.updated_at);
|
|
184
338
|
const checksum = asString(item.checksum, hashText(content));
|
|
185
|
-
const
|
|
339
|
+
const embeddingText = buildFileEmbeddingText(filePath, excerpt, content, textProfile);
|
|
186
340
|
|
|
187
341
|
return {
|
|
188
342
|
id,
|
|
@@ -194,8 +348,13 @@ export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
|
|
|
194
348
|
source_of_truth: asBoolean(item.source_of_truth, false),
|
|
195
349
|
trust_level: asNumber(item.trust_level, 50),
|
|
196
350
|
updated_at: updatedAt,
|
|
197
|
-
text,
|
|
198
|
-
signature: hashText(`file|${checksum}|${updatedAt}|${hashText(text)}`)
|
|
351
|
+
text: embeddingText.text,
|
|
352
|
+
signature: hashText(`file|${checksum}|${updatedAt}|${hashText(embeddingText.text)}`),
|
|
353
|
+
text_profile: embeddingText.profile,
|
|
354
|
+
text_compacted: embeddingText.compacted,
|
|
355
|
+
text_original_chars: embeddingText.original_chars,
|
|
356
|
+
text_chars: embeddingText.text_chars,
|
|
357
|
+
text_omitted_chars: embeddingText.omitted_chars
|
|
199
358
|
};
|
|
200
359
|
})
|
|
201
360
|
.filter((value): value is FileEntity => value !== null);
|
|
@@ -408,8 +567,24 @@ function roundVector(values: number[]): number[] {
|
|
|
408
567
|
return values.map((value) => Number(value.toFixed(6)));
|
|
409
568
|
}
|
|
410
569
|
|
|
411
|
-
function resolveSignatureProfile(
|
|
412
|
-
|
|
570
|
+
export function resolveSignatureProfile(
|
|
571
|
+
maxTokenCap: number | null,
|
|
572
|
+
textProfile: EmbedTextProfile = "full",
|
|
573
|
+
entityType?: SignatureEntityType
|
|
574
|
+
): string {
|
|
575
|
+
const parts: string[] = [];
|
|
576
|
+
if (maxTokenCap) {
|
|
577
|
+
parts.push(`max_tokens=${maxTokenCap}`);
|
|
578
|
+
}
|
|
579
|
+
if (textProfile === "compact-files" && (entityType === undefined || entityType === "File")) {
|
|
580
|
+
parts.push(
|
|
581
|
+
"text_profile=compact-files",
|
|
582
|
+
COMPACT_FILE_TEXT_STRATEGY,
|
|
583
|
+
`threshold_chars=${COMPACT_FILE_TEXT_THRESHOLD_CHARS}`,
|
|
584
|
+
`target_chars=${COMPACT_FILE_TEXT_TARGET_CHARS}`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return parts.length ? `embed|${parts.join("|")}` : "";
|
|
413
588
|
}
|
|
414
589
|
|
|
415
590
|
function embeddingSignature(entitySignature: string, profile: string): string {
|
|
@@ -434,8 +609,19 @@ async function main(): Promise<void> {
|
|
|
434
609
|
fs.mkdirSync(MODEL_CACHE_DIR, { recursive: true });
|
|
435
610
|
|
|
436
611
|
const modelId = resolveModelId();
|
|
437
|
-
|
|
438
|
-
|
|
612
|
+
const textProfile = resolveEmbedTextProfile();
|
|
613
|
+
|
|
614
|
+
const documents = parseFileEntities(readJsonl(path.join(CACHE_DIR, "documents.jsonl")), { textProfile });
|
|
615
|
+
const textProfileStats = {
|
|
616
|
+
strategy: textProfile === "compact-files" ? COMPACT_FILE_TEXT_STRATEGY : null,
|
|
617
|
+
threshold_chars: textProfile === "compact-files" ? COMPACT_FILE_TEXT_THRESHOLD_CHARS : null,
|
|
618
|
+
target_chars: textProfile === "compact-files" ? COMPACT_FILE_TEXT_TARGET_CHARS : null,
|
|
619
|
+
file_entities: documents.length,
|
|
620
|
+
compacted_files: documents.filter((doc) => doc.text_compacted).length,
|
|
621
|
+
original_chars: documents.reduce((total, doc) => total + doc.text_original_chars, 0),
|
|
622
|
+
text_chars: documents.reduce((total, doc) => total + doc.text_chars, 0),
|
|
623
|
+
saved_chars: documents.reduce((total, doc) => total + doc.text_omitted_chars, 0)
|
|
624
|
+
};
|
|
439
625
|
const rules = parseRuleEntities(readJsonl(path.join(CACHE_DIR, "entities.rule.jsonl")));
|
|
440
626
|
const adrs = parseAdrEntities(readJsonl(path.join(CACHE_DIR, "entities.adr.jsonl")));
|
|
441
627
|
const modules = parseModuleEntities(readJsonl(path.join(CACHE_DIR, "entities.module.jsonl")));
|
|
@@ -483,7 +669,10 @@ async function main(): Promise<void> {
|
|
|
483
669
|
memoryBytes: memoryHeadroom,
|
|
484
670
|
sessions: previewPoolConfig.sessions
|
|
485
671
|
});
|
|
486
|
-
const
|
|
672
|
+
const defaultSignatureProfile = resolveSignatureProfile(tokenBudget.cap, "full");
|
|
673
|
+
const fileSignatureProfile = resolveSignatureProfile(tokenBudget.cap, textProfile, "File");
|
|
674
|
+
const signatureProfileForEntity = (entity: SearchEntity) =>
|
|
675
|
+
entity.type === "File" ? fileSignatureProfile : defaultSignatureProfile;
|
|
487
676
|
|
|
488
677
|
const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
|
|
489
678
|
|
|
@@ -494,6 +683,7 @@ async function main(): Promise<void> {
|
|
|
494
683
|
let dimensions = 0;
|
|
495
684
|
|
|
496
685
|
entities.forEach((entity, index) => {
|
|
686
|
+
const signatureProfile = signatureProfileForEntity(entity);
|
|
497
687
|
const signature = embeddingSignature(entity.signature, signatureProfile);
|
|
498
688
|
const previous = existing.get(entity.id);
|
|
499
689
|
if (previous && previous.signature === signature && previous.vector.length > 0) {
|
|
@@ -628,6 +818,7 @@ async function main(): Promise<void> {
|
|
|
628
818
|
maxInFlightTokens,
|
|
629
819
|
onVector(index, rawVector) {
|
|
630
820
|
const entity = entities[index];
|
|
821
|
+
const signatureProfile = signatureProfileForEntity(entity);
|
|
631
822
|
const vector = roundVector(rawVector);
|
|
632
823
|
embedded += 1;
|
|
633
824
|
dimensions = dimensions || vector.length;
|
|
@@ -662,6 +853,13 @@ async function main(): Promise<void> {
|
|
|
662
853
|
mode,
|
|
663
854
|
model: modelId,
|
|
664
855
|
dimensions,
|
|
856
|
+
text_profile: textProfile,
|
|
857
|
+
signature_profile: fileSignatureProfile === defaultSignatureProfile ? defaultSignatureProfile : "per_entity",
|
|
858
|
+
signature_profiles: {
|
|
859
|
+
default: defaultSignatureProfile,
|
|
860
|
+
file: fileSignatureProfile
|
|
861
|
+
},
|
|
862
|
+
text_profile_stats: textProfileStats,
|
|
665
863
|
counts: {
|
|
666
864
|
entities: entities.length,
|
|
667
865
|
output: outputCount,
|
|
@@ -675,7 +873,10 @@ async function main(): Promise<void> {
|
|
|
675
873
|
fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
676
874
|
|
|
677
875
|
console.log(
|
|
678
|
-
`[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason}`
|
|
876
|
+
`[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason} text_profile=${textProfile}`
|
|
877
|
+
);
|
|
878
|
+
console.log(
|
|
879
|
+
`[embed] text_profile=${textProfile} compacted_files=${textProfileStats.compacted_files}/${textProfileStats.file_entities} saved_chars=${textProfileStats.saved_chars}`
|
|
679
880
|
);
|
|
680
881
|
console.log(
|
|
681
882
|
`[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
|
|
@@ -21,7 +21,7 @@ import { pushWorkflowSnapshot, setWorkflowPushContext } from "./workflow/push.js
|
|
|
21
21
|
import { hasWorkflowState, loadWorkflowState } from "./workflow/state.js";
|
|
22
22
|
|
|
23
23
|
const require = createRequire(import.meta.url);
|
|
24
|
-
const pkg = require("
|
|
24
|
+
const pkg = require("../../package.json") as { version: string };
|
|
25
25
|
|
|
26
26
|
export const name = "cortex-enterprise";
|
|
27
27
|
export const version: string = pkg.version;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function gitLines(projectRoot: string, args: string[]): string[] {
|
|
4
|
+
const output = execFileSync("git", args, {
|
|
5
|
+
cwd: projectRoot,
|
|
6
|
+
encoding: "utf8",
|
|
7
|
+
timeout: 5000,
|
|
8
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
9
|
+
});
|
|
10
|
+
return output.split("\n").map((value) => value.trim()).filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function trackedChangedFiles(projectRoot: string): string[] {
|
|
14
|
+
try {
|
|
15
|
+
return gitLines(projectRoot, ["diff", "--name-only", "HEAD"]);
|
|
16
|
+
} catch {
|
|
17
|
+
// No HEAD yet (repo without commits): every tracked file is new.
|
|
18
|
+
return [
|
|
19
|
+
...gitLines(projectRoot, ["diff", "--name-only"]),
|
|
20
|
+
...gitLines(projectRoot, ["ls-files"]),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveChangedReviewFiles(projectRoot: string): string[] | null {
|
|
26
|
+
try {
|
|
27
|
+
gitLines(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
|
|
28
|
+
const tracked = trackedChangedFiles(projectRoot);
|
|
29
|
+
const untracked = gitLines(projectRoot, ["ls-files", "--others", "--exclude-standard"]);
|
|
30
|
+
return [...new Set([...tracked, ...untracked])].sort();
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -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
|
+
}
|