@kage-core/kage-graph-mcp 1.1.24 → 1.1.26
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 +41 -2
- package/dist/cli.js +203 -0
- package/dist/daemon.js +19 -1
- package/dist/index.js +208 -0
- package/dist/kernel.js +1606 -25
- package/package.json +1 -1
- package/viewer/app.js +238 -2
- package/viewer/index.html +16 -2
- package/viewer/styles.css +102 -7
package/dist/kernel.js
CHANGED
|
@@ -77,6 +77,16 @@ exports.gcProject = gcProject;
|
|
|
77
77
|
exports.installAgentPolicy = installAgentPolicy;
|
|
78
78
|
exports.recall = recall;
|
|
79
79
|
exports.queryCodeGraph = queryCodeGraph;
|
|
80
|
+
exports.kageRisk = kageRisk;
|
|
81
|
+
exports.kageDependencyPath = kageDependencyPath;
|
|
82
|
+
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
83
|
+
exports.kageReviewerSuggestions = kageReviewerSuggestions;
|
|
84
|
+
exports.kageContributors = kageContributors;
|
|
85
|
+
exports.kageDecisionIntelligence = kageDecisionIntelligence;
|
|
86
|
+
exports.kageModuleHealth = kageModuleHealth;
|
|
87
|
+
exports.kageGraphInsights = kageGraphInsights;
|
|
88
|
+
exports.kageWorkspace = kageWorkspace;
|
|
89
|
+
exports.kageWorkspaceRecall = kageWorkspaceRecall;
|
|
80
90
|
exports.queryGraph = queryGraph;
|
|
81
91
|
exports.graphMermaid = graphMermaid;
|
|
82
92
|
exports.kageMetrics = kageMetrics;
|
|
@@ -460,6 +470,12 @@ function estimateTokens(text) {
|
|
|
460
470
|
function packetText(packet) {
|
|
461
471
|
return `${packet.title}\n${packet.summary}\n${packet.body}\n${packet.type}\n${packet.tags.join(" ")}\n${packet.paths.join(" ")}`;
|
|
462
472
|
}
|
|
473
|
+
function isGeneratedChangeMemory(packet) {
|
|
474
|
+
return packet.type === "workflow"
|
|
475
|
+
&& packet.tags.includes("change-memory")
|
|
476
|
+
&& packet.tags.includes("diff-proposal")
|
|
477
|
+
&& packet.source_refs.some((ref) => ref.kind === "git_diff");
|
|
478
|
+
}
|
|
463
479
|
function tokenSet(text) {
|
|
464
480
|
return new Set(tokenize(text).filter((term) => term.length > 2));
|
|
465
481
|
}
|
|
@@ -476,6 +492,7 @@ function duplicateCandidates(projectDir, packet, threshold = 0.58) {
|
|
|
476
492
|
const current = tokenSet(packetText(packet));
|
|
477
493
|
return [...loadApprovedPackets(projectDir), ...loadPendingPackets(projectDir)]
|
|
478
494
|
.filter((candidate) => candidate.id !== packet.id)
|
|
495
|
+
.filter((candidate) => !(isGeneratedChangeMemory(packet) && isGeneratedChangeMemory(candidate)))
|
|
479
496
|
.map((candidate) => ({ packet: candidate, score: jaccard(current, tokenSet(packetText(candidate))) }))
|
|
480
497
|
.filter((entry) => entry.score >= threshold)
|
|
481
498
|
.sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title))
|
|
@@ -888,6 +905,30 @@ function readGit(projectDir, args) {
|
|
|
888
905
|
return null;
|
|
889
906
|
}
|
|
890
907
|
}
|
|
908
|
+
function safeStat(path) {
|
|
909
|
+
try {
|
|
910
|
+
return (0, node_fs_1.statSync)(path);
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
function safeLstat(path) {
|
|
917
|
+
try {
|
|
918
|
+
return (0, node_fs_1.lstatSync)(path);
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function safeReadText(path) {
|
|
925
|
+
try {
|
|
926
|
+
return (0, node_fs_1.readFileSync)(path, "utf8");
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
891
932
|
function gitBranch(projectDir) {
|
|
892
933
|
return readGit(projectDir, ["branch", "--show-current"]) || readGit(projectDir, ["rev-parse", "--short", "HEAD"]);
|
|
893
934
|
}
|
|
@@ -901,6 +942,12 @@ function gitMergeBase(projectDir) {
|
|
|
901
942
|
return readGit(projectDir, ["merge-base", "HEAD", "origin/main"])
|
|
902
943
|
|| readGit(projectDir, ["merge-base", "HEAD", "origin/master"]);
|
|
903
944
|
}
|
|
945
|
+
function gitProjectPrefix(projectDir) {
|
|
946
|
+
const prefix = readGit(projectDir, ["rev-parse", "--show-prefix"]);
|
|
947
|
+
if (prefix === null)
|
|
948
|
+
return null;
|
|
949
|
+
return prefix.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
950
|
+
}
|
|
904
951
|
// Directories that are never meaningful in change-memory packets.
|
|
905
952
|
// These are typically generated, vendored, or ephemeral — any project can
|
|
906
953
|
// accumulate thousands of files here that bury real signal.
|
|
@@ -934,12 +981,26 @@ function isNoisePath(filePath) {
|
|
|
934
981
|
return false;
|
|
935
982
|
return NOISE_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
936
983
|
}
|
|
937
|
-
function
|
|
984
|
+
function gitPathToProjectRelative(projectDir, path) {
|
|
985
|
+
const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
986
|
+
const projectPrefix = gitProjectPrefix(projectDir);
|
|
987
|
+
if (projectPrefix === null || projectPrefix === "")
|
|
988
|
+
return normalized;
|
|
989
|
+
if (normalized === projectPrefix)
|
|
990
|
+
return "";
|
|
991
|
+
const prefix = `${projectPrefix}/`;
|
|
992
|
+
if (!normalized.startsWith(prefix)) {
|
|
993
|
+
return (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, normalized)) ? normalized : null;
|
|
994
|
+
}
|
|
995
|
+
return normalized.slice(prefix.length);
|
|
996
|
+
}
|
|
997
|
+
function parsePorcelainStatus(projectDir, status) {
|
|
938
998
|
return unique(status
|
|
939
999
|
.split(/\r?\n/)
|
|
940
1000
|
.map(parsePorcelainPath)
|
|
941
1001
|
.map((path) => path.replace(/^.* -> /, ""))
|
|
942
|
-
.
|
|
1002
|
+
.map((path) => gitPathToProjectRelative(projectDir, path))
|
|
1003
|
+
.filter((path) => Boolean(path))
|
|
943
1004
|
.filter((path) => !shouldSkipRepoMemoryPath(path))).sort();
|
|
944
1005
|
}
|
|
945
1006
|
function parsePorcelainPath(line) {
|
|
@@ -948,13 +1009,14 @@ function parsePorcelainPath(line) {
|
|
|
948
1009
|
}
|
|
949
1010
|
function branchDiffStat(projectDir, changedFiles) {
|
|
950
1011
|
const diffStats = [
|
|
951
|
-
readGit(projectDir, ["diff", "--stat"]),
|
|
952
|
-
readGit(projectDir, ["diff", "--cached", "--stat"]),
|
|
1012
|
+
readGit(projectDir, ["diff", "--stat", "--relative"]),
|
|
1013
|
+
readGit(projectDir, ["diff", "--cached", "--stat", "--relative"]),
|
|
953
1014
|
].filter(Boolean).join("\n").trim();
|
|
954
1015
|
const untracked = new Set((readGit(projectDir, ["ls-files", "--others", "--exclude-standard"]) ?? "")
|
|
955
1016
|
.split(/\r?\n/)
|
|
956
1017
|
.map((path) => path.trim())
|
|
957
|
-
.
|
|
1018
|
+
.map((path) => gitPathToProjectRelative(projectDir, path))
|
|
1019
|
+
.filter((path) => Boolean(path))
|
|
958
1020
|
.filter((path) => changedFiles.includes(path)));
|
|
959
1021
|
const untrackedStats = [...untracked]
|
|
960
1022
|
.filter((file) => !diffStats.includes(file))
|
|
@@ -1016,8 +1078,9 @@ function createRepoOverviewPacket(projectDir) {
|
|
|
1016
1078
|
if (deps.stripe)
|
|
1017
1079
|
tags.push("stripe");
|
|
1018
1080
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1081
|
+
const readmeText = (0, node_fs_1.existsSync)(readmePath) ? safeReadText(readmePath) : null;
|
|
1082
|
+
if (readmeText) {
|
|
1083
|
+
const readme = readmeText.slice(0, 1000);
|
|
1021
1084
|
bodyParts.push(`README excerpt:\n${readme}`);
|
|
1022
1085
|
}
|
|
1023
1086
|
const createdAt = nowIso();
|
|
@@ -1038,7 +1101,7 @@ function createRepoOverviewPacket(projectDir) {
|
|
|
1038
1101
|
stack,
|
|
1039
1102
|
source_refs: [
|
|
1040
1103
|
...((0, node_fs_1.existsSync)(packagePath) ? [{ kind: "file", path: "package.json" }] : []),
|
|
1041
|
-
...(
|
|
1104
|
+
...(readmeText ? [{ kind: "file", path: "README.md" }] : []),
|
|
1042
1105
|
],
|
|
1043
1106
|
context: {
|
|
1044
1107
|
fact: "Generated repo overview summarizes package metadata and the README as a navigation aid for agent startup.",
|
|
@@ -1626,8 +1689,9 @@ function structuralFileCacheDir(projectDir) {
|
|
|
1626
1689
|
function structuralPackedFileCachePath(projectDir) {
|
|
1627
1690
|
return (0, node_path_1.join)(structuralIndexDir(projectDir), "file-cache.json");
|
|
1628
1691
|
}
|
|
1692
|
+
const STRUCTURAL_EXTRACTOR_VERSION = 2;
|
|
1629
1693
|
function structuralFileCachePath(projectDir, rel, hash) {
|
|
1630
|
-
return (0, node_path_1.join)(structuralFileCacheDir(projectDir),
|
|
1694
|
+
return (0, node_path_1.join)(structuralFileCacheDir(projectDir), `v${STRUCTURAL_EXTRACTOR_VERSION}-${slugify(rel)}-${hash}.json`);
|
|
1631
1695
|
}
|
|
1632
1696
|
function emptyStructuralIndexManifest(projectDir) {
|
|
1633
1697
|
return {
|
|
@@ -1748,7 +1812,20 @@ function scanStructuralFiles(projectDir) {
|
|
|
1748
1812
|
ignore("kageignore");
|
|
1749
1813
|
continue;
|
|
1750
1814
|
}
|
|
1751
|
-
const
|
|
1815
|
+
const linkStats = safeLstat(absolutePath);
|
|
1816
|
+
if (!linkStats) {
|
|
1817
|
+
ignore("unreadable_path");
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
if (linkStats.isSymbolicLink()) {
|
|
1821
|
+
ignore("symlink");
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
const stats = safeStat(absolutePath);
|
|
1825
|
+
if (!stats) {
|
|
1826
|
+
ignore("unreadable_path");
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1752
1829
|
if (stats.isDirectory()) {
|
|
1753
1830
|
visit(absolutePath);
|
|
1754
1831
|
continue;
|
|
@@ -1918,7 +1995,7 @@ function expandCompactStructuralCachedFile(compact) {
|
|
|
1918
1995
|
}
|
|
1919
1996
|
const packedStructuralCache = new Map();
|
|
1920
1997
|
function structuralPackedCacheKey(rel, hash) {
|
|
1921
|
-
return
|
|
1998
|
+
return `v${STRUCTURAL_EXTRACTOR_VERSION}\0${rel}\0${hash}`;
|
|
1922
1999
|
}
|
|
1923
2000
|
function readPackedStructuralCache(projectDir) {
|
|
1924
2001
|
const path = structuralPackedFileCachePath(projectDir);
|
|
@@ -2329,8 +2406,12 @@ function resolveImportPath(projectDir, fromRelativePath, specifier, knownFiles)
|
|
|
2329
2406
|
if (!specifier.startsWith("."))
|
|
2330
2407
|
return null;
|
|
2331
2408
|
const base = (0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.join)(projectDir, fromRelativePath)), specifier);
|
|
2409
|
+
const sourceExtensionCandidates = /\.(?:mjs|cjs|js|jsx)$/.test(base)
|
|
2410
|
+
? [".ts", ".tsx", ".mts", ".cts"].map((extension) => base.replace(/\.(?:mjs|cjs|js|jsx)$/, extension))
|
|
2411
|
+
: [];
|
|
2332
2412
|
const candidates = [
|
|
2333
2413
|
base,
|
|
2414
|
+
...sourceExtensionCandidates,
|
|
2334
2415
|
...[...CODE_EXTENSIONS].map((extension) => `${base}${extension}`),
|
|
2335
2416
|
...[...CODE_EXTENSIONS].map((extension) => (0, node_path_1.join)(base, `index${extension}`)),
|
|
2336
2417
|
];
|
|
@@ -2442,6 +2523,12 @@ function extractSymbols(path, text) {
|
|
|
2442
2523
|
function extractGenericSymbols(path, text) {
|
|
2443
2524
|
const symbols = [];
|
|
2444
2525
|
const language = codeLanguage(path);
|
|
2526
|
+
const fileKind = codeFileKind(path);
|
|
2527
|
+
const genericKind = (name, fallback) => {
|
|
2528
|
+
if (fileKind === "test" && /^(test_|Test|it_|should_)/.test(name))
|
|
2529
|
+
return "test";
|
|
2530
|
+
return fallback;
|
|
2531
|
+
};
|
|
2445
2532
|
const addSymbol = (name, kind, line, signature, exported = true) => {
|
|
2446
2533
|
symbols.push({
|
|
2447
2534
|
id: symbolId(path, name, kind, line),
|
|
@@ -2466,7 +2553,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2466
2553
|
if (language === "python") {
|
|
2467
2554
|
match = trimmed.match(/^(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/);
|
|
2468
2555
|
if (match)
|
|
2469
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2556
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2470
2557
|
match = trimmed.match(/^class\s+([A-Za-z_][\w]*)\b/);
|
|
2471
2558
|
if (match)
|
|
2472
2559
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2474,7 +2561,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2474
2561
|
if (language === "go") {
|
|
2475
2562
|
match = trimmed.match(/^func\s+(?:\([^)]+\)\s*)?([A-Za-z_][\w]*)\s*\(/);
|
|
2476
2563
|
if (match)
|
|
2477
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2564
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2478
2565
|
match = trimmed.match(/^type\s+([A-Za-z_][\w]*)\s+(?:struct|interface)\b/);
|
|
2479
2566
|
if (match)
|
|
2480
2567
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2482,7 +2569,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2482
2569
|
if (language === "rust") {
|
|
2483
2570
|
match = trimmed.match(/^(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)\s*[<(]/);
|
|
2484
2571
|
if (match)
|
|
2485
|
-
return addSymbol(match[1], "function", line, trimmed, /^pub\b/.test(trimmed));
|
|
2572
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed, /^pub\b/.test(trimmed));
|
|
2486
2573
|
match = trimmed.match(/^(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_][\w]*)\b/);
|
|
2487
2574
|
if (match)
|
|
2488
2575
|
return addSymbol(match[1], "class", line, trimmed, /^pub\b/.test(trimmed));
|
|
@@ -2490,7 +2577,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2490
2577
|
if (language === "ruby") {
|
|
2491
2578
|
match = trimmed.match(/^def\s+(?:self\.)?([A-Za-z_][\w!?=]*)/);
|
|
2492
2579
|
if (match)
|
|
2493
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2580
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2494
2581
|
match = trimmed.match(/^class\s+([A-Za-z_:][\w:]*)\b/);
|
|
2495
2582
|
if (match)
|
|
2496
2583
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2498,7 +2585,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2498
2585
|
if (language === "php") {
|
|
2499
2586
|
match = trimmed.match(/^(?:public|private|protected|static|\s)*function\s+([A-Za-z_][\w]*)\s*\(/);
|
|
2500
2587
|
if (match)
|
|
2501
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2588
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2502
2589
|
match = trimmed.match(/^(?:final\s+|abstract\s+)?class\s+([A-Za-z_][\w]*)\b/);
|
|
2503
2590
|
if (match)
|
|
2504
2591
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2506,7 +2593,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2506
2593
|
if (["java", "kotlin", "csharp", "cpp", "swift"].includes(language)) {
|
|
2507
2594
|
match = trimmed.match(/^(?:public|private|protected|internal|static|final|open|override|async|virtual|inline|constexpr|\s)+[\w:<>,\[\]?&*\s]+\s+([A-Za-z_][\w]*)\s*\([^;]*\)\s*(?:\{|=>|throws\b)?/);
|
|
2508
2595
|
if (match && !["if", "for", "while", "switch", "catch"].includes(match[1]))
|
|
2509
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2596
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2510
2597
|
match = trimmed.match(/^(?:public|private|protected|internal|static|final|open|abstract|sealed|\s)*(?:class|interface|struct|enum)\s+([A-Za-z_][\w]*)\b/);
|
|
2511
2598
|
if (match)
|
|
2512
2599
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2699,6 +2786,47 @@ function extractCalls(path, text, symbols, symbolByName) {
|
|
|
2699
2786
|
visit(sourceFile);
|
|
2700
2787
|
return calls.sort((a, b) => a.line - b.line || a.to_symbol.localeCompare(b.to_symbol));
|
|
2701
2788
|
}
|
|
2789
|
+
const GENERIC_CALL_STOP_WORDS = new Set([
|
|
2790
|
+
"catch",
|
|
2791
|
+
"class",
|
|
2792
|
+
"def",
|
|
2793
|
+
"elif",
|
|
2794
|
+
"for",
|
|
2795
|
+
"func",
|
|
2796
|
+
"function",
|
|
2797
|
+
"if",
|
|
2798
|
+
"interface",
|
|
2799
|
+
"return",
|
|
2800
|
+
"switch",
|
|
2801
|
+
"while",
|
|
2802
|
+
]);
|
|
2803
|
+
function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
2804
|
+
const calls = [];
|
|
2805
|
+
const lines = text.split(/\r?\n/);
|
|
2806
|
+
for (let index = 0; index < lines.length && calls.length < MAX_CODE_GRAPH_CALLS_PER_FILE; index += 1) {
|
|
2807
|
+
const line = index + 1;
|
|
2808
|
+
const trimmed = lines[index].trim();
|
|
2809
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//"))
|
|
2810
|
+
continue;
|
|
2811
|
+
if (/^(?:async\s+)?def\s+|^func\s+|^(?:pub\s+)?(?:async\s+)?fn\s+|^function\s+|^(?:public|private|protected|internal|static|final|open|override|async|virtual|inline|constexpr|\s)+[\w:<>,\[\]?&*\s]+\s+[A-Za-z_][\w]*\s*\(/.test(trimmed))
|
|
2812
|
+
continue;
|
|
2813
|
+
for (const match of trimmed.matchAll(/\b([A-Za-z_][\w]*)\s*\(/g)) {
|
|
2814
|
+
const name = match[1];
|
|
2815
|
+
if (GENERIC_CALL_STOP_WORDS.has(name))
|
|
2816
|
+
continue;
|
|
2817
|
+
const targets = symbolByName.get(name)?.filter((target) => target.path !== path || target.line !== line);
|
|
2818
|
+
if (!targets?.length)
|
|
2819
|
+
continue;
|
|
2820
|
+
const caller = symbolAtLine(symbols, path, line);
|
|
2821
|
+
for (const target of targets.slice(0, 3)) {
|
|
2822
|
+
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
2823
|
+
break;
|
|
2824
|
+
calls.push({ from_symbol: caller?.id ?? null, to_symbol: target.id, path, line });
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
return calls.sort((a, b) => a.line - b.line || a.to_symbol.localeCompare(b.to_symbol));
|
|
2829
|
+
}
|
|
2702
2830
|
function extractRoutes(path, text, symbols) {
|
|
2703
2831
|
const routes = [];
|
|
2704
2832
|
const addRoute = (method, routePath, offset, framework, handler = null) => {
|
|
@@ -3271,7 +3399,7 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
3271
3399
|
const imports = structural.imports.slice();
|
|
3272
3400
|
const contents = new Map();
|
|
3273
3401
|
for (const file of structural.files) {
|
|
3274
|
-
if (!
|
|
3402
|
+
if (!CODE_EXTENSIONS.has(extensionOf(file.path)))
|
|
3275
3403
|
continue;
|
|
3276
3404
|
if (file.size_bytes > MAX_CODE_FILE_BYTES)
|
|
3277
3405
|
continue;
|
|
@@ -3318,13 +3446,14 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
3318
3446
|
const routes = [];
|
|
3319
3447
|
const tests = [];
|
|
3320
3448
|
for (const [rel, content] of contents) {
|
|
3321
|
-
if (!TS_AST_EXTENSIONS.has(extensionOf(rel)))
|
|
3322
|
-
continue;
|
|
3323
3449
|
if (calls.length >= MAX_CODE_GRAPH_CALLS)
|
|
3324
3450
|
break;
|
|
3325
3451
|
const fileSymbols = symbols.filter((symbol) => symbol.path === rel);
|
|
3326
3452
|
const fileImports = imports.filter((item) => item.from_path === rel);
|
|
3327
|
-
|
|
3453
|
+
const fileCalls = TS_AST_EXTENSIONS.has(extensionOf(rel))
|
|
3454
|
+
? extractCalls(rel, content, fileSymbols, symbolByName)
|
|
3455
|
+
: extractGenericCalls(rel, content, fileSymbols, symbolByName);
|
|
3456
|
+
calls.push(...fileCalls.slice(0, Math.max(0, MAX_CODE_GRAPH_CALLS - calls.length)));
|
|
3328
3457
|
routes.push(...extractRoutes(rel, content, fileSymbols));
|
|
3329
3458
|
tests.push(...extractTests(rel, content, fileSymbols, fileImports));
|
|
3330
3459
|
}
|
|
@@ -4742,6 +4871,1454 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
4742
4871
|
structural_edges: structuralEdges,
|
|
4743
4872
|
};
|
|
4744
4873
|
}
|
|
4874
|
+
function gitLines(projectDir, args) {
|
|
4875
|
+
return (readGit(projectDir, args) ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
4876
|
+
}
|
|
4877
|
+
function gitCommitRecords(projectDir, limit = 1000) {
|
|
4878
|
+
const raw = readGit(projectDir, ["log", `-${limit}`, "--format=__KAGE_COMMIT__%x1f%an <%ae>%x1f%s", "--name-only"]) ?? "";
|
|
4879
|
+
const records = [];
|
|
4880
|
+
let current = null;
|
|
4881
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
4882
|
+
const line = rawLine.trim();
|
|
4883
|
+
if (!line)
|
|
4884
|
+
continue;
|
|
4885
|
+
if (line.startsWith("__KAGE_COMMIT__")) {
|
|
4886
|
+
if (current)
|
|
4887
|
+
records.push(current);
|
|
4888
|
+
const [, author = "", subject = ""] = line.split("\x1f");
|
|
4889
|
+
current = { author: author.trim(), subject: subject.trim(), files: [] };
|
|
4890
|
+
continue;
|
|
4891
|
+
}
|
|
4892
|
+
if (current)
|
|
4893
|
+
current.files.push(line);
|
|
4894
|
+
}
|
|
4895
|
+
if (current)
|
|
4896
|
+
records.push(current);
|
|
4897
|
+
return records;
|
|
4898
|
+
}
|
|
4899
|
+
function commitCategory(subject) {
|
|
4900
|
+
const text = subject.toLowerCase();
|
|
4901
|
+
if (/^(fix|bug|hotfix|revert)(\b|\(|:)|\bfix(e[sd])?\b|\bbug\b/.test(text))
|
|
4902
|
+
return "fix";
|
|
4903
|
+
if (/^(feat|feature)(\b|\(|:)|\badd(ed|s)?\b|\bintroduce/.test(text))
|
|
4904
|
+
return "feat";
|
|
4905
|
+
if (/^(perf|performance)(\b|\(|:)|\boptimi[sz]e/.test(text))
|
|
4906
|
+
return "perf";
|
|
4907
|
+
if (/^(refactor|cleanup)(\b|\(|:)|\brename\b|\bmove\b/.test(text))
|
|
4908
|
+
return "refactor";
|
|
4909
|
+
if (/^(test|tests)(\b|\(|:)|\bspec\b/.test(text))
|
|
4910
|
+
return "test";
|
|
4911
|
+
if (/^(doc|docs)(\b|\(|:)|\breadme\b/.test(text))
|
|
4912
|
+
return "docs";
|
|
4913
|
+
if (/^(chore|build|ci|deps?)(\b|\(|:)|\bversion\b|\brelease\b|\bupgrade\b/.test(text))
|
|
4914
|
+
return "chore";
|
|
4915
|
+
return "other";
|
|
4916
|
+
}
|
|
4917
|
+
function gitCommitCountForPath(projectDir, path, since) {
|
|
4918
|
+
const args = ["log", "--format=%H"];
|
|
4919
|
+
if (since)
|
|
4920
|
+
args.push(`--since=${since}`);
|
|
4921
|
+
args.push("--", path);
|
|
4922
|
+
return gitLines(projectDir, args).length;
|
|
4923
|
+
}
|
|
4924
|
+
function gitPrimaryOwnerForPath(projectDir, path) {
|
|
4925
|
+
const authors = gitLines(projectDir, ["log", "--format=%an <%ae>", "--", path]);
|
|
4926
|
+
if (!authors.length)
|
|
4927
|
+
return { primary_owner: null, primary_owner_pct: null, contributor_count: 0 };
|
|
4928
|
+
const counts = new Map();
|
|
4929
|
+
for (const author of authors)
|
|
4930
|
+
counts.set(author, (counts.get(author) ?? 0) + 1);
|
|
4931
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
4932
|
+
return {
|
|
4933
|
+
primary_owner: ranked[0]?.[0] ?? null,
|
|
4934
|
+
primary_owner_pct: ranked[0] ? Number((ranked[0][1] / authors.length).toFixed(2)) : null,
|
|
4935
|
+
contributor_count: ranked.length,
|
|
4936
|
+
};
|
|
4937
|
+
}
|
|
4938
|
+
function gitAuthorCountsForPath(projectDir, path, since) {
|
|
4939
|
+
const args = ["log", "--format=%an <%ae>"];
|
|
4940
|
+
if (since)
|
|
4941
|
+
args.push(`--since=${since}`);
|
|
4942
|
+
args.push("--", path);
|
|
4943
|
+
const counts = new Map();
|
|
4944
|
+
for (const author of gitLines(projectDir, args))
|
|
4945
|
+
counts.set(author, (counts.get(author) ?? 0) + 1);
|
|
4946
|
+
return counts;
|
|
4947
|
+
}
|
|
4948
|
+
function gitCoChangePartnersForPath(projectDir, path, graphPaths) {
|
|
4949
|
+
const commits = gitLines(projectDir, ["log", "--format=%H", "-n", "80", "--", path]);
|
|
4950
|
+
const counts = new Map();
|
|
4951
|
+
for (const commit of commits) {
|
|
4952
|
+
const changed = gitLines(projectDir, ["show", "--name-only", "--format=", "--no-renames", commit])
|
|
4953
|
+
.filter((candidate) => candidate !== path && graphPaths.has(candidate));
|
|
4954
|
+
if (changed.length > 200)
|
|
4955
|
+
continue;
|
|
4956
|
+
for (const file of new Set(changed))
|
|
4957
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
4958
|
+
}
|
|
4959
|
+
return [...counts.entries()]
|
|
4960
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
4961
|
+
.slice(0, 5)
|
|
4962
|
+
.map(([file_path, count]) => ({ file_path, count }));
|
|
4963
|
+
}
|
|
4964
|
+
function gitFileSignal(projectDir, path, graphPaths) {
|
|
4965
|
+
const total = gitCommitCountForPath(projectDir, path);
|
|
4966
|
+
const owner = gitPrimaryOwnerForPath(projectDir, path);
|
|
4967
|
+
return {
|
|
4968
|
+
file_path: path,
|
|
4969
|
+
commit_count_total: total,
|
|
4970
|
+
commit_count_30d: gitCommitCountForPath(projectDir, path, "30 days ago"),
|
|
4971
|
+
commit_count_90d: gitCommitCountForPath(projectDir, path, "90 days ago"),
|
|
4972
|
+
last_commit_at: gitLines(projectDir, ["log", "-1", "--format=%cI", "--", path])[0] ?? null,
|
|
4973
|
+
primary_owner: owner.primary_owner,
|
|
4974
|
+
primary_owner_pct: owner.primary_owner_pct,
|
|
4975
|
+
contributor_count: owner.contributor_count,
|
|
4976
|
+
co_change_partners: gitCoChangePartnersForPath(projectDir, path, graphPaths),
|
|
4977
|
+
};
|
|
4978
|
+
}
|
|
4979
|
+
function gitChangedFiles(projectDir) {
|
|
4980
|
+
return gitLines(projectDir, ["status", "--porcelain", "-uall"])
|
|
4981
|
+
.map((line) => line.slice(3).trim().split(" -> ").at(-1) ?? "")
|
|
4982
|
+
.filter(Boolean)
|
|
4983
|
+
.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
|
|
4984
|
+
.filter((path) => !isNoisePath(path));
|
|
4985
|
+
}
|
|
4986
|
+
function globalGitHotspots(projectDir, graph) {
|
|
4987
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
4988
|
+
const counts = new Map();
|
|
4989
|
+
for (const line of gitLines(projectDir, ["log", "--since=90 days ago", "--name-only", "--format=__KAGE_COMMIT__", "-n", "1000"])) {
|
|
4990
|
+
if (line === "__KAGE_COMMIT__" || !graphPaths.has(line))
|
|
4991
|
+
continue;
|
|
4992
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
4993
|
+
}
|
|
4994
|
+
return [...counts.entries()]
|
|
4995
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
4996
|
+
.slice(0, 5)
|
|
4997
|
+
.map(([file_path, commit_count_90d]) => {
|
|
4998
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file_path);
|
|
4999
|
+
return {
|
|
5000
|
+
file_path,
|
|
5001
|
+
commit_count_90d,
|
|
5002
|
+
hotspot_score: Number(Math.min(1, commit_count_90d / 20).toFixed(2)),
|
|
5003
|
+
primary_owner: owner.primary_owner,
|
|
5004
|
+
};
|
|
5005
|
+
});
|
|
5006
|
+
}
|
|
5007
|
+
function globalOwnershipSilos(projectDir, graph) {
|
|
5008
|
+
const candidates = [];
|
|
5009
|
+
for (const file of graph.files) {
|
|
5010
|
+
if (file.kind !== "source")
|
|
5011
|
+
continue;
|
|
5012
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file.path);
|
|
5013
|
+
const commitCount = gitCommitCountForPath(projectDir, file.path);
|
|
5014
|
+
if (!owner.primary_owner || owner.primary_owner_pct == null)
|
|
5015
|
+
continue;
|
|
5016
|
+
if (owner.primary_owner_pct < 0.8 || commitCount < 5)
|
|
5017
|
+
continue;
|
|
5018
|
+
candidates.push({
|
|
5019
|
+
file_path: file.path,
|
|
5020
|
+
primary_owner: owner.primary_owner,
|
|
5021
|
+
primary_owner_pct: owner.primary_owner_pct,
|
|
5022
|
+
commit_count_total: commitCount,
|
|
5023
|
+
});
|
|
5024
|
+
}
|
|
5025
|
+
return candidates
|
|
5026
|
+
.sort((a, b) => b.primary_owner_pct - a.primary_owner_pct || b.commit_count_total - a.commit_count_total || a.file_path.localeCompare(b.file_path))
|
|
5027
|
+
.slice(0, 10);
|
|
5028
|
+
}
|
|
5029
|
+
function codeDependents(graph) {
|
|
5030
|
+
const dependents = new Map();
|
|
5031
|
+
for (const edge of graph.imports) {
|
|
5032
|
+
if (!edge.to_path)
|
|
5033
|
+
continue;
|
|
5034
|
+
const list = dependents.get(edge.to_path) ?? new Set();
|
|
5035
|
+
list.add(edge.from_path);
|
|
5036
|
+
dependents.set(edge.to_path, list);
|
|
5037
|
+
}
|
|
5038
|
+
return dependents;
|
|
5039
|
+
}
|
|
5040
|
+
function impactSurface(target, dependents, graph) {
|
|
5041
|
+
const visited = new Set();
|
|
5042
|
+
let frontier = new Set([target]);
|
|
5043
|
+
for (let depth = 0; depth < 2; depth++) {
|
|
5044
|
+
const next = new Set();
|
|
5045
|
+
for (const node of frontier) {
|
|
5046
|
+
for (const dependent of dependents.get(node) ?? []) {
|
|
5047
|
+
if (dependent === target || visited.has(dependent))
|
|
5048
|
+
continue;
|
|
5049
|
+
visited.add(dependent);
|
|
5050
|
+
next.add(dependent);
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
frontier = next;
|
|
5054
|
+
}
|
|
5055
|
+
const dependentScore = new Map();
|
|
5056
|
+
for (const [path, incoming] of dependents.entries())
|
|
5057
|
+
dependentScore.set(path, incoming.size);
|
|
5058
|
+
return [...visited]
|
|
5059
|
+
.sort((a, b) => (dependentScore.get(b) ?? 0) - (dependentScore.get(a) ?? 0) || a.localeCompare(b))
|
|
5060
|
+
.slice(0, 5);
|
|
5061
|
+
}
|
|
5062
|
+
function hasTestCoverage(target, graph) {
|
|
5063
|
+
const file = graph.files.find((candidate) => candidate.path === target);
|
|
5064
|
+
if (file?.kind === "test")
|
|
5065
|
+
return true;
|
|
5066
|
+
if (graph.tests.some((test) => test.covers_path === target))
|
|
5067
|
+
return true;
|
|
5068
|
+
const base = (0, node_path_1.basename)(target).replace(/\.[^.]+$/, "").toLowerCase();
|
|
5069
|
+
return graph.files.some((candidate) => {
|
|
5070
|
+
if (candidate.kind !== "test")
|
|
5071
|
+
return false;
|
|
5072
|
+
const lower = candidate.path.toLowerCase();
|
|
5073
|
+
return lower.includes(`test_${base}`) || lower.includes(`${base}_test`) || lower.includes(`${base}.spec`) || lower.includes(`${base}.test`);
|
|
5074
|
+
});
|
|
5075
|
+
}
|
|
5076
|
+
function classifyRisk(git, dependentsCount, testGap) {
|
|
5077
|
+
if (!git.commit_count_total && !dependentsCount)
|
|
5078
|
+
return "unknown";
|
|
5079
|
+
if (git.commit_count_30d >= 5 || git.commit_count_90d >= 15)
|
|
5080
|
+
return "churn-heavy";
|
|
5081
|
+
if (dependentsCount >= 5)
|
|
5082
|
+
return "high-coupling";
|
|
5083
|
+
if ((git.primary_owner_pct ?? 0) >= 0.8 && git.commit_count_total >= 10)
|
|
5084
|
+
return "single-owner";
|
|
5085
|
+
if (testGap && (dependentsCount > 0 || git.commit_count_90d > 0))
|
|
5086
|
+
return "test-gap";
|
|
5087
|
+
return "stable";
|
|
5088
|
+
}
|
|
5089
|
+
function kageRisk(projectDir, targets = [], changedFiles = []) {
|
|
5090
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5091
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5092
|
+
const dependents = codeDependents(graph);
|
|
5093
|
+
const resolvedTargets = unique((targets.length ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
|
|
5094
|
+
.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
|
|
5095
|
+
.filter((path) => path && !isNoisePath(path)));
|
|
5096
|
+
const warnings = [];
|
|
5097
|
+
if (!gitHead(projectDir))
|
|
5098
|
+
warnings.push("Git history is unavailable, so churn, ownership, and co-change signals may be empty.");
|
|
5099
|
+
if (!resolvedTargets.length)
|
|
5100
|
+
warnings.push("No targets supplied and no changed files detected.");
|
|
5101
|
+
const changeSet = new Set(resolvedTargets);
|
|
5102
|
+
const targetMap = {};
|
|
5103
|
+
for (const target of resolvedTargets) {
|
|
5104
|
+
const directDependents = [...(dependents.get(target) ?? [])].sort();
|
|
5105
|
+
const git = gitFileSignal(projectDir, target, graphPaths);
|
|
5106
|
+
const testGap = !hasTestCoverage(target, graph);
|
|
5107
|
+
const coChangeWarnings = git.co_change_partners.map((partner) => ({
|
|
5108
|
+
file_path: partner.file_path,
|
|
5109
|
+
count: partner.count,
|
|
5110
|
+
included_in_change: changeSet.has(partner.file_path),
|
|
5111
|
+
}));
|
|
5112
|
+
const missingCoChanges = coChangeWarnings.filter((partner) => !partner.included_in_change);
|
|
5113
|
+
const hotspotScore = Number(Math.min(1, (git.commit_count_30d / 6) * 0.5 + (git.commit_count_90d / 20) * 0.5).toFixed(2));
|
|
5114
|
+
const riskType = classifyRisk(git, directDependents.length, testGap);
|
|
5115
|
+
const owner = git.primary_owner ?? "unknown";
|
|
5116
|
+
targetMap[target] = {
|
|
5117
|
+
target,
|
|
5118
|
+
exists_in_code_graph: graphPaths.has(target),
|
|
5119
|
+
hotspot_score: hotspotScore,
|
|
5120
|
+
risk_type: riskType,
|
|
5121
|
+
dependents_count: directDependents.length,
|
|
5122
|
+
dependents: directDependents.slice(0, 10),
|
|
5123
|
+
impact_surface: impactSurface(target, dependents, graph),
|
|
5124
|
+
test_gap: testGap,
|
|
5125
|
+
co_change_warnings: coChangeWarnings,
|
|
5126
|
+
git,
|
|
5127
|
+
risk_summary: `${target} - ${riskType}, hotspot ${Math.round(hotspotScore * 100)}%, ${directDependents.length} direct dependents, ${git.commit_count_90d} commits in 90d, owner ${owner}${testGap ? ", test gap" : ""}${missingCoChanges.length ? `, ${missingCoChanges.length} co-change partner(s) not in this change` : ""}.`,
|
|
5128
|
+
};
|
|
5129
|
+
}
|
|
5130
|
+
return {
|
|
5131
|
+
schema_version: 1,
|
|
5132
|
+
project_dir: projectDir,
|
|
5133
|
+
generated_at: nowIso(),
|
|
5134
|
+
targets: targetMap,
|
|
5135
|
+
global_hotspots: globalGitHotspots(projectDir, graph),
|
|
5136
|
+
ownership_silos: globalOwnershipSilos(projectDir, graph),
|
|
5137
|
+
changed_files: changedFiles.length ? changedFiles : undefined,
|
|
5138
|
+
warnings,
|
|
5139
|
+
};
|
|
5140
|
+
}
|
|
5141
|
+
function resolveCodeGraphPath(projectDir, graph, input, warnings, label) {
|
|
5142
|
+
const normalized = (gitPathToProjectRelative(projectDir, input) ?? input).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
5143
|
+
const paths = graph.files.map((file) => file.path);
|
|
5144
|
+
if (paths.includes(normalized))
|
|
5145
|
+
return normalized;
|
|
5146
|
+
const suffixMatches = paths.filter((path) => path.endsWith(`/${normalized}`) || path === normalized);
|
|
5147
|
+
if (suffixMatches.length === 1)
|
|
5148
|
+
return suffixMatches[0];
|
|
5149
|
+
if (suffixMatches.length > 1) {
|
|
5150
|
+
warnings.push(`${label} "${input}" is ambiguous: ${suffixMatches.slice(0, 5).join(", ")}`);
|
|
5151
|
+
return null;
|
|
5152
|
+
}
|
|
5153
|
+
const nameMatches = paths.filter((path) => (0, node_path_1.basename)(path) === normalized || (0, node_path_1.basename)(path) === (0, node_path_1.basename)(normalized));
|
|
5154
|
+
if (nameMatches.length === 1)
|
|
5155
|
+
return nameMatches[0];
|
|
5156
|
+
if (nameMatches.length > 1)
|
|
5157
|
+
warnings.push(`${label} "${input}" matched multiple files by name: ${nameMatches.slice(0, 5).join(", ")}`);
|
|
5158
|
+
else
|
|
5159
|
+
warnings.push(`${label} "${input}" was not found in the code graph.`);
|
|
5160
|
+
return null;
|
|
5161
|
+
}
|
|
5162
|
+
function importEdgeKey(from, to) {
|
|
5163
|
+
return `${from}\u0000${to}`;
|
|
5164
|
+
}
|
|
5165
|
+
function dependencyAdjacency(graph, mode) {
|
|
5166
|
+
const adjacency = new Map();
|
|
5167
|
+
const edges = new Map();
|
|
5168
|
+
const add = (from, to, edge) => {
|
|
5169
|
+
const next = adjacency.get(from) ?? new Set();
|
|
5170
|
+
next.add(to);
|
|
5171
|
+
adjacency.set(from, next);
|
|
5172
|
+
if (!edges.has(importEdgeKey(from, to)))
|
|
5173
|
+
edges.set(importEdgeKey(from, to), edge);
|
|
5174
|
+
};
|
|
5175
|
+
for (const edge of graph.imports) {
|
|
5176
|
+
if (!edge.to_path)
|
|
5177
|
+
continue;
|
|
5178
|
+
if (mode === "forward" || mode === "undirected")
|
|
5179
|
+
add(edge.from_path, edge.to_path, edge);
|
|
5180
|
+
if (mode === "reverse" || mode === "undirected")
|
|
5181
|
+
add(edge.to_path, edge.from_path, edge);
|
|
5182
|
+
}
|
|
5183
|
+
return { adjacency, edges };
|
|
5184
|
+
}
|
|
5185
|
+
function shortestDependencyPath(graph, from, to, mode) {
|
|
5186
|
+
if (from === to)
|
|
5187
|
+
return { path: [from], edges: [] };
|
|
5188
|
+
const { adjacency, edges } = dependencyAdjacency(graph, mode);
|
|
5189
|
+
const queue = [from];
|
|
5190
|
+
const previous = new Map([[from, null]]);
|
|
5191
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
5192
|
+
const current = queue[index];
|
|
5193
|
+
for (const next of [...(adjacency.get(current) ?? [])].sort()) {
|
|
5194
|
+
if (previous.has(next))
|
|
5195
|
+
continue;
|
|
5196
|
+
previous.set(next, current);
|
|
5197
|
+
if (next === to) {
|
|
5198
|
+
const path = [to];
|
|
5199
|
+
let cursor = current;
|
|
5200
|
+
while (cursor) {
|
|
5201
|
+
path.push(cursor);
|
|
5202
|
+
cursor = previous.get(cursor) ?? null;
|
|
5203
|
+
}
|
|
5204
|
+
path.reverse();
|
|
5205
|
+
const pathEdges = [];
|
|
5206
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
5207
|
+
const a = path[i];
|
|
5208
|
+
const b = path[i + 1];
|
|
5209
|
+
const edge = edges.get(importEdgeKey(a, b));
|
|
5210
|
+
const reverseEdge = edges.get(importEdgeKey(b, a));
|
|
5211
|
+
const source = edge ?? reverseEdge;
|
|
5212
|
+
if (!source)
|
|
5213
|
+
continue;
|
|
5214
|
+
pathEdges.push({
|
|
5215
|
+
from_path: source.from_path,
|
|
5216
|
+
to_path: source.to_path ?? b,
|
|
5217
|
+
kind: source.kind,
|
|
5218
|
+
specifier: source.specifier,
|
|
5219
|
+
line: source.line,
|
|
5220
|
+
direction: source.from_path === a ? "forward" : "reverse",
|
|
5221
|
+
});
|
|
5222
|
+
}
|
|
5223
|
+
return { path, edges: pathEdges };
|
|
5224
|
+
}
|
|
5225
|
+
queue.push(next);
|
|
5226
|
+
}
|
|
5227
|
+
}
|
|
5228
|
+
return null;
|
|
5229
|
+
}
|
|
5230
|
+
function kageDependencyPath(projectDir, from, to) {
|
|
5231
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5232
|
+
const warnings = [];
|
|
5233
|
+
const resolvedFrom = resolveCodeGraphPath(projectDir, graph, from, warnings, "Source");
|
|
5234
|
+
const resolvedTo = resolveCodeGraphPath(projectDir, graph, to, warnings, "Target");
|
|
5235
|
+
if (!resolvedFrom || !resolvedTo) {
|
|
5236
|
+
return {
|
|
5237
|
+
schema_version: 1,
|
|
5238
|
+
project_dir: projectDir,
|
|
5239
|
+
generated_at: nowIso(),
|
|
5240
|
+
from,
|
|
5241
|
+
to,
|
|
5242
|
+
resolved_from: resolvedFrom,
|
|
5243
|
+
resolved_to: resolvedTo,
|
|
5244
|
+
relation: "none",
|
|
5245
|
+
path: [],
|
|
5246
|
+
edges: [],
|
|
5247
|
+
distance: null,
|
|
5248
|
+
summary: "No dependency path could be computed because one or both targets were not resolved.",
|
|
5249
|
+
warnings,
|
|
5250
|
+
};
|
|
5251
|
+
}
|
|
5252
|
+
const forward = shortestDependencyPath(graph, resolvedFrom, resolvedTo, "forward");
|
|
5253
|
+
if (forward) {
|
|
5254
|
+
return {
|
|
5255
|
+
schema_version: 1,
|
|
5256
|
+
project_dir: projectDir,
|
|
5257
|
+
generated_at: nowIso(),
|
|
5258
|
+
from,
|
|
5259
|
+
to,
|
|
5260
|
+
resolved_from: resolvedFrom,
|
|
5261
|
+
resolved_to: resolvedTo,
|
|
5262
|
+
relation: "source_depends_on_target",
|
|
5263
|
+
path: forward.path,
|
|
5264
|
+
edges: forward.edges,
|
|
5265
|
+
distance: Math.max(0, forward.path.length - 1),
|
|
5266
|
+
summary: `${resolvedFrom} depends on ${resolvedTo} through ${Math.max(0, forward.path.length - 1)} import edge(s).`,
|
|
5267
|
+
warnings,
|
|
5268
|
+
};
|
|
5269
|
+
}
|
|
5270
|
+
const reverse = shortestDependencyPath(graph, resolvedTo, resolvedFrom, "forward");
|
|
5271
|
+
if (reverse) {
|
|
5272
|
+
return {
|
|
5273
|
+
schema_version: 1,
|
|
5274
|
+
project_dir: projectDir,
|
|
5275
|
+
generated_at: nowIso(),
|
|
5276
|
+
from,
|
|
5277
|
+
to,
|
|
5278
|
+
resolved_from: resolvedFrom,
|
|
5279
|
+
resolved_to: resolvedTo,
|
|
5280
|
+
relation: "target_depends_on_source",
|
|
5281
|
+
path: reverse.path.slice().reverse(),
|
|
5282
|
+
edges: reverse.edges.slice().reverse(),
|
|
5283
|
+
distance: Math.max(0, reverse.path.length - 1),
|
|
5284
|
+
summary: `${resolvedTo} depends on ${resolvedFrom}; changing ${resolvedFrom} may affect ${resolvedTo}.`,
|
|
5285
|
+
warnings,
|
|
5286
|
+
};
|
|
5287
|
+
}
|
|
5288
|
+
const undirected = shortestDependencyPath(graph, resolvedFrom, resolvedTo, "undirected");
|
|
5289
|
+
if (undirected) {
|
|
5290
|
+
return {
|
|
5291
|
+
schema_version: 1,
|
|
5292
|
+
project_dir: projectDir,
|
|
5293
|
+
generated_at: nowIso(),
|
|
5294
|
+
from,
|
|
5295
|
+
to,
|
|
5296
|
+
resolved_from: resolvedFrom,
|
|
5297
|
+
resolved_to: resolvedTo,
|
|
5298
|
+
relation: "connected_undirected",
|
|
5299
|
+
path: undirected.path,
|
|
5300
|
+
edges: undirected.edges,
|
|
5301
|
+
distance: Math.max(0, undirected.path.length - 1),
|
|
5302
|
+
summary: `${resolvedFrom} and ${resolvedTo} are connected in the import graph, but not by a direct dependency direction from source to target.`,
|
|
5303
|
+
warnings,
|
|
5304
|
+
};
|
|
5305
|
+
}
|
|
5306
|
+
return {
|
|
5307
|
+
schema_version: 1,
|
|
5308
|
+
project_dir: projectDir,
|
|
5309
|
+
generated_at: nowIso(),
|
|
5310
|
+
from,
|
|
5311
|
+
to,
|
|
5312
|
+
resolved_from: resolvedFrom,
|
|
5313
|
+
resolved_to: resolvedTo,
|
|
5314
|
+
relation: "none",
|
|
5315
|
+
path: [],
|
|
5316
|
+
edges: [],
|
|
5317
|
+
distance: null,
|
|
5318
|
+
summary: `${resolvedFrom} and ${resolvedTo} are not connected in the current code graph.`,
|
|
5319
|
+
warnings,
|
|
5320
|
+
};
|
|
5321
|
+
}
|
|
5322
|
+
function isEntrypointLike(path) {
|
|
5323
|
+
const name = (0, node_path_1.basename)(path).replace(/\.[^.]+$/, "").toLowerCase();
|
|
5324
|
+
if (["index", "main", "server", "app", "cli", "bin", "daemon", "worker", "setup", "config"].includes(name))
|
|
5325
|
+
return true;
|
|
5326
|
+
return /(^|\/)(bin|scripts|commands|pages|app|routes)\//.test(path);
|
|
5327
|
+
}
|
|
5328
|
+
function cleanupConfidence(score) {
|
|
5329
|
+
if (score >= 0.8)
|
|
5330
|
+
return "high";
|
|
5331
|
+
if (score >= 0.55)
|
|
5332
|
+
return "medium";
|
|
5333
|
+
return "low";
|
|
5334
|
+
}
|
|
5335
|
+
function runtimeReferenceNeedles(path) {
|
|
5336
|
+
const withoutExtension = path.replace(/\.[^.]+$/, "");
|
|
5337
|
+
const compiledJs = /\.(?:ts|tsx|mts|cts)$/.test(path) ? path.replace(/\.(?:ts|tsx|mts|cts)$/, ".js") : path;
|
|
5338
|
+
return unique([
|
|
5339
|
+
path,
|
|
5340
|
+
compiledJs,
|
|
5341
|
+
(0, node_path_1.basename)(path),
|
|
5342
|
+
(0, node_path_1.basename)(compiledJs),
|
|
5343
|
+
(0, node_path_1.basename)(withoutExtension),
|
|
5344
|
+
]).filter((item) => item.length >= 4);
|
|
5345
|
+
}
|
|
5346
|
+
function hasRuntimePathReference(projectDir, graph, target) {
|
|
5347
|
+
const needles = runtimeReferenceNeedles(target);
|
|
5348
|
+
for (const file of graph.files) {
|
|
5349
|
+
if (file.path === target)
|
|
5350
|
+
continue;
|
|
5351
|
+
if (!["source", "config", "manifest"].includes(file.kind))
|
|
5352
|
+
continue;
|
|
5353
|
+
if (file.size_bytes > MAX_CODE_FILE_BYTES)
|
|
5354
|
+
continue;
|
|
5355
|
+
const absolutePath = (0, node_path_1.join)(projectDir, file.path);
|
|
5356
|
+
if (!(0, node_fs_1.existsSync)(absolutePath))
|
|
5357
|
+
continue;
|
|
5358
|
+
const text = (0, node_fs_1.readFileSync)(absolutePath, "utf8");
|
|
5359
|
+
if (needles.some((needle) => text.includes(needle)))
|
|
5360
|
+
return true;
|
|
5361
|
+
}
|
|
5362
|
+
return false;
|
|
5363
|
+
}
|
|
5364
|
+
function kageCleanupCandidates(projectDir) {
|
|
5365
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5366
|
+
const fileByPath = new Map(graph.files.map((file) => [file.path, file]));
|
|
5367
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5368
|
+
const inbound = new Map();
|
|
5369
|
+
const outbound = new Map();
|
|
5370
|
+
for (const edge of graph.imports) {
|
|
5371
|
+
if (!edge.to_path)
|
|
5372
|
+
continue;
|
|
5373
|
+
const inList = inbound.get(edge.to_path) ?? [];
|
|
5374
|
+
inList.push(edge);
|
|
5375
|
+
inbound.set(edge.to_path, inList);
|
|
5376
|
+
const outList = outbound.get(edge.from_path) ?? [];
|
|
5377
|
+
outList.push(edge);
|
|
5378
|
+
outbound.set(edge.from_path, outList);
|
|
5379
|
+
}
|
|
5380
|
+
const routeFiles = new Set(graph.routes.map((route) => route.file_path));
|
|
5381
|
+
const skippedEntryPoints = [];
|
|
5382
|
+
const warnings = [];
|
|
5383
|
+
const hasGit = Boolean(gitHead(projectDir));
|
|
5384
|
+
if (!hasGit)
|
|
5385
|
+
warnings.push("Git history is unavailable, so cleanup confidence does not use recency.");
|
|
5386
|
+
const candidates = [];
|
|
5387
|
+
const skippedRuntimeReferences = [];
|
|
5388
|
+
for (const file of graph.files) {
|
|
5389
|
+
if (file.kind !== "source")
|
|
5390
|
+
continue;
|
|
5391
|
+
if (isEntrypointLike(file.path) || routeFiles.has(file.path)) {
|
|
5392
|
+
skippedEntryPoints.push(file.path);
|
|
5393
|
+
continue;
|
|
5394
|
+
}
|
|
5395
|
+
const inboundEdges = inbound.get(file.path) ?? [];
|
|
5396
|
+
const sourceInbound = inboundEdges.filter((edge) => fileByPath.get(edge.from_path)?.kind === "source");
|
|
5397
|
+
if (inboundEdges.length > 0 || sourceInbound.length > 0)
|
|
5398
|
+
continue;
|
|
5399
|
+
if (hasRuntimePathReference(projectDir, graph, file.path)) {
|
|
5400
|
+
skippedRuntimeReferences.push(file.path);
|
|
5401
|
+
continue;
|
|
5402
|
+
}
|
|
5403
|
+
const coveredByTests = hasTestCoverage(file.path, graph);
|
|
5404
|
+
const git = hasGit ? gitFileSignal(projectDir, file.path, graphPaths) : null;
|
|
5405
|
+
const reasons = [
|
|
5406
|
+
"no inbound imports in the current code graph",
|
|
5407
|
+
"not recognized as an entrypoint or route file",
|
|
5408
|
+
];
|
|
5409
|
+
let score = 0.55;
|
|
5410
|
+
if (!coveredByTests) {
|
|
5411
|
+
score += 0.15;
|
|
5412
|
+
reasons.push("no direct test coverage signal");
|
|
5413
|
+
}
|
|
5414
|
+
else {
|
|
5415
|
+
reasons.push("has a test coverage signal, verify before cleanup");
|
|
5416
|
+
}
|
|
5417
|
+
if (git && git.commit_count_90d === 0) {
|
|
5418
|
+
score += 0.15;
|
|
5419
|
+
reasons.push("no commits in the last 90 days");
|
|
5420
|
+
}
|
|
5421
|
+
else if (git && git.commit_count_90d > 0) {
|
|
5422
|
+
score -= 0.1;
|
|
5423
|
+
reasons.push(`${git.commit_count_90d} commit(s) in the last 90 days`);
|
|
5424
|
+
}
|
|
5425
|
+
if (file.line_count <= 20)
|
|
5426
|
+
score += 0.05;
|
|
5427
|
+
score = Number(Math.max(0, Math.min(1, score)).toFixed(2));
|
|
5428
|
+
candidates.push({
|
|
5429
|
+
path: file.path,
|
|
5430
|
+
kind: "unreferenced_file",
|
|
5431
|
+
confidence: cleanupConfidence(score),
|
|
5432
|
+
score,
|
|
5433
|
+
reasons,
|
|
5434
|
+
inbound_imports: inboundEdges.length,
|
|
5435
|
+
source_inbound_imports: sourceInbound.length,
|
|
5436
|
+
outbound_imports: (outbound.get(file.path) ?? []).length,
|
|
5437
|
+
covered_by_tests: coveredByTests,
|
|
5438
|
+
last_commit_at: git?.last_commit_at ?? null,
|
|
5439
|
+
});
|
|
5440
|
+
}
|
|
5441
|
+
candidates.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
5442
|
+
return {
|
|
5443
|
+
schema_version: 1,
|
|
5444
|
+
project_dir: projectDir,
|
|
5445
|
+
generated_at: nowIso(),
|
|
5446
|
+
candidates,
|
|
5447
|
+
skipped_entrypoints: skippedEntryPoints.sort(),
|
|
5448
|
+
skipped_runtime_references: skippedRuntimeReferences.sort(),
|
|
5449
|
+
warnings,
|
|
5450
|
+
summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
|
|
5451
|
+
};
|
|
5452
|
+
}
|
|
5453
|
+
function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
|
|
5454
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5455
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5456
|
+
const resolvedTargets = unique((targets.length ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
|
|
5457
|
+
.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
|
|
5458
|
+
.filter((path) => path && !isNoisePath(path)));
|
|
5459
|
+
const warnings = [];
|
|
5460
|
+
if (!gitHead(projectDir))
|
|
5461
|
+
warnings.push("Git history is unavailable, so reviewer suggestions cannot be computed.");
|
|
5462
|
+
if (!resolvedTargets.length)
|
|
5463
|
+
warnings.push("No targets supplied and no changed files detected.");
|
|
5464
|
+
const scores = new Map();
|
|
5465
|
+
const ensure = (reviewer) => {
|
|
5466
|
+
const existing = scores.get(reviewer);
|
|
5467
|
+
if (existing)
|
|
5468
|
+
return existing;
|
|
5469
|
+
const created = {
|
|
5470
|
+
reviewer,
|
|
5471
|
+
score: 0,
|
|
5472
|
+
reasons: [],
|
|
5473
|
+
authored_targets: [],
|
|
5474
|
+
cochange_targets: [],
|
|
5475
|
+
commit_count_total: 0,
|
|
5476
|
+
commit_count_90d: 0,
|
|
5477
|
+
};
|
|
5478
|
+
scores.set(reviewer, created);
|
|
5479
|
+
return created;
|
|
5480
|
+
};
|
|
5481
|
+
for (const target of resolvedTargets) {
|
|
5482
|
+
const allAuthors = gitAuthorCountsForPath(projectDir, target);
|
|
5483
|
+
const recentAuthors = gitAuthorCountsForPath(projectDir, target, "90 days ago");
|
|
5484
|
+
for (const [author, count] of allAuthors.entries()) {
|
|
5485
|
+
const item = ensure(author);
|
|
5486
|
+
item.score += Math.min(30, count * 4);
|
|
5487
|
+
item.commit_count_total += count;
|
|
5488
|
+
if (!item.authored_targets.includes(target))
|
|
5489
|
+
item.authored_targets.push(target);
|
|
5490
|
+
item.reasons.push(`${count} historical commit(s) on ${target}`);
|
|
5491
|
+
}
|
|
5492
|
+
for (const [author, count] of recentAuthors.entries()) {
|
|
5493
|
+
const item = ensure(author);
|
|
5494
|
+
item.score += Math.min(20, count * 5);
|
|
5495
|
+
item.commit_count_90d += count;
|
|
5496
|
+
item.reasons.push(`${count} recent commit(s) on ${target}`);
|
|
5497
|
+
}
|
|
5498
|
+
for (const partner of gitCoChangePartnersForPath(projectDir, target, graphPaths)) {
|
|
5499
|
+
const owner = gitPrimaryOwnerForPath(projectDir, partner.file_path).primary_owner;
|
|
5500
|
+
if (!owner)
|
|
5501
|
+
continue;
|
|
5502
|
+
const item = ensure(owner);
|
|
5503
|
+
item.score += Math.min(12, partner.count * 3);
|
|
5504
|
+
if (!item.cochange_targets.includes(partner.file_path))
|
|
5505
|
+
item.cochange_targets.push(partner.file_path);
|
|
5506
|
+
item.reasons.push(`${partner.file_path} changed with ${target} ${partner.count} time(s)`);
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
const suggestions = [...scores.values()]
|
|
5510
|
+
.map((item) => ({
|
|
5511
|
+
...item,
|
|
5512
|
+
score: Number(Math.min(100, item.score).toFixed(2)),
|
|
5513
|
+
reasons: unique(item.reasons).slice(0, 8),
|
|
5514
|
+
authored_targets: item.authored_targets.sort(),
|
|
5515
|
+
cochange_targets: item.cochange_targets.sort(),
|
|
5516
|
+
}))
|
|
5517
|
+
.sort((a, b) => b.score - a.score || a.reviewer.localeCompare(b.reviewer))
|
|
5518
|
+
.slice(0, 5);
|
|
5519
|
+
return {
|
|
5520
|
+
schema_version: 1,
|
|
5521
|
+
project_dir: projectDir,
|
|
5522
|
+
generated_at: nowIso(),
|
|
5523
|
+
targets: resolvedTargets,
|
|
5524
|
+
suggestions,
|
|
5525
|
+
warnings,
|
|
5526
|
+
summary: suggestions.length
|
|
5527
|
+
? `Suggested ${suggestions.length} reviewer(s) for ${resolvedTargets.length} target file(s).`
|
|
5528
|
+
: `No reviewer suggestions for ${resolvedTargets.length} target file(s).`,
|
|
5529
|
+
};
|
|
5530
|
+
}
|
|
5531
|
+
function kageContributors(projectDir) {
|
|
5532
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5533
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5534
|
+
const warnings = [];
|
|
5535
|
+
if (!gitHead(projectDir))
|
|
5536
|
+
warnings.push("Git history is unavailable, so contributor profiles cannot be computed.");
|
|
5537
|
+
const byContributor = new Map();
|
|
5538
|
+
const ensure = (contributor) => {
|
|
5539
|
+
const existing = byContributor.get(contributor);
|
|
5540
|
+
if (existing)
|
|
5541
|
+
return existing;
|
|
5542
|
+
const created = { commits_total: 0, files: new Map(), categories: new Map() };
|
|
5543
|
+
byContributor.set(contributor, created);
|
|
5544
|
+
return created;
|
|
5545
|
+
};
|
|
5546
|
+
for (const record of gitCommitRecords(projectDir)) {
|
|
5547
|
+
if (!record.author)
|
|
5548
|
+
continue;
|
|
5549
|
+
const item = ensure(record.author);
|
|
5550
|
+
item.commits_total += 1;
|
|
5551
|
+
const category = commitCategory(record.subject);
|
|
5552
|
+
item.categories.set(category, (item.categories.get(category) ?? 0) + 1);
|
|
5553
|
+
for (const file of unique(record.files.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)).filter((path) => graphPaths.has(path))) {
|
|
5554
|
+
item.files.set(file, (item.files.get(file) ?? 0) + 1);
|
|
5555
|
+
}
|
|
5556
|
+
}
|
|
5557
|
+
const recentCommits = new Map();
|
|
5558
|
+
for (const author of gitLines(projectDir, ["log", "--since=90 days ago", "--format=%an <%ae>"])) {
|
|
5559
|
+
recentCommits.set(author, (recentCommits.get(author) ?? 0) + 1);
|
|
5560
|
+
}
|
|
5561
|
+
const ownedFiles = new Map();
|
|
5562
|
+
for (const file of graph.files.filter((item) => item.kind === "source")) {
|
|
5563
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file.path);
|
|
5564
|
+
if (!owner.primary_owner || owner.primary_owner_pct == null)
|
|
5565
|
+
continue;
|
|
5566
|
+
const commits = gitCommitCountForPath(projectDir, file.path);
|
|
5567
|
+
const list = ownedFiles.get(owner.primary_owner) ?? [];
|
|
5568
|
+
list.push({ path: file.path, ownership_pct: owner.primary_owner_pct, commits });
|
|
5569
|
+
ownedFiles.set(owner.primary_owner, list);
|
|
5570
|
+
}
|
|
5571
|
+
const hotspots = globalGitHotspots(projectDir, graph);
|
|
5572
|
+
const profiles = [...byContributor.entries()].map(([contributor, item]) => {
|
|
5573
|
+
const filesTouched = [...item.files.entries()]
|
|
5574
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
5575
|
+
.slice(0, 8)
|
|
5576
|
+
.map(([path, commits]) => ({ path, commits }));
|
|
5577
|
+
const modules = countBy([...item.files.keys()], moduleNameForPath);
|
|
5578
|
+
const modulesTouched = Object.entries(modules)
|
|
5579
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
5580
|
+
.slice(0, 6)
|
|
5581
|
+
.map(([module, files]) => ({ module, files }));
|
|
5582
|
+
const owned = (ownedFiles.get(contributor) ?? []).sort((a, b) => b.ownership_pct - a.ownership_pct || b.commits - a.commits || a.path.localeCompare(b.path));
|
|
5583
|
+
const siloFiles = owned.filter((file) => file.ownership_pct >= 0.8 && file.commits >= 5).slice(0, 8);
|
|
5584
|
+
const hotspotFiles = hotspots
|
|
5585
|
+
.filter((hotspot) => hotspot.primary_owner === contributor)
|
|
5586
|
+
.map((hotspot) => ({ path: hotspot.file_path, hotspot_score: hotspot.hotspot_score, commits_90d: hotspot.commit_count_90d }))
|
|
5587
|
+
.slice(0, 6);
|
|
5588
|
+
const categories = Object.fromEntries([...item.categories.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
|
|
5589
|
+
return {
|
|
5590
|
+
contributor,
|
|
5591
|
+
commits_total: item.commits_total,
|
|
5592
|
+
commits_90d: recentCommits.get(contributor) ?? 0,
|
|
5593
|
+
files_touched: filesTouched,
|
|
5594
|
+
modules_touched: modulesTouched,
|
|
5595
|
+
primary_owned_files: owned.length,
|
|
5596
|
+
silo_files: siloFiles,
|
|
5597
|
+
hotspot_files: hotspotFiles,
|
|
5598
|
+
commit_categories: categories,
|
|
5599
|
+
summary: `${contributor}: ${item.commits_total} commit(s), ${recentCommits.get(contributor) ?? 0} in 90d, ${owned.length} primary-owned source file(s), ${siloFiles.length} silo file(s).`,
|
|
5600
|
+
};
|
|
5601
|
+
})
|
|
5602
|
+
.sort((a, b) => b.commits_90d - a.commits_90d || b.commits_total - a.commits_total || a.contributor.localeCompare(b.contributor))
|
|
5603
|
+
.slice(0, 20);
|
|
5604
|
+
return {
|
|
5605
|
+
schema_version: 1,
|
|
5606
|
+
project_dir: projectDir,
|
|
5607
|
+
generated_at: nowIso(),
|
|
5608
|
+
contributors: profiles,
|
|
5609
|
+
warnings,
|
|
5610
|
+
summary: profiles.length
|
|
5611
|
+
? `${profiles.length} contributor profile(s). Most active: ${profiles[0].contributor} with ${profiles[0].commits_90d} commit(s) in 90d.`
|
|
5612
|
+
: "No contributor profiles could be computed.",
|
|
5613
|
+
};
|
|
5614
|
+
}
|
|
5615
|
+
const DECISION_INTELLIGENCE_TYPES = new Set([
|
|
5616
|
+
"bug_fix",
|
|
5617
|
+
"code_explanation",
|
|
5618
|
+
"constraint",
|
|
5619
|
+
"convention",
|
|
5620
|
+
"decision",
|
|
5621
|
+
"gotcha",
|
|
5622
|
+
"negative_result",
|
|
5623
|
+
"policy",
|
|
5624
|
+
"rationale",
|
|
5625
|
+
"runbook",
|
|
5626
|
+
"workflow",
|
|
5627
|
+
]);
|
|
5628
|
+
function decisionContextValue(packet, key) {
|
|
5629
|
+
const value = packet.context?.[key];
|
|
5630
|
+
if (Array.isArray(value))
|
|
5631
|
+
return value.join("; ");
|
|
5632
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
5633
|
+
}
|
|
5634
|
+
function qualityScore(packet) {
|
|
5635
|
+
const score = Number(packet.quality.score);
|
|
5636
|
+
return Number.isFinite(score) ? score : null;
|
|
5637
|
+
}
|
|
5638
|
+
function kageDecisionIntelligence(projectDir) {
|
|
5639
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5640
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5641
|
+
const sourcePaths = new Set(graph.files.filter((file) => file.kind === "source" || file.kind === "test").map((file) => file.path));
|
|
5642
|
+
const approved = loadApprovedPackets(projectDir);
|
|
5643
|
+
const decisionPackets = approved.filter((packet) => DECISION_INTELLIGENCE_TYPES.has(packet.type));
|
|
5644
|
+
const warnings = [];
|
|
5645
|
+
const packetsByPath = new Map();
|
|
5646
|
+
const byType = countBy(decisionPackets, (packet) => packet.type);
|
|
5647
|
+
for (const packet of decisionPackets) {
|
|
5648
|
+
for (const path of packet.paths.filter((item) => sourcePaths.has(item))) {
|
|
5649
|
+
const list = packetsByPath.get(path) ?? [];
|
|
5650
|
+
list.push(packet);
|
|
5651
|
+
packetsByPath.set(path, list);
|
|
5652
|
+
}
|
|
5653
|
+
}
|
|
5654
|
+
const { forward, reverse } = codeGraphAdjacency(graph);
|
|
5655
|
+
const rank = filePageRank(graph, forward);
|
|
5656
|
+
const hotspots = new Map(globalGitHotspots(projectDir, graph).map((hotspot) => [hotspot.file_path, hotspot]));
|
|
5657
|
+
const coverageGaps = graph.files
|
|
5658
|
+
.filter((file) => sourcePaths.has(file.path) && !packetsByPath.has(file.path))
|
|
5659
|
+
.map((file) => {
|
|
5660
|
+
const hotspot = hotspots.get(file.path);
|
|
5661
|
+
const dependents = reverse.get(file.path)?.size ?? 0;
|
|
5662
|
+
const churn90d = hotspot?.commit_count_90d ?? (gitHead(projectDir) ? gitCommitCountForPath(projectDir, file.path, "90 days ago") : 0);
|
|
5663
|
+
const signals = [];
|
|
5664
|
+
if (dependents)
|
|
5665
|
+
signals.push(`${dependents} dependent(s)`);
|
|
5666
|
+
if (churn90d)
|
|
5667
|
+
signals.push(`${churn90d} commit(s) in 90d`);
|
|
5668
|
+
if (file.kind === "source" && !hasTestCoverage(file.path, graph))
|
|
5669
|
+
signals.push("no direct test signal");
|
|
5670
|
+
return {
|
|
5671
|
+
path: file.path,
|
|
5672
|
+
reason: signals.length ? `No decision memory despite ${signals.join(", ")}.` : "No decision memory is linked to this code path.",
|
|
5673
|
+
dependents,
|
|
5674
|
+
churn_90d: churn90d,
|
|
5675
|
+
primary_owner: hotspot?.primary_owner ?? gitPrimaryOwnerForPath(projectDir, file.path).primary_owner,
|
|
5676
|
+
};
|
|
5677
|
+
})
|
|
5678
|
+
.sort((a, b) => {
|
|
5679
|
+
const aScore = (rank.get(a.path) ?? 0) * 1000 + a.dependents * 10 + a.churn_90d;
|
|
5680
|
+
const bScore = (rank.get(b.path) ?? 0) * 1000 + b.dependents * 10 + b.churn_90d;
|
|
5681
|
+
return bScore - aScore || a.path.localeCompare(b.path);
|
|
5682
|
+
})
|
|
5683
|
+
.slice(0, 20);
|
|
5684
|
+
const topDecisions = decisionPackets
|
|
5685
|
+
.map((packet) => ({
|
|
5686
|
+
packet_id: packet.id,
|
|
5687
|
+
title: packet.title,
|
|
5688
|
+
type: packet.type,
|
|
5689
|
+
paths: packet.paths.filter((path) => graphPaths.has(path)).slice(0, 8),
|
|
5690
|
+
summary: packet.summary,
|
|
5691
|
+
why: decisionContextValue(packet, "why"),
|
|
5692
|
+
risk_if_forgotten: decisionContextValue(packet, "risk_if_forgotten"),
|
|
5693
|
+
verification: decisionContextValue(packet, "verification"),
|
|
5694
|
+
quality_score: qualityScore(packet),
|
|
5695
|
+
}))
|
|
5696
|
+
.sort((a, b) => (b.quality_score ?? 0) - (a.quality_score ?? 0) || b.paths.length - a.paths.length || a.title.localeCompare(b.title))
|
|
5697
|
+
.slice(0, 20);
|
|
5698
|
+
const weakOrStale = decisionPackets
|
|
5699
|
+
.map((packet) => {
|
|
5700
|
+
const quality = evaluateMemoryQuality(projectDir, packet);
|
|
5701
|
+
const reasons = unique([
|
|
5702
|
+
...staleMemoryReasons(projectDir, packet),
|
|
5703
|
+
...(quality.risks ?? []),
|
|
5704
|
+
]);
|
|
5705
|
+
if (qualityScore(packet) !== null && Number(quality.score) < 72 && !reasons.includes(`quality score ${quality.score}`)) {
|
|
5706
|
+
reasons.push(`quality score ${quality.score}`);
|
|
5707
|
+
}
|
|
5708
|
+
return { packet, reasons };
|
|
5709
|
+
})
|
|
5710
|
+
.filter((item) => item.reasons.length > 0)
|
|
5711
|
+
.sort((a, b) => b.reasons.length - a.reasons.length || a.packet.title.localeCompare(b.packet.title))
|
|
5712
|
+
.slice(0, 20)
|
|
5713
|
+
.map((item) => ({
|
|
5714
|
+
packet_id: item.packet.id,
|
|
5715
|
+
title: item.packet.title,
|
|
5716
|
+
type: item.packet.type,
|
|
5717
|
+
reasons: item.reasons,
|
|
5718
|
+
paths: item.packet.paths.slice(0, 8),
|
|
5719
|
+
}));
|
|
5720
|
+
if (!gitHead(projectDir))
|
|
5721
|
+
warnings.push("Git history is unavailable, so coverage gaps do not include churn or ownership signals.");
|
|
5722
|
+
const coveragePercent = percent(packetsByPath.size, sourcePaths.size);
|
|
5723
|
+
return {
|
|
5724
|
+
schema_version: 1,
|
|
5725
|
+
project_dir: projectDir,
|
|
5726
|
+
generated_at: nowIso(),
|
|
5727
|
+
decision_memory_count: decisionPackets.length,
|
|
5728
|
+
code_paths_with_memory: packetsByPath.size,
|
|
5729
|
+
code_paths_total: sourcePaths.size,
|
|
5730
|
+
coverage_percent: coveragePercent,
|
|
5731
|
+
by_type: byType,
|
|
5732
|
+
top_decisions: topDecisions,
|
|
5733
|
+
coverage_gaps: coverageGaps,
|
|
5734
|
+
weak_or_stale_memory: weakOrStale,
|
|
5735
|
+
warnings,
|
|
5736
|
+
summary: `${decisionPackets.length} why-memory packet(s) cover ${packetsByPath.size}/${sourcePaths.size} code path(s) (${coveragePercent}%). ${coverageGaps.length} high-signal uncovered path(s) surfaced.`,
|
|
5737
|
+
};
|
|
5738
|
+
}
|
|
5739
|
+
function moduleNameForPath(path) {
|
|
5740
|
+
const parts = path.split("/");
|
|
5741
|
+
if (parts.length <= 1)
|
|
5742
|
+
return "(root)";
|
|
5743
|
+
if (parts[0] === "mcp" && parts.length > 2)
|
|
5744
|
+
return `${parts[0]}/${parts[1]}`;
|
|
5745
|
+
return parts[0];
|
|
5746
|
+
}
|
|
5747
|
+
function moduleGrade(score) {
|
|
5748
|
+
if (score >= 85)
|
|
5749
|
+
return "A";
|
|
5750
|
+
if (score >= 70)
|
|
5751
|
+
return "B";
|
|
5752
|
+
if (score >= 50)
|
|
5753
|
+
return "C";
|
|
5754
|
+
return "D";
|
|
5755
|
+
}
|
|
5756
|
+
function kageModuleHealth(projectDir) {
|
|
5757
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5758
|
+
const cleanup = kageCleanupCandidates(projectDir);
|
|
5759
|
+
const cleanupByModule = countBy(cleanup.candidates, (candidate) => moduleNameForPath(candidate.path));
|
|
5760
|
+
const warnings = [...cleanup.warnings];
|
|
5761
|
+
const hasGit = Boolean(gitHead(projectDir));
|
|
5762
|
+
if (!hasGit)
|
|
5763
|
+
warnings.push("Git history is unavailable, so module health excludes churn and ownership signals.");
|
|
5764
|
+
const modules = new Map();
|
|
5765
|
+
for (const file of graph.files) {
|
|
5766
|
+
const name = moduleNameForPath(file.path);
|
|
5767
|
+
const list = modules.get(name) ?? [];
|
|
5768
|
+
list.push(file);
|
|
5769
|
+
modules.set(name, list);
|
|
5770
|
+
}
|
|
5771
|
+
const items = [];
|
|
5772
|
+
for (const [module, files] of modules.entries()) {
|
|
5773
|
+
const paths = new Set(files.map((file) => file.path));
|
|
5774
|
+
const sourceFiles = files.filter((file) => file.kind === "source");
|
|
5775
|
+
const testFiles = files.filter((file) => file.kind === "test");
|
|
5776
|
+
const symbols = graph.symbols.filter((symbol) => paths.has(symbol.path)).length;
|
|
5777
|
+
const imports = graph.imports.filter((edge) => paths.has(edge.from_path)).length;
|
|
5778
|
+
const routes = graph.routes.filter((route) => paths.has(route.file_path)).length;
|
|
5779
|
+
const tests = graph.tests.filter((test) => paths.has(test.test_path)).length;
|
|
5780
|
+
const cleanupCandidates = cleanupByModule[module] ?? 0;
|
|
5781
|
+
const testGapFiles = sourceFiles.filter((file) => !hasTestCoverage(file.path, graph)).length;
|
|
5782
|
+
let churn90d = 0;
|
|
5783
|
+
const ownerCounts = new Map();
|
|
5784
|
+
if (hasGit) {
|
|
5785
|
+
for (const file of sourceFiles) {
|
|
5786
|
+
churn90d += gitCommitCountForPath(projectDir, file.path, "90 days ago");
|
|
5787
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file.path).primary_owner;
|
|
5788
|
+
if (owner)
|
|
5789
|
+
ownerCounts.set(owner, (ownerCounts.get(owner) ?? 0) + 1);
|
|
5790
|
+
}
|
|
5791
|
+
}
|
|
5792
|
+
const primaryOwners = [...ownerCounts.entries()]
|
|
5793
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
5794
|
+
.slice(0, 3)
|
|
5795
|
+
.map(([owner, count]) => ({ owner, files: count }));
|
|
5796
|
+
const singleOwnerPenalty = primaryOwners[0] && sourceFiles.length >= 3 && primaryOwners[0].files / sourceFiles.length >= 0.8 ? 10 : 0;
|
|
5797
|
+
let score = 100;
|
|
5798
|
+
score -= Math.min(30, churn90d * 2);
|
|
5799
|
+
score -= Math.min(25, testGapFiles * 5);
|
|
5800
|
+
score -= Math.min(20, cleanupCandidates * 10);
|
|
5801
|
+
score -= singleOwnerPenalty;
|
|
5802
|
+
score = Number(Math.max(0, Math.min(100, score)).toFixed(2));
|
|
5803
|
+
const reasons = [];
|
|
5804
|
+
if (churn90d)
|
|
5805
|
+
reasons.push(`${churn90d} commit(s) in 90 days`);
|
|
5806
|
+
if (testGapFiles)
|
|
5807
|
+
reasons.push(`${testGapFiles} source file(s) lack direct test signal`);
|
|
5808
|
+
if (cleanupCandidates)
|
|
5809
|
+
reasons.push(`${cleanupCandidates} cleanup candidate(s)`);
|
|
5810
|
+
if (singleOwnerPenalty)
|
|
5811
|
+
reasons.push("ownership concentrated in one primary owner");
|
|
5812
|
+
if (!reasons.length)
|
|
5813
|
+
reasons.push("low churn, no cleanup candidates, and no source test gaps detected");
|
|
5814
|
+
items.push({
|
|
5815
|
+
module,
|
|
5816
|
+
score,
|
|
5817
|
+
grade: moduleGrade(score),
|
|
5818
|
+
files: files.length,
|
|
5819
|
+
source_files: sourceFiles.length,
|
|
5820
|
+
test_files: testFiles.length,
|
|
5821
|
+
symbols,
|
|
5822
|
+
imports,
|
|
5823
|
+
routes,
|
|
5824
|
+
tests,
|
|
5825
|
+
cleanup_candidates: cleanupCandidates,
|
|
5826
|
+
test_gap_files: testGapFiles,
|
|
5827
|
+
churn_90d: churn90d,
|
|
5828
|
+
primary_owners: primaryOwners,
|
|
5829
|
+
reasons,
|
|
5830
|
+
});
|
|
5831
|
+
}
|
|
5832
|
+
items.sort((a, b) => a.score - b.score || b.files - a.files || a.module.localeCompare(b.module));
|
|
5833
|
+
return {
|
|
5834
|
+
schema_version: 1,
|
|
5835
|
+
project_dir: projectDir,
|
|
5836
|
+
generated_at: nowIso(),
|
|
5837
|
+
modules: items,
|
|
5838
|
+
warnings: unique(warnings),
|
|
5839
|
+
summary: `${items.length} module health scorecard(s). Lowest score: ${items[0] ? `${items[0].module} ${items[0].score}` : "none"}.`,
|
|
5840
|
+
};
|
|
5841
|
+
}
|
|
5842
|
+
function codeGraphAdjacency(graph) {
|
|
5843
|
+
const paths = new Set(graph.files.map((file) => file.path));
|
|
5844
|
+
const forward = new Map();
|
|
5845
|
+
const reverse = new Map();
|
|
5846
|
+
for (const file of graph.files) {
|
|
5847
|
+
forward.set(file.path, new Set());
|
|
5848
|
+
reverse.set(file.path, new Set());
|
|
5849
|
+
}
|
|
5850
|
+
for (const edge of graph.imports) {
|
|
5851
|
+
if (!edge.to_path || !paths.has(edge.from_path) || !paths.has(edge.to_path))
|
|
5852
|
+
continue;
|
|
5853
|
+
forward.get(edge.from_path).add(edge.to_path);
|
|
5854
|
+
reverse.get(edge.to_path).add(edge.from_path);
|
|
5855
|
+
}
|
|
5856
|
+
return { forward, reverse };
|
|
5857
|
+
}
|
|
5858
|
+
function filePageRank(graph, forward) {
|
|
5859
|
+
const paths = graph.files.map((file) => file.path);
|
|
5860
|
+
const n = paths.length || 1;
|
|
5861
|
+
const rank = new Map(paths.map((path) => [path, 1 / n]));
|
|
5862
|
+
const damping = 0.85;
|
|
5863
|
+
for (let iteration = 0; iteration < 20; iteration += 1) {
|
|
5864
|
+
const next = new Map(paths.map((path) => [path, (1 - damping) / n]));
|
|
5865
|
+
for (const path of paths) {
|
|
5866
|
+
const outs = [...(forward.get(path) ?? [])];
|
|
5867
|
+
const share = (rank.get(path) ?? 0) / Math.max(1, outs.length);
|
|
5868
|
+
if (!outs.length) {
|
|
5869
|
+
for (const target of paths)
|
|
5870
|
+
next.set(target, (next.get(target) ?? 0) + damping * share / n);
|
|
5871
|
+
}
|
|
5872
|
+
else {
|
|
5873
|
+
for (const target of outs)
|
|
5874
|
+
next.set(target, (next.get(target) ?? 0) + damping * share);
|
|
5875
|
+
}
|
|
5876
|
+
}
|
|
5877
|
+
rank.clear();
|
|
5878
|
+
for (const [path, score] of next)
|
|
5879
|
+
rank.set(path, score);
|
|
5880
|
+
}
|
|
5881
|
+
return rank;
|
|
5882
|
+
}
|
|
5883
|
+
function dependencyCycles(graph, forward) {
|
|
5884
|
+
const paths = graph.files.map((file) => file.path);
|
|
5885
|
+
const indexByPath = new Map();
|
|
5886
|
+
const lowByPath = new Map();
|
|
5887
|
+
const stack = [];
|
|
5888
|
+
const onStack = new Set();
|
|
5889
|
+
const components = [];
|
|
5890
|
+
let index = 0;
|
|
5891
|
+
const strongConnect = (path) => {
|
|
5892
|
+
indexByPath.set(path, index);
|
|
5893
|
+
lowByPath.set(path, index);
|
|
5894
|
+
index += 1;
|
|
5895
|
+
stack.push(path);
|
|
5896
|
+
onStack.add(path);
|
|
5897
|
+
for (const next of forward.get(path) ?? []) {
|
|
5898
|
+
if (!indexByPath.has(next)) {
|
|
5899
|
+
strongConnect(next);
|
|
5900
|
+
lowByPath.set(path, Math.min(lowByPath.get(path) ?? 0, lowByPath.get(next) ?? 0));
|
|
5901
|
+
}
|
|
5902
|
+
else if (onStack.has(next)) {
|
|
5903
|
+
lowByPath.set(path, Math.min(lowByPath.get(path) ?? 0, indexByPath.get(next) ?? 0));
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
if (lowByPath.get(path) === indexByPath.get(path)) {
|
|
5907
|
+
const component = [];
|
|
5908
|
+
while (stack.length) {
|
|
5909
|
+
const next = stack.pop();
|
|
5910
|
+
onStack.delete(next);
|
|
5911
|
+
component.push(next);
|
|
5912
|
+
if (next === path)
|
|
5913
|
+
break;
|
|
5914
|
+
}
|
|
5915
|
+
if (component.length > 1 || (forward.get(path) ?? new Set()).has(path))
|
|
5916
|
+
components.push(component.sort());
|
|
5917
|
+
}
|
|
5918
|
+
};
|
|
5919
|
+
for (const path of paths)
|
|
5920
|
+
if (!indexByPath.has(path))
|
|
5921
|
+
strongConnect(path);
|
|
5922
|
+
return components
|
|
5923
|
+
.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]))
|
|
5924
|
+
.slice(0, 10)
|
|
5925
|
+
.map((files) => ({ files, size: files.length }));
|
|
5926
|
+
}
|
|
5927
|
+
function graphCommunities(graph, forward, reverse) {
|
|
5928
|
+
const visited = new Set();
|
|
5929
|
+
const fileByPath = new Map(graph.files.map((file) => [file.path, file]));
|
|
5930
|
+
const routeByFile = new Map();
|
|
5931
|
+
for (const route of graph.routes) {
|
|
5932
|
+
const list = routeByFile.get(route.file_path) ?? [];
|
|
5933
|
+
list.push(`${route.method} ${route.path}`);
|
|
5934
|
+
routeByFile.set(route.file_path, list);
|
|
5935
|
+
}
|
|
5936
|
+
const components = [];
|
|
5937
|
+
for (const file of graph.files) {
|
|
5938
|
+
if (visited.has(file.path))
|
|
5939
|
+
continue;
|
|
5940
|
+
const queue = [file.path];
|
|
5941
|
+
visited.add(file.path);
|
|
5942
|
+
const component = [];
|
|
5943
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
5944
|
+
const current = queue[index];
|
|
5945
|
+
component.push(current);
|
|
5946
|
+
const neighbors = new Set([...(forward.get(current) ?? []), ...(reverse.get(current) ?? [])]);
|
|
5947
|
+
for (const next of neighbors) {
|
|
5948
|
+
if (visited.has(next))
|
|
5949
|
+
continue;
|
|
5950
|
+
visited.add(next);
|
|
5951
|
+
queue.push(next);
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
components.push(component.sort());
|
|
5955
|
+
}
|
|
5956
|
+
return components
|
|
5957
|
+
.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]))
|
|
5958
|
+
.slice(0, 12)
|
|
5959
|
+
.map((files, index) => {
|
|
5960
|
+
const moduleCounts = countBy(files, moduleNameForPath);
|
|
5961
|
+
const label = Object.entries(moduleCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? `community-${index + 1}`;
|
|
5962
|
+
return {
|
|
5963
|
+
id: index + 1,
|
|
5964
|
+
label,
|
|
5965
|
+
files,
|
|
5966
|
+
entrypoints: files.filter((path) => isEntrypointLike(path) || fileByPath.get(path)?.kind === "manifest").slice(0, 10),
|
|
5967
|
+
routes: files.flatMap((path) => routeByFile.get(path) ?? []).slice(0, 10),
|
|
5968
|
+
};
|
|
5969
|
+
});
|
|
5970
|
+
}
|
|
5971
|
+
function entryFlows(graph, forward) {
|
|
5972
|
+
const routeEntries = graph.routes.map((route) => route.file_path);
|
|
5973
|
+
const entryFiles = graph.files
|
|
5974
|
+
.filter((file) => file.kind === "source" && isEntrypointLike(file.path))
|
|
5975
|
+
.map((file) => file.path);
|
|
5976
|
+
const entries = unique([...routeEntries, ...entryFiles]).slice(0, 8);
|
|
5977
|
+
const flows = [];
|
|
5978
|
+
for (const entry of entries) {
|
|
5979
|
+
const path = [entry];
|
|
5980
|
+
const seen = new Set(path);
|
|
5981
|
+
let current = entry;
|
|
5982
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
5983
|
+
const next = [...(forward.get(current) ?? [])]
|
|
5984
|
+
.filter((candidate) => !seen.has(candidate))
|
|
5985
|
+
.sort((a, b) => (forward.get(b)?.size ?? 0) - (forward.get(a)?.size ?? 0) || a.localeCompare(b))[0];
|
|
5986
|
+
if (!next)
|
|
5987
|
+
break;
|
|
5988
|
+
path.push(next);
|
|
5989
|
+
seen.add(next);
|
|
5990
|
+
current = next;
|
|
5991
|
+
}
|
|
5992
|
+
if (path.length > 1)
|
|
5993
|
+
flows.push({ entry, path });
|
|
5994
|
+
}
|
|
5995
|
+
return flows;
|
|
5996
|
+
}
|
|
5997
|
+
function graphLanguageCoverage(graph) {
|
|
5998
|
+
const byLanguage = new Map();
|
|
5999
|
+
for (const file of graph.files) {
|
|
6000
|
+
if (file.kind !== "source" && file.kind !== "test")
|
|
6001
|
+
continue;
|
|
6002
|
+
const list = byLanguage.get(file.language) ?? [];
|
|
6003
|
+
list.push(file);
|
|
6004
|
+
byLanguage.set(file.language, list);
|
|
6005
|
+
}
|
|
6006
|
+
const preciseParsers = ["scip", "lsif", "lsp"];
|
|
6007
|
+
const astParsers = ["typescript-ast", "tree-sitter"];
|
|
6008
|
+
return [...byLanguage.entries()]
|
|
6009
|
+
.map(([language, files]) => {
|
|
6010
|
+
const precise = files.filter((file) => preciseParsers.includes(file.parser)).length;
|
|
6011
|
+
const ast = files.filter((file) => astParsers.includes(file.parser)).length;
|
|
6012
|
+
const generic = files.filter((file) => file.parser === "generic-static").length;
|
|
6013
|
+
const metadata = files.filter((file) => file.parser === "metadata").length;
|
|
6014
|
+
return {
|
|
6015
|
+
language,
|
|
6016
|
+
files: files.length,
|
|
6017
|
+
precise_files: precise,
|
|
6018
|
+
ast_files: ast,
|
|
6019
|
+
generic_files: generic,
|
|
6020
|
+
metadata_files: metadata,
|
|
6021
|
+
coverage_percent: percent(files.length - metadata, files.length),
|
|
6022
|
+
};
|
|
6023
|
+
})
|
|
6024
|
+
.sort((a, b) => b.files - a.files || a.language.localeCompare(b.language));
|
|
6025
|
+
}
|
|
6026
|
+
function kageGraphInsights(projectDir) {
|
|
6027
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
6028
|
+
const { forward, reverse } = codeGraphAdjacency(graph);
|
|
6029
|
+
const rank = filePageRank(graph, forward);
|
|
6030
|
+
const centralFiles = graph.files
|
|
6031
|
+
.map((file) => ({
|
|
6032
|
+
path: file.path,
|
|
6033
|
+
pagerank: Number((rank.get(file.path) ?? 0).toFixed(6)),
|
|
6034
|
+
dependents: reverse.get(file.path)?.size ?? 0,
|
|
6035
|
+
imports: forward.get(file.path)?.size ?? 0,
|
|
6036
|
+
kind: file.kind,
|
|
6037
|
+
}))
|
|
6038
|
+
.sort((a, b) => b.pagerank - a.pagerank || b.dependents - a.dependents || a.path.localeCompare(b.path))
|
|
6039
|
+
.slice(0, 15);
|
|
6040
|
+
const cycles = dependencyCycles(graph, forward);
|
|
6041
|
+
const communities = graphCommunities(graph, forward, reverse);
|
|
6042
|
+
const flows = entryFlows(graph, forward);
|
|
6043
|
+
return {
|
|
6044
|
+
schema_version: 1,
|
|
6045
|
+
project_dir: projectDir,
|
|
6046
|
+
generated_at: nowIso(),
|
|
6047
|
+
language_coverage: graphLanguageCoverage(graph),
|
|
6048
|
+
edge_mix: {
|
|
6049
|
+
imports: graph.imports.length,
|
|
6050
|
+
calls: graph.calls.length,
|
|
6051
|
+
routes: graph.routes.length,
|
|
6052
|
+
tests: graph.tests.length,
|
|
6053
|
+
packages: graph.packages.length,
|
|
6054
|
+
},
|
|
6055
|
+
central_files: centralFiles,
|
|
6056
|
+
dependency_cycles: cycles,
|
|
6057
|
+
communities,
|
|
6058
|
+
entry_flows: flows,
|
|
6059
|
+
warnings: [],
|
|
6060
|
+
summary: `${centralFiles.length} central file(s), ${cycles.length} dependency cycle(s), ${communities.length} communit${communities.length === 1 ? "y" : "ies"}, ${flows.length} entry flow(s).`,
|
|
6061
|
+
};
|
|
6062
|
+
}
|
|
6063
|
+
const WORKSPACE_SKIP_DIRS = new Set([
|
|
6064
|
+
".agent_memory",
|
|
6065
|
+
".git",
|
|
6066
|
+
".hg",
|
|
6067
|
+
".next",
|
|
6068
|
+
".repowise",
|
|
6069
|
+
".repowise-workspace",
|
|
6070
|
+
"coverage",
|
|
6071
|
+
"dist",
|
|
6072
|
+
"node_modules",
|
|
6073
|
+
"target",
|
|
6074
|
+
"vendor",
|
|
6075
|
+
]);
|
|
6076
|
+
function discoverWorkspaceRepos(rootDir, maxDepth = 3) {
|
|
6077
|
+
const root = (0, node_path_1.resolve)(rootDir);
|
|
6078
|
+
const repos = [];
|
|
6079
|
+
const visit = (dir, depth) => {
|
|
6080
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".git"))) {
|
|
6081
|
+
repos.push(dir);
|
|
6082
|
+
if (depth > 0) {
|
|
6083
|
+
// Keep scanning nested repos for frontend/backend layouts inside a mono root.
|
|
6084
|
+
}
|
|
6085
|
+
}
|
|
6086
|
+
if (depth >= maxDepth)
|
|
6087
|
+
return;
|
|
6088
|
+
let entries = [];
|
|
6089
|
+
try {
|
|
6090
|
+
entries = (0, node_fs_1.readdirSync)(dir).sort();
|
|
6091
|
+
}
|
|
6092
|
+
catch {
|
|
6093
|
+
return;
|
|
6094
|
+
}
|
|
6095
|
+
for (const entry of entries) {
|
|
6096
|
+
if (WORKSPACE_SKIP_DIRS.has(entry) || entry.startsWith("."))
|
|
6097
|
+
continue;
|
|
6098
|
+
const path = (0, node_path_1.join)(dir, entry);
|
|
6099
|
+
let stats;
|
|
6100
|
+
try {
|
|
6101
|
+
stats = (0, node_fs_1.lstatSync)(path);
|
|
6102
|
+
}
|
|
6103
|
+
catch {
|
|
6104
|
+
continue;
|
|
6105
|
+
}
|
|
6106
|
+
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
6107
|
+
continue;
|
|
6108
|
+
visit(path, depth + 1);
|
|
6109
|
+
}
|
|
6110
|
+
};
|
|
6111
|
+
visit(root, 0);
|
|
6112
|
+
return unique(repos).sort((a, b) => (0, node_path_1.relative)(root, a).localeCompare((0, node_path_1.relative)(root, b)));
|
|
6113
|
+
}
|
|
6114
|
+
function workspaceAlias(rootDir, repoPath, used) {
|
|
6115
|
+
const root = (0, node_path_1.resolve)(rootDir);
|
|
6116
|
+
const rel = (0, node_path_1.relative)(root, repoPath).replace(/\\/g, "/");
|
|
6117
|
+
const base = rel && rel !== "" ? (0, node_path_1.basename)(rel) : (0, node_path_1.basename)(repoPath);
|
|
6118
|
+
let alias = slugify(base || "repo");
|
|
6119
|
+
if (!alias)
|
|
6120
|
+
alias = "repo";
|
|
6121
|
+
let next = alias;
|
|
6122
|
+
let suffix = 2;
|
|
6123
|
+
while (used.has(next)) {
|
|
6124
|
+
next = `${alias}-${suffix}`;
|
|
6125
|
+
suffix += 1;
|
|
6126
|
+
}
|
|
6127
|
+
used.add(next);
|
|
6128
|
+
return next;
|
|
6129
|
+
}
|
|
6130
|
+
function packageInfo(projectDir) {
|
|
6131
|
+
const pkgPath = (0, node_path_1.join)(projectDir, "package.json");
|
|
6132
|
+
if (!(0, node_fs_1.existsSync)(pkgPath))
|
|
6133
|
+
return { name: null, dependencies: [] };
|
|
6134
|
+
try {
|
|
6135
|
+
const pkg = readJson(pkgPath);
|
|
6136
|
+
const dependencyBlocks = [
|
|
6137
|
+
pkg.dependencies,
|
|
6138
|
+
pkg.devDependencies,
|
|
6139
|
+
pkg.peerDependencies,
|
|
6140
|
+
pkg.optionalDependencies,
|
|
6141
|
+
].filter((block) => Boolean(block && typeof block === "object" && !Array.isArray(block)));
|
|
6142
|
+
return {
|
|
6143
|
+
name: typeof pkg.name === "string" ? pkg.name : null,
|
|
6144
|
+
dependencies: unique(dependencyBlocks.flatMap((block) => Object.keys(block))).sort(),
|
|
6145
|
+
};
|
|
6146
|
+
}
|
|
6147
|
+
catch {
|
|
6148
|
+
return { name: null, dependencies: [] };
|
|
6149
|
+
}
|
|
6150
|
+
}
|
|
6151
|
+
function workspaceRepoSummary(rootDir, repoPath, alias) {
|
|
6152
|
+
const pkg = packageInfo(repoPath);
|
|
6153
|
+
const graph = readCurrentCodeGraph(repoPath);
|
|
6154
|
+
return {
|
|
6155
|
+
alias,
|
|
6156
|
+
path: (0, node_path_1.relative)((0, node_path_1.resolve)(rootDir), repoPath).replace(/\\/g, "/") || ".",
|
|
6157
|
+
package_name: pkg.name,
|
|
6158
|
+
indexed: (0, node_fs_1.existsSync)(memoryRoot(repoPath)),
|
|
6159
|
+
approved_packets: loadApprovedPackets(repoPath).length,
|
|
6160
|
+
pending_packets: loadPendingPackets(repoPath).length,
|
|
6161
|
+
code_files: graph?.files.length ?? 0,
|
|
6162
|
+
code_symbols: graph?.symbols.length ?? 0,
|
|
6163
|
+
branch: gitBranch(repoPath),
|
|
6164
|
+
head: gitHead(repoPath),
|
|
6165
|
+
dependencies: pkg.dependencies,
|
|
6166
|
+
};
|
|
6167
|
+
}
|
|
6168
|
+
function routeNeedles(routePath) {
|
|
6169
|
+
if (routePath.length < 3 || routePath === "/:dynamic")
|
|
6170
|
+
return [];
|
|
6171
|
+
const normalized = routePath.replace(/:[A-Za-z0-9_]+/g, "");
|
|
6172
|
+
return unique([
|
|
6173
|
+
routePath,
|
|
6174
|
+
normalized,
|
|
6175
|
+
normalized.replace(/\/+$/, ""),
|
|
6176
|
+
].filter((needle) => needle.length >= 3 && needle !== "/"));
|
|
6177
|
+
}
|
|
6178
|
+
function workspaceRouteContracts(workspaceDir, repos) {
|
|
6179
|
+
const graphs = new Map();
|
|
6180
|
+
for (const repo of repos) {
|
|
6181
|
+
const repoPath = repo.path === "." ? workspaceDir : (0, node_path_1.join)(workspaceDir, repo.path);
|
|
6182
|
+
const graph = readCurrentCodeGraph(repoPath);
|
|
6183
|
+
if (graph)
|
|
6184
|
+
graphs.set(repo.alias, graph);
|
|
6185
|
+
}
|
|
6186
|
+
const contracts = [];
|
|
6187
|
+
for (const provider of repos) {
|
|
6188
|
+
const providerGraph = graphs.get(provider.alias);
|
|
6189
|
+
if (!providerGraph?.routes.length)
|
|
6190
|
+
continue;
|
|
6191
|
+
for (const route of providerGraph.routes) {
|
|
6192
|
+
const needles = routeNeedles(route.path);
|
|
6193
|
+
if (!needles.length)
|
|
6194
|
+
continue;
|
|
6195
|
+
for (const consumer of repos) {
|
|
6196
|
+
if (consumer.alias === provider.alias)
|
|
6197
|
+
continue;
|
|
6198
|
+
const consumerGraph = graphs.get(consumer.alias);
|
|
6199
|
+
if (!consumerGraph)
|
|
6200
|
+
continue;
|
|
6201
|
+
const consumerRoot = consumer.path === "." ? workspaceDir : (0, node_path_1.join)(workspaceDir, consumer.path);
|
|
6202
|
+
for (const file of consumerGraph.files) {
|
|
6203
|
+
if (!["source", "config", "manifest"].includes(file.kind))
|
|
6204
|
+
continue;
|
|
6205
|
+
if (file.size_bytes > MAX_CODE_FILE_BYTES)
|
|
6206
|
+
continue;
|
|
6207
|
+
const absolutePath = (0, node_path_1.join)(consumerRoot, file.path);
|
|
6208
|
+
if (!(0, node_fs_1.existsSync)(absolutePath))
|
|
6209
|
+
continue;
|
|
6210
|
+
let text = "";
|
|
6211
|
+
try {
|
|
6212
|
+
text = (0, node_fs_1.readFileSync)(absolutePath, "utf8");
|
|
6213
|
+
}
|
|
6214
|
+
catch {
|
|
6215
|
+
continue;
|
|
6216
|
+
}
|
|
6217
|
+
const matched = needles.find((needle) => text.includes(needle));
|
|
6218
|
+
if (!matched)
|
|
6219
|
+
continue;
|
|
6220
|
+
contracts.push({
|
|
6221
|
+
provider_repo: provider.alias,
|
|
6222
|
+
provider_file: route.file_path,
|
|
6223
|
+
method: route.method,
|
|
6224
|
+
path: route.path,
|
|
6225
|
+
consumer_repo: consumer.alias,
|
|
6226
|
+
consumer_file: file.path,
|
|
6227
|
+
confidence: matched === route.path ? "high" : "medium",
|
|
6228
|
+
evidence: `consumer source mentions ${matched}`,
|
|
6229
|
+
});
|
|
6230
|
+
break;
|
|
6231
|
+
}
|
|
6232
|
+
}
|
|
6233
|
+
}
|
|
6234
|
+
}
|
|
6235
|
+
return contracts
|
|
6236
|
+
.sort((a, b) => a.provider_repo.localeCompare(b.provider_repo) || a.path.localeCompare(b.path) || a.consumer_repo.localeCompare(b.consumer_repo))
|
|
6237
|
+
.slice(0, 50);
|
|
6238
|
+
}
|
|
6239
|
+
function kageWorkspace(projectDir) {
|
|
6240
|
+
const root = (0, node_path_1.resolve)(projectDir);
|
|
6241
|
+
const warnings = [];
|
|
6242
|
+
const repoPaths = discoverWorkspaceRepos(root);
|
|
6243
|
+
if (!repoPaths.length)
|
|
6244
|
+
warnings.push("No git repositories found under the workspace directory.");
|
|
6245
|
+
const usedAliases = new Set();
|
|
6246
|
+
const rawRepos = repoPaths.map((repoPath) => workspaceRepoSummary(root, repoPath, workspaceAlias(root, repoPath, usedAliases)));
|
|
6247
|
+
const packageOwners = new Map();
|
|
6248
|
+
for (const repo of rawRepos) {
|
|
6249
|
+
if (repo.package_name)
|
|
6250
|
+
packageOwners.set(repo.package_name, { alias: repo.alias, package_name: repo.package_name });
|
|
6251
|
+
}
|
|
6252
|
+
const packageDependencies = [];
|
|
6253
|
+
const repos = rawRepos.map((repo) => {
|
|
6254
|
+
const deps = repo.dependencies
|
|
6255
|
+
.map((dep) => packageOwners.get(dep))
|
|
6256
|
+
.filter((dep) => Boolean(dep && dep.alias !== repo.alias))
|
|
6257
|
+
.sort((a, b) => a.alias.localeCompare(b.alias));
|
|
6258
|
+
for (const dep of deps)
|
|
6259
|
+
packageDependencies.push({ from: repo.alias, to: dep.alias, package_name: dep.package_name });
|
|
6260
|
+
const { dependencies: _dependencies, ...rest } = repo;
|
|
6261
|
+
return { ...rest, dependencies_on_workspace_repos: deps };
|
|
6262
|
+
});
|
|
6263
|
+
const routeContracts = workspaceRouteContracts(root, repos);
|
|
6264
|
+
if (repos.length && repos.every((repo) => !repo.indexed))
|
|
6265
|
+
warnings.push("Workspace repos were found, but none has .agent_memory yet. Run kage init or kage refresh in each repo you want searchable.");
|
|
6266
|
+
return {
|
|
6267
|
+
schema_version: 1,
|
|
6268
|
+
workspace_dir: root,
|
|
6269
|
+
generated_at: nowIso(),
|
|
6270
|
+
repos,
|
|
6271
|
+
package_dependencies: packageDependencies.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
|
|
6272
|
+
route_contracts: routeContracts,
|
|
6273
|
+
warnings,
|
|
6274
|
+
summary: `${repos.length} repo(s), ${repos.filter((repo) => repo.indexed).length} with Kage memory, ${packageDependencies.length} workspace package dependenc${packageDependencies.length === 1 ? "y" : "ies"}, ${routeContracts.length} route contract link(s).`,
|
|
6275
|
+
};
|
|
6276
|
+
}
|
|
6277
|
+
function kageWorkspaceRecall(projectDir, query, limit = 8) {
|
|
6278
|
+
const workspace = kageWorkspace(projectDir);
|
|
6279
|
+
const warnings = [...workspace.warnings];
|
|
6280
|
+
const hits = [];
|
|
6281
|
+
for (const repo of workspace.repos) {
|
|
6282
|
+
if (!repo.indexed)
|
|
6283
|
+
continue;
|
|
6284
|
+
const repoPath = repo.path === "." ? workspace.workspace_dir : (0, node_path_1.join)(workspace.workspace_dir, repo.path);
|
|
6285
|
+
let result;
|
|
6286
|
+
try {
|
|
6287
|
+
result = recall(repoPath, query, Math.max(1, Math.min(limit, 5)), false);
|
|
6288
|
+
}
|
|
6289
|
+
catch (error) {
|
|
6290
|
+
warnings.push(`Recall failed for ${repo.alias}: ${error instanceof Error ? error.message : String(error)}`);
|
|
6291
|
+
continue;
|
|
6292
|
+
}
|
|
6293
|
+
for (const hit of result.results) {
|
|
6294
|
+
hits.push({
|
|
6295
|
+
repo: repo.alias,
|
|
6296
|
+
repo_path: repo.path,
|
|
6297
|
+
title: hit.packet.title,
|
|
6298
|
+
type: hit.packet.type,
|
|
6299
|
+
score: hit.score,
|
|
6300
|
+
summary: hit.packet.summary,
|
|
6301
|
+
paths: hit.packet.paths,
|
|
6302
|
+
why_matched: hit.why_matched,
|
|
6303
|
+
});
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
hits.sort((a, b) => b.score - a.score || a.repo.localeCompare(b.repo) || a.title.localeCompare(b.title));
|
|
6307
|
+
const topHits = hits.slice(0, Math.max(1, limit));
|
|
6308
|
+
const contextLines = topHits.length
|
|
6309
|
+
? topHits.map((hit, index) => `${index + 1}. [${hit.repo}] ${hit.title} (${hit.type}, score ${hit.score.toFixed(2)})\n ${hit.summary}`).join("\n")
|
|
6310
|
+
: "No workspace memory matched.";
|
|
6311
|
+
return {
|
|
6312
|
+
schema_version: 1,
|
|
6313
|
+
workspace_dir: workspace.workspace_dir,
|
|
6314
|
+
query,
|
|
6315
|
+
generated_at: nowIso(),
|
|
6316
|
+
repos_searched: workspace.repos.filter((repo) => repo.indexed).length,
|
|
6317
|
+
hits: topHits,
|
|
6318
|
+
warnings,
|
|
6319
|
+
context_block: `# Kage Workspace Context\n\nQuery: ${query}\n\n${contextLines}`,
|
|
6320
|
+
};
|
|
6321
|
+
}
|
|
4745
6322
|
function queryGraph(projectDir, query, limit = 10, graph) {
|
|
4746
6323
|
graph = graph ?? readCurrentGraphs(projectDir)?.knowledgeGraph ?? buildKnowledgeGraph(projectDir);
|
|
4747
6324
|
const terms = tokenize(query);
|
|
@@ -5282,10 +6859,14 @@ function baselineDiscoveryFiles(projectDir, task) {
|
|
|
5282
6859
|
const absolute = (0, node_path_1.join)(projectDir, path);
|
|
5283
6860
|
if (!(0, node_fs_1.existsSync)(absolute))
|
|
5284
6861
|
return null;
|
|
5285
|
-
const stats = (
|
|
6862
|
+
const stats = safeStat(absolute);
|
|
6863
|
+
if (!stats)
|
|
6864
|
+
return null;
|
|
5286
6865
|
if (!stats.isFile() || stats.size > 240_000)
|
|
5287
6866
|
return null;
|
|
5288
|
-
const text = (
|
|
6867
|
+
const text = safeReadText(absolute);
|
|
6868
|
+
if (text === null)
|
|
6869
|
+
return null;
|
|
5289
6870
|
const score = scoreText(terms, `${path}\n${text.slice(0, 8000)}`, [path]);
|
|
5290
6871
|
const alwaysUseful = ["README.md", "AGENTS.md", "CLAUDE.md", "package.json"].includes(path);
|
|
5291
6872
|
if (score <= 0 && !alwaysUseful)
|
|
@@ -6421,7 +8002,7 @@ function proposeFromDiff(projectDir) {
|
|
|
6421
8002
|
const status = readGit(projectDir, ["status", "--porcelain", "-uall"]);
|
|
6422
8003
|
if (status === null)
|
|
6423
8004
|
return { ok: false, changedFiles: [], errors: ["Not a git repository or git is unavailable."] };
|
|
6424
|
-
const changedFiles = parsePorcelainStatus(status);
|
|
8005
|
+
const changedFiles = parsePorcelainStatus(projectDir, status);
|
|
6425
8006
|
if (changedFiles.length === 0)
|
|
6426
8007
|
return { ok: false, changedFiles: [], errors: ["No changed files found."] };
|
|
6427
8008
|
const stat = branchDiffStat(projectDir, changedFiles);
|
|
@@ -6470,7 +8051,7 @@ function buildBranchOverlay(projectDir) {
|
|
|
6470
8051
|
branch: gitBranch(projectDir),
|
|
6471
8052
|
head: gitHead(projectDir),
|
|
6472
8053
|
merge_base: gitMergeBase(projectDir),
|
|
6473
|
-
changed_files: parsePorcelainStatus(status),
|
|
8054
|
+
changed_files: parsePorcelainStatus(projectDir, status),
|
|
6474
8055
|
pending_packet_ids: loadPendingPackets(projectDir).map((packet) => packet.id).sort(),
|
|
6475
8056
|
generated_at: nowIso(),
|
|
6476
8057
|
};
|