@kage-core/kage-graph-mcp 1.1.25 → 1.1.27
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 +43 -2
- package/dist/cli.js +237 -0
- package/dist/daemon.js +19 -1
- package/dist/index.js +208 -0
- package/dist/kernel.js +1676 -12
- package/package.json +1 -1
- package/viewer/app.js +489 -2
- package/viewer/index.html +10 -2
- package/viewer/styles.css +174 -4
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;
|
|
@@ -99,6 +109,9 @@ exports.buildBranchOverlay = buildBranchOverlay;
|
|
|
99
109
|
exports.createReviewArtifact = createReviewArtifact;
|
|
100
110
|
exports.prSummarize = prSummarize;
|
|
101
111
|
exports.prCheck = prCheck;
|
|
112
|
+
exports.kageHookStatus = kageHookStatus;
|
|
113
|
+
exports.kageHookInstall = kageHookInstall;
|
|
114
|
+
exports.kageHookUninstall = kageHookUninstall;
|
|
102
115
|
exports.exportPublicBundle = exportPublicBundle;
|
|
103
116
|
exports.orgStatus = orgStatus;
|
|
104
117
|
exports.orgUploadPacket = orgUploadPacket;
|
|
@@ -1679,8 +1692,9 @@ function structuralFileCacheDir(projectDir) {
|
|
|
1679
1692
|
function structuralPackedFileCachePath(projectDir) {
|
|
1680
1693
|
return (0, node_path_1.join)(structuralIndexDir(projectDir), "file-cache.json");
|
|
1681
1694
|
}
|
|
1695
|
+
const STRUCTURAL_EXTRACTOR_VERSION = 2;
|
|
1682
1696
|
function structuralFileCachePath(projectDir, rel, hash) {
|
|
1683
|
-
return (0, node_path_1.join)(structuralFileCacheDir(projectDir),
|
|
1697
|
+
return (0, node_path_1.join)(structuralFileCacheDir(projectDir), `v${STRUCTURAL_EXTRACTOR_VERSION}-${slugify(rel)}-${hash}.json`);
|
|
1684
1698
|
}
|
|
1685
1699
|
function emptyStructuralIndexManifest(projectDir) {
|
|
1686
1700
|
return {
|
|
@@ -1984,7 +1998,7 @@ function expandCompactStructuralCachedFile(compact) {
|
|
|
1984
1998
|
}
|
|
1985
1999
|
const packedStructuralCache = new Map();
|
|
1986
2000
|
function structuralPackedCacheKey(rel, hash) {
|
|
1987
|
-
return
|
|
2001
|
+
return `v${STRUCTURAL_EXTRACTOR_VERSION}\0${rel}\0${hash}`;
|
|
1988
2002
|
}
|
|
1989
2003
|
function readPackedStructuralCache(projectDir) {
|
|
1990
2004
|
const path = structuralPackedFileCachePath(projectDir);
|
|
@@ -2395,8 +2409,12 @@ function resolveImportPath(projectDir, fromRelativePath, specifier, knownFiles)
|
|
|
2395
2409
|
if (!specifier.startsWith("."))
|
|
2396
2410
|
return null;
|
|
2397
2411
|
const base = (0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.join)(projectDir, fromRelativePath)), specifier);
|
|
2412
|
+
const sourceExtensionCandidates = /\.(?:mjs|cjs|js|jsx)$/.test(base)
|
|
2413
|
+
? [".ts", ".tsx", ".mts", ".cts"].map((extension) => base.replace(/\.(?:mjs|cjs|js|jsx)$/, extension))
|
|
2414
|
+
: [];
|
|
2398
2415
|
const candidates = [
|
|
2399
2416
|
base,
|
|
2417
|
+
...sourceExtensionCandidates,
|
|
2400
2418
|
...[...CODE_EXTENSIONS].map((extension) => `${base}${extension}`),
|
|
2401
2419
|
...[...CODE_EXTENSIONS].map((extension) => (0, node_path_1.join)(base, `index${extension}`)),
|
|
2402
2420
|
];
|
|
@@ -2508,6 +2526,12 @@ function extractSymbols(path, text) {
|
|
|
2508
2526
|
function extractGenericSymbols(path, text) {
|
|
2509
2527
|
const symbols = [];
|
|
2510
2528
|
const language = codeLanguage(path);
|
|
2529
|
+
const fileKind = codeFileKind(path);
|
|
2530
|
+
const genericKind = (name, fallback) => {
|
|
2531
|
+
if (fileKind === "test" && /^(test_|Test|it_|should_)/.test(name))
|
|
2532
|
+
return "test";
|
|
2533
|
+
return fallback;
|
|
2534
|
+
};
|
|
2511
2535
|
const addSymbol = (name, kind, line, signature, exported = true) => {
|
|
2512
2536
|
symbols.push({
|
|
2513
2537
|
id: symbolId(path, name, kind, line),
|
|
@@ -2532,7 +2556,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2532
2556
|
if (language === "python") {
|
|
2533
2557
|
match = trimmed.match(/^(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/);
|
|
2534
2558
|
if (match)
|
|
2535
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2559
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2536
2560
|
match = trimmed.match(/^class\s+([A-Za-z_][\w]*)\b/);
|
|
2537
2561
|
if (match)
|
|
2538
2562
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2540,7 +2564,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2540
2564
|
if (language === "go") {
|
|
2541
2565
|
match = trimmed.match(/^func\s+(?:\([^)]+\)\s*)?([A-Za-z_][\w]*)\s*\(/);
|
|
2542
2566
|
if (match)
|
|
2543
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2567
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2544
2568
|
match = trimmed.match(/^type\s+([A-Za-z_][\w]*)\s+(?:struct|interface)\b/);
|
|
2545
2569
|
if (match)
|
|
2546
2570
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2548,7 +2572,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2548
2572
|
if (language === "rust") {
|
|
2549
2573
|
match = trimmed.match(/^(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)\s*[<(]/);
|
|
2550
2574
|
if (match)
|
|
2551
|
-
return addSymbol(match[1], "function", line, trimmed, /^pub\b/.test(trimmed));
|
|
2575
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed, /^pub\b/.test(trimmed));
|
|
2552
2576
|
match = trimmed.match(/^(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z_][\w]*)\b/);
|
|
2553
2577
|
if (match)
|
|
2554
2578
|
return addSymbol(match[1], "class", line, trimmed, /^pub\b/.test(trimmed));
|
|
@@ -2556,7 +2580,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2556
2580
|
if (language === "ruby") {
|
|
2557
2581
|
match = trimmed.match(/^def\s+(?:self\.)?([A-Za-z_][\w!?=]*)/);
|
|
2558
2582
|
if (match)
|
|
2559
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2583
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2560
2584
|
match = trimmed.match(/^class\s+([A-Za-z_:][\w:]*)\b/);
|
|
2561
2585
|
if (match)
|
|
2562
2586
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2564,7 +2588,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2564
2588
|
if (language === "php") {
|
|
2565
2589
|
match = trimmed.match(/^(?:public|private|protected|static|\s)*function\s+([A-Za-z_][\w]*)\s*\(/);
|
|
2566
2590
|
if (match)
|
|
2567
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2591
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2568
2592
|
match = trimmed.match(/^(?:final\s+|abstract\s+)?class\s+([A-Za-z_][\w]*)\b/);
|
|
2569
2593
|
if (match)
|
|
2570
2594
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2572,7 +2596,7 @@ function extractGenericSymbols(path, text) {
|
|
|
2572
2596
|
if (["java", "kotlin", "csharp", "cpp", "swift"].includes(language)) {
|
|
2573
2597
|
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)?/);
|
|
2574
2598
|
if (match && !["if", "for", "while", "switch", "catch"].includes(match[1]))
|
|
2575
|
-
return addSymbol(match[1], "function", line, trimmed);
|
|
2599
|
+
return addSymbol(match[1], genericKind(match[1], "function"), line, trimmed);
|
|
2576
2600
|
match = trimmed.match(/^(?:public|private|protected|internal|static|final|open|abstract|sealed|\s)*(?:class|interface|struct|enum)\s+([A-Za-z_][\w]*)\b/);
|
|
2577
2601
|
if (match)
|
|
2578
2602
|
return addSymbol(match[1], "class", line, trimmed);
|
|
@@ -2765,6 +2789,47 @@ function extractCalls(path, text, symbols, symbolByName) {
|
|
|
2765
2789
|
visit(sourceFile);
|
|
2766
2790
|
return calls.sort((a, b) => a.line - b.line || a.to_symbol.localeCompare(b.to_symbol));
|
|
2767
2791
|
}
|
|
2792
|
+
const GENERIC_CALL_STOP_WORDS = new Set([
|
|
2793
|
+
"catch",
|
|
2794
|
+
"class",
|
|
2795
|
+
"def",
|
|
2796
|
+
"elif",
|
|
2797
|
+
"for",
|
|
2798
|
+
"func",
|
|
2799
|
+
"function",
|
|
2800
|
+
"if",
|
|
2801
|
+
"interface",
|
|
2802
|
+
"return",
|
|
2803
|
+
"switch",
|
|
2804
|
+
"while",
|
|
2805
|
+
]);
|
|
2806
|
+
function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
2807
|
+
const calls = [];
|
|
2808
|
+
const lines = text.split(/\r?\n/);
|
|
2809
|
+
for (let index = 0; index < lines.length && calls.length < MAX_CODE_GRAPH_CALLS_PER_FILE; index += 1) {
|
|
2810
|
+
const line = index + 1;
|
|
2811
|
+
const trimmed = lines[index].trim();
|
|
2812
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//"))
|
|
2813
|
+
continue;
|
|
2814
|
+
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))
|
|
2815
|
+
continue;
|
|
2816
|
+
for (const match of trimmed.matchAll(/\b([A-Za-z_][\w]*)\s*\(/g)) {
|
|
2817
|
+
const name = match[1];
|
|
2818
|
+
if (GENERIC_CALL_STOP_WORDS.has(name))
|
|
2819
|
+
continue;
|
|
2820
|
+
const targets = symbolByName.get(name)?.filter((target) => target.path !== path || target.line !== line);
|
|
2821
|
+
if (!targets?.length)
|
|
2822
|
+
continue;
|
|
2823
|
+
const caller = symbolAtLine(symbols, path, line);
|
|
2824
|
+
for (const target of targets.slice(0, 3)) {
|
|
2825
|
+
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
2826
|
+
break;
|
|
2827
|
+
calls.push({ from_symbol: caller?.id ?? null, to_symbol: target.id, path, line });
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
return calls.sort((a, b) => a.line - b.line || a.to_symbol.localeCompare(b.to_symbol));
|
|
2832
|
+
}
|
|
2768
2833
|
function extractRoutes(path, text, symbols) {
|
|
2769
2834
|
const routes = [];
|
|
2770
2835
|
const addRoute = (method, routePath, offset, framework, handler = null) => {
|
|
@@ -3337,7 +3402,7 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
3337
3402
|
const imports = structural.imports.slice();
|
|
3338
3403
|
const contents = new Map();
|
|
3339
3404
|
for (const file of structural.files) {
|
|
3340
|
-
if (!
|
|
3405
|
+
if (!CODE_EXTENSIONS.has(extensionOf(file.path)))
|
|
3341
3406
|
continue;
|
|
3342
3407
|
if (file.size_bytes > MAX_CODE_FILE_BYTES)
|
|
3343
3408
|
continue;
|
|
@@ -3384,13 +3449,14 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
3384
3449
|
const routes = [];
|
|
3385
3450
|
const tests = [];
|
|
3386
3451
|
for (const [rel, content] of contents) {
|
|
3387
|
-
if (!TS_AST_EXTENSIONS.has(extensionOf(rel)))
|
|
3388
|
-
continue;
|
|
3389
3452
|
if (calls.length >= MAX_CODE_GRAPH_CALLS)
|
|
3390
3453
|
break;
|
|
3391
3454
|
const fileSymbols = symbols.filter((symbol) => symbol.path === rel);
|
|
3392
3455
|
const fileImports = imports.filter((item) => item.from_path === rel);
|
|
3393
|
-
|
|
3456
|
+
const fileCalls = TS_AST_EXTENSIONS.has(extensionOf(rel))
|
|
3457
|
+
? extractCalls(rel, content, fileSymbols, symbolByName)
|
|
3458
|
+
: extractGenericCalls(rel, content, fileSymbols, symbolByName);
|
|
3459
|
+
calls.push(...fileCalls.slice(0, Math.max(0, MAX_CODE_GRAPH_CALLS - calls.length)));
|
|
3394
3460
|
routes.push(...extractRoutes(rel, content, fileSymbols));
|
|
3395
3461
|
tests.push(...extractTests(rel, content, fileSymbols, fileImports));
|
|
3396
3462
|
}
|
|
@@ -4808,6 +4874,1454 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
4808
4874
|
structural_edges: structuralEdges,
|
|
4809
4875
|
};
|
|
4810
4876
|
}
|
|
4877
|
+
function gitLines(projectDir, args) {
|
|
4878
|
+
return (readGit(projectDir, args) ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
4879
|
+
}
|
|
4880
|
+
function gitCommitRecords(projectDir, limit = 1000) {
|
|
4881
|
+
const raw = readGit(projectDir, ["log", `-${limit}`, "--format=__KAGE_COMMIT__%x1f%an <%ae>%x1f%s", "--name-only"]) ?? "";
|
|
4882
|
+
const records = [];
|
|
4883
|
+
let current = null;
|
|
4884
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
4885
|
+
const line = rawLine.trim();
|
|
4886
|
+
if (!line)
|
|
4887
|
+
continue;
|
|
4888
|
+
if (line.startsWith("__KAGE_COMMIT__")) {
|
|
4889
|
+
if (current)
|
|
4890
|
+
records.push(current);
|
|
4891
|
+
const [, author = "", subject = ""] = line.split("\x1f");
|
|
4892
|
+
current = { author: author.trim(), subject: subject.trim(), files: [] };
|
|
4893
|
+
continue;
|
|
4894
|
+
}
|
|
4895
|
+
if (current)
|
|
4896
|
+
current.files.push(line);
|
|
4897
|
+
}
|
|
4898
|
+
if (current)
|
|
4899
|
+
records.push(current);
|
|
4900
|
+
return records;
|
|
4901
|
+
}
|
|
4902
|
+
function commitCategory(subject) {
|
|
4903
|
+
const text = subject.toLowerCase();
|
|
4904
|
+
if (/^(fix|bug|hotfix|revert)(\b|\(|:)|\bfix(e[sd])?\b|\bbug\b/.test(text))
|
|
4905
|
+
return "fix";
|
|
4906
|
+
if (/^(feat|feature)(\b|\(|:)|\badd(ed|s)?\b|\bintroduce/.test(text))
|
|
4907
|
+
return "feat";
|
|
4908
|
+
if (/^(perf|performance)(\b|\(|:)|\boptimi[sz]e/.test(text))
|
|
4909
|
+
return "perf";
|
|
4910
|
+
if (/^(refactor|cleanup)(\b|\(|:)|\brename\b|\bmove\b/.test(text))
|
|
4911
|
+
return "refactor";
|
|
4912
|
+
if (/^(test|tests)(\b|\(|:)|\bspec\b/.test(text))
|
|
4913
|
+
return "test";
|
|
4914
|
+
if (/^(doc|docs)(\b|\(|:)|\breadme\b/.test(text))
|
|
4915
|
+
return "docs";
|
|
4916
|
+
if (/^(chore|build|ci|deps?)(\b|\(|:)|\bversion\b|\brelease\b|\bupgrade\b/.test(text))
|
|
4917
|
+
return "chore";
|
|
4918
|
+
return "other";
|
|
4919
|
+
}
|
|
4920
|
+
function gitCommitCountForPath(projectDir, path, since) {
|
|
4921
|
+
const args = ["log", "--format=%H"];
|
|
4922
|
+
if (since)
|
|
4923
|
+
args.push(`--since=${since}`);
|
|
4924
|
+
args.push("--", path);
|
|
4925
|
+
return gitLines(projectDir, args).length;
|
|
4926
|
+
}
|
|
4927
|
+
function gitPrimaryOwnerForPath(projectDir, path) {
|
|
4928
|
+
const authors = gitLines(projectDir, ["log", "--format=%an <%ae>", "--", path]);
|
|
4929
|
+
if (!authors.length)
|
|
4930
|
+
return { primary_owner: null, primary_owner_pct: null, contributor_count: 0 };
|
|
4931
|
+
const counts = new Map();
|
|
4932
|
+
for (const author of authors)
|
|
4933
|
+
counts.set(author, (counts.get(author) ?? 0) + 1);
|
|
4934
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
4935
|
+
return {
|
|
4936
|
+
primary_owner: ranked[0]?.[0] ?? null,
|
|
4937
|
+
primary_owner_pct: ranked[0] ? Number((ranked[0][1] / authors.length).toFixed(2)) : null,
|
|
4938
|
+
contributor_count: ranked.length,
|
|
4939
|
+
};
|
|
4940
|
+
}
|
|
4941
|
+
function gitAuthorCountsForPath(projectDir, path, since) {
|
|
4942
|
+
const args = ["log", "--format=%an <%ae>"];
|
|
4943
|
+
if (since)
|
|
4944
|
+
args.push(`--since=${since}`);
|
|
4945
|
+
args.push("--", path);
|
|
4946
|
+
const counts = new Map();
|
|
4947
|
+
for (const author of gitLines(projectDir, args))
|
|
4948
|
+
counts.set(author, (counts.get(author) ?? 0) + 1);
|
|
4949
|
+
return counts;
|
|
4950
|
+
}
|
|
4951
|
+
function gitCoChangePartnersForPath(projectDir, path, graphPaths) {
|
|
4952
|
+
const commits = gitLines(projectDir, ["log", "--format=%H", "-n", "80", "--", path]);
|
|
4953
|
+
const counts = new Map();
|
|
4954
|
+
for (const commit of commits) {
|
|
4955
|
+
const changed = gitLines(projectDir, ["show", "--name-only", "--format=", "--no-renames", commit])
|
|
4956
|
+
.filter((candidate) => candidate !== path && graphPaths.has(candidate));
|
|
4957
|
+
if (changed.length > 200)
|
|
4958
|
+
continue;
|
|
4959
|
+
for (const file of new Set(changed))
|
|
4960
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
4961
|
+
}
|
|
4962
|
+
return [...counts.entries()]
|
|
4963
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
4964
|
+
.slice(0, 5)
|
|
4965
|
+
.map(([file_path, count]) => ({ file_path, count }));
|
|
4966
|
+
}
|
|
4967
|
+
function gitFileSignal(projectDir, path, graphPaths) {
|
|
4968
|
+
const total = gitCommitCountForPath(projectDir, path);
|
|
4969
|
+
const owner = gitPrimaryOwnerForPath(projectDir, path);
|
|
4970
|
+
return {
|
|
4971
|
+
file_path: path,
|
|
4972
|
+
commit_count_total: total,
|
|
4973
|
+
commit_count_30d: gitCommitCountForPath(projectDir, path, "30 days ago"),
|
|
4974
|
+
commit_count_90d: gitCommitCountForPath(projectDir, path, "90 days ago"),
|
|
4975
|
+
last_commit_at: gitLines(projectDir, ["log", "-1", "--format=%cI", "--", path])[0] ?? null,
|
|
4976
|
+
primary_owner: owner.primary_owner,
|
|
4977
|
+
primary_owner_pct: owner.primary_owner_pct,
|
|
4978
|
+
contributor_count: owner.contributor_count,
|
|
4979
|
+
co_change_partners: gitCoChangePartnersForPath(projectDir, path, graphPaths),
|
|
4980
|
+
};
|
|
4981
|
+
}
|
|
4982
|
+
function gitChangedFiles(projectDir) {
|
|
4983
|
+
return gitLines(projectDir, ["status", "--porcelain", "-uall"])
|
|
4984
|
+
.map((line) => line.slice(3).trim().split(" -> ").at(-1) ?? "")
|
|
4985
|
+
.filter(Boolean)
|
|
4986
|
+
.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
|
|
4987
|
+
.filter((path) => !isNoisePath(path));
|
|
4988
|
+
}
|
|
4989
|
+
function globalGitHotspots(projectDir, graph) {
|
|
4990
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
4991
|
+
const counts = new Map();
|
|
4992
|
+
for (const line of gitLines(projectDir, ["log", "--since=90 days ago", "--name-only", "--format=__KAGE_COMMIT__", "-n", "1000"])) {
|
|
4993
|
+
if (line === "__KAGE_COMMIT__" || !graphPaths.has(line))
|
|
4994
|
+
continue;
|
|
4995
|
+
counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
4996
|
+
}
|
|
4997
|
+
return [...counts.entries()]
|
|
4998
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
4999
|
+
.slice(0, 5)
|
|
5000
|
+
.map(([file_path, commit_count_90d]) => {
|
|
5001
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file_path);
|
|
5002
|
+
return {
|
|
5003
|
+
file_path,
|
|
5004
|
+
commit_count_90d,
|
|
5005
|
+
hotspot_score: Number(Math.min(1, commit_count_90d / 20).toFixed(2)),
|
|
5006
|
+
primary_owner: owner.primary_owner,
|
|
5007
|
+
};
|
|
5008
|
+
});
|
|
5009
|
+
}
|
|
5010
|
+
function globalOwnershipSilos(projectDir, graph) {
|
|
5011
|
+
const candidates = [];
|
|
5012
|
+
for (const file of graph.files) {
|
|
5013
|
+
if (file.kind !== "source")
|
|
5014
|
+
continue;
|
|
5015
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file.path);
|
|
5016
|
+
const commitCount = gitCommitCountForPath(projectDir, file.path);
|
|
5017
|
+
if (!owner.primary_owner || owner.primary_owner_pct == null)
|
|
5018
|
+
continue;
|
|
5019
|
+
if (owner.primary_owner_pct < 0.8 || commitCount < 5)
|
|
5020
|
+
continue;
|
|
5021
|
+
candidates.push({
|
|
5022
|
+
file_path: file.path,
|
|
5023
|
+
primary_owner: owner.primary_owner,
|
|
5024
|
+
primary_owner_pct: owner.primary_owner_pct,
|
|
5025
|
+
commit_count_total: commitCount,
|
|
5026
|
+
});
|
|
5027
|
+
}
|
|
5028
|
+
return candidates
|
|
5029
|
+
.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))
|
|
5030
|
+
.slice(0, 10);
|
|
5031
|
+
}
|
|
5032
|
+
function codeDependents(graph) {
|
|
5033
|
+
const dependents = new Map();
|
|
5034
|
+
for (const edge of graph.imports) {
|
|
5035
|
+
if (!edge.to_path)
|
|
5036
|
+
continue;
|
|
5037
|
+
const list = dependents.get(edge.to_path) ?? new Set();
|
|
5038
|
+
list.add(edge.from_path);
|
|
5039
|
+
dependents.set(edge.to_path, list);
|
|
5040
|
+
}
|
|
5041
|
+
return dependents;
|
|
5042
|
+
}
|
|
5043
|
+
function impactSurface(target, dependents, graph) {
|
|
5044
|
+
const visited = new Set();
|
|
5045
|
+
let frontier = new Set([target]);
|
|
5046
|
+
for (let depth = 0; depth < 2; depth++) {
|
|
5047
|
+
const next = new Set();
|
|
5048
|
+
for (const node of frontier) {
|
|
5049
|
+
for (const dependent of dependents.get(node) ?? []) {
|
|
5050
|
+
if (dependent === target || visited.has(dependent))
|
|
5051
|
+
continue;
|
|
5052
|
+
visited.add(dependent);
|
|
5053
|
+
next.add(dependent);
|
|
5054
|
+
}
|
|
5055
|
+
}
|
|
5056
|
+
frontier = next;
|
|
5057
|
+
}
|
|
5058
|
+
const dependentScore = new Map();
|
|
5059
|
+
for (const [path, incoming] of dependents.entries())
|
|
5060
|
+
dependentScore.set(path, incoming.size);
|
|
5061
|
+
return [...visited]
|
|
5062
|
+
.sort((a, b) => (dependentScore.get(b) ?? 0) - (dependentScore.get(a) ?? 0) || a.localeCompare(b))
|
|
5063
|
+
.slice(0, 5);
|
|
5064
|
+
}
|
|
5065
|
+
function hasTestCoverage(target, graph) {
|
|
5066
|
+
const file = graph.files.find((candidate) => candidate.path === target);
|
|
5067
|
+
if (file?.kind === "test")
|
|
5068
|
+
return true;
|
|
5069
|
+
if (graph.tests.some((test) => test.covers_path === target))
|
|
5070
|
+
return true;
|
|
5071
|
+
const base = (0, node_path_1.basename)(target).replace(/\.[^.]+$/, "").toLowerCase();
|
|
5072
|
+
return graph.files.some((candidate) => {
|
|
5073
|
+
if (candidate.kind !== "test")
|
|
5074
|
+
return false;
|
|
5075
|
+
const lower = candidate.path.toLowerCase();
|
|
5076
|
+
return lower.includes(`test_${base}`) || lower.includes(`${base}_test`) || lower.includes(`${base}.spec`) || lower.includes(`${base}.test`);
|
|
5077
|
+
});
|
|
5078
|
+
}
|
|
5079
|
+
function classifyRisk(git, dependentsCount, testGap) {
|
|
5080
|
+
if (!git.commit_count_total && !dependentsCount)
|
|
5081
|
+
return "unknown";
|
|
5082
|
+
if (git.commit_count_30d >= 5 || git.commit_count_90d >= 15)
|
|
5083
|
+
return "churn-heavy";
|
|
5084
|
+
if (dependentsCount >= 5)
|
|
5085
|
+
return "high-coupling";
|
|
5086
|
+
if ((git.primary_owner_pct ?? 0) >= 0.8 && git.commit_count_total >= 10)
|
|
5087
|
+
return "single-owner";
|
|
5088
|
+
if (testGap && (dependentsCount > 0 || git.commit_count_90d > 0))
|
|
5089
|
+
return "test-gap";
|
|
5090
|
+
return "stable";
|
|
5091
|
+
}
|
|
5092
|
+
function kageRisk(projectDir, targets = [], changedFiles = []) {
|
|
5093
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5094
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5095
|
+
const dependents = codeDependents(graph);
|
|
5096
|
+
const resolvedTargets = unique((targets.length ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
|
|
5097
|
+
.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
|
|
5098
|
+
.filter((path) => path && !isNoisePath(path)));
|
|
5099
|
+
const warnings = [];
|
|
5100
|
+
if (!gitHead(projectDir))
|
|
5101
|
+
warnings.push("Git history is unavailable, so churn, ownership, and co-change signals may be empty.");
|
|
5102
|
+
if (!resolvedTargets.length)
|
|
5103
|
+
warnings.push("No targets supplied and no changed files detected.");
|
|
5104
|
+
const changeSet = new Set(resolvedTargets);
|
|
5105
|
+
const targetMap = {};
|
|
5106
|
+
for (const target of resolvedTargets) {
|
|
5107
|
+
const directDependents = [...(dependents.get(target) ?? [])].sort();
|
|
5108
|
+
const git = gitFileSignal(projectDir, target, graphPaths);
|
|
5109
|
+
const testGap = !hasTestCoverage(target, graph);
|
|
5110
|
+
const coChangeWarnings = git.co_change_partners.map((partner) => ({
|
|
5111
|
+
file_path: partner.file_path,
|
|
5112
|
+
count: partner.count,
|
|
5113
|
+
included_in_change: changeSet.has(partner.file_path),
|
|
5114
|
+
}));
|
|
5115
|
+
const missingCoChanges = coChangeWarnings.filter((partner) => !partner.included_in_change);
|
|
5116
|
+
const hotspotScore = Number(Math.min(1, (git.commit_count_30d / 6) * 0.5 + (git.commit_count_90d / 20) * 0.5).toFixed(2));
|
|
5117
|
+
const riskType = classifyRisk(git, directDependents.length, testGap);
|
|
5118
|
+
const owner = git.primary_owner ?? "unknown";
|
|
5119
|
+
targetMap[target] = {
|
|
5120
|
+
target,
|
|
5121
|
+
exists_in_code_graph: graphPaths.has(target),
|
|
5122
|
+
hotspot_score: hotspotScore,
|
|
5123
|
+
risk_type: riskType,
|
|
5124
|
+
dependents_count: directDependents.length,
|
|
5125
|
+
dependents: directDependents.slice(0, 10),
|
|
5126
|
+
impact_surface: impactSurface(target, dependents, graph),
|
|
5127
|
+
test_gap: testGap,
|
|
5128
|
+
co_change_warnings: coChangeWarnings,
|
|
5129
|
+
git,
|
|
5130
|
+
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` : ""}.`,
|
|
5131
|
+
};
|
|
5132
|
+
}
|
|
5133
|
+
return {
|
|
5134
|
+
schema_version: 1,
|
|
5135
|
+
project_dir: projectDir,
|
|
5136
|
+
generated_at: nowIso(),
|
|
5137
|
+
targets: targetMap,
|
|
5138
|
+
global_hotspots: globalGitHotspots(projectDir, graph),
|
|
5139
|
+
ownership_silos: globalOwnershipSilos(projectDir, graph),
|
|
5140
|
+
changed_files: changedFiles.length ? changedFiles : undefined,
|
|
5141
|
+
warnings,
|
|
5142
|
+
};
|
|
5143
|
+
}
|
|
5144
|
+
function resolveCodeGraphPath(projectDir, graph, input, warnings, label) {
|
|
5145
|
+
const normalized = (gitPathToProjectRelative(projectDir, input) ?? input).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
5146
|
+
const paths = graph.files.map((file) => file.path);
|
|
5147
|
+
if (paths.includes(normalized))
|
|
5148
|
+
return normalized;
|
|
5149
|
+
const suffixMatches = paths.filter((path) => path.endsWith(`/${normalized}`) || path === normalized);
|
|
5150
|
+
if (suffixMatches.length === 1)
|
|
5151
|
+
return suffixMatches[0];
|
|
5152
|
+
if (suffixMatches.length > 1) {
|
|
5153
|
+
warnings.push(`${label} "${input}" is ambiguous: ${suffixMatches.slice(0, 5).join(", ")}`);
|
|
5154
|
+
return null;
|
|
5155
|
+
}
|
|
5156
|
+
const nameMatches = paths.filter((path) => (0, node_path_1.basename)(path) === normalized || (0, node_path_1.basename)(path) === (0, node_path_1.basename)(normalized));
|
|
5157
|
+
if (nameMatches.length === 1)
|
|
5158
|
+
return nameMatches[0];
|
|
5159
|
+
if (nameMatches.length > 1)
|
|
5160
|
+
warnings.push(`${label} "${input}" matched multiple files by name: ${nameMatches.slice(0, 5).join(", ")}`);
|
|
5161
|
+
else
|
|
5162
|
+
warnings.push(`${label} "${input}" was not found in the code graph.`);
|
|
5163
|
+
return null;
|
|
5164
|
+
}
|
|
5165
|
+
function importEdgeKey(from, to) {
|
|
5166
|
+
return `${from}\u0000${to}`;
|
|
5167
|
+
}
|
|
5168
|
+
function dependencyAdjacency(graph, mode) {
|
|
5169
|
+
const adjacency = new Map();
|
|
5170
|
+
const edges = new Map();
|
|
5171
|
+
const add = (from, to, edge) => {
|
|
5172
|
+
const next = adjacency.get(from) ?? new Set();
|
|
5173
|
+
next.add(to);
|
|
5174
|
+
adjacency.set(from, next);
|
|
5175
|
+
if (!edges.has(importEdgeKey(from, to)))
|
|
5176
|
+
edges.set(importEdgeKey(from, to), edge);
|
|
5177
|
+
};
|
|
5178
|
+
for (const edge of graph.imports) {
|
|
5179
|
+
if (!edge.to_path)
|
|
5180
|
+
continue;
|
|
5181
|
+
if (mode === "forward" || mode === "undirected")
|
|
5182
|
+
add(edge.from_path, edge.to_path, edge);
|
|
5183
|
+
if (mode === "reverse" || mode === "undirected")
|
|
5184
|
+
add(edge.to_path, edge.from_path, edge);
|
|
5185
|
+
}
|
|
5186
|
+
return { adjacency, edges };
|
|
5187
|
+
}
|
|
5188
|
+
function shortestDependencyPath(graph, from, to, mode) {
|
|
5189
|
+
if (from === to)
|
|
5190
|
+
return { path: [from], edges: [] };
|
|
5191
|
+
const { adjacency, edges } = dependencyAdjacency(graph, mode);
|
|
5192
|
+
const queue = [from];
|
|
5193
|
+
const previous = new Map([[from, null]]);
|
|
5194
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
5195
|
+
const current = queue[index];
|
|
5196
|
+
for (const next of [...(adjacency.get(current) ?? [])].sort()) {
|
|
5197
|
+
if (previous.has(next))
|
|
5198
|
+
continue;
|
|
5199
|
+
previous.set(next, current);
|
|
5200
|
+
if (next === to) {
|
|
5201
|
+
const path = [to];
|
|
5202
|
+
let cursor = current;
|
|
5203
|
+
while (cursor) {
|
|
5204
|
+
path.push(cursor);
|
|
5205
|
+
cursor = previous.get(cursor) ?? null;
|
|
5206
|
+
}
|
|
5207
|
+
path.reverse();
|
|
5208
|
+
const pathEdges = [];
|
|
5209
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
5210
|
+
const a = path[i];
|
|
5211
|
+
const b = path[i + 1];
|
|
5212
|
+
const edge = edges.get(importEdgeKey(a, b));
|
|
5213
|
+
const reverseEdge = edges.get(importEdgeKey(b, a));
|
|
5214
|
+
const source = edge ?? reverseEdge;
|
|
5215
|
+
if (!source)
|
|
5216
|
+
continue;
|
|
5217
|
+
pathEdges.push({
|
|
5218
|
+
from_path: source.from_path,
|
|
5219
|
+
to_path: source.to_path ?? b,
|
|
5220
|
+
kind: source.kind,
|
|
5221
|
+
specifier: source.specifier,
|
|
5222
|
+
line: source.line,
|
|
5223
|
+
direction: source.from_path === a ? "forward" : "reverse",
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
5226
|
+
return { path, edges: pathEdges };
|
|
5227
|
+
}
|
|
5228
|
+
queue.push(next);
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
return null;
|
|
5232
|
+
}
|
|
5233
|
+
function kageDependencyPath(projectDir, from, to) {
|
|
5234
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5235
|
+
const warnings = [];
|
|
5236
|
+
const resolvedFrom = resolveCodeGraphPath(projectDir, graph, from, warnings, "Source");
|
|
5237
|
+
const resolvedTo = resolveCodeGraphPath(projectDir, graph, to, warnings, "Target");
|
|
5238
|
+
if (!resolvedFrom || !resolvedTo) {
|
|
5239
|
+
return {
|
|
5240
|
+
schema_version: 1,
|
|
5241
|
+
project_dir: projectDir,
|
|
5242
|
+
generated_at: nowIso(),
|
|
5243
|
+
from,
|
|
5244
|
+
to,
|
|
5245
|
+
resolved_from: resolvedFrom,
|
|
5246
|
+
resolved_to: resolvedTo,
|
|
5247
|
+
relation: "none",
|
|
5248
|
+
path: [],
|
|
5249
|
+
edges: [],
|
|
5250
|
+
distance: null,
|
|
5251
|
+
summary: "No dependency path could be computed because one or both targets were not resolved.",
|
|
5252
|
+
warnings,
|
|
5253
|
+
};
|
|
5254
|
+
}
|
|
5255
|
+
const forward = shortestDependencyPath(graph, resolvedFrom, resolvedTo, "forward");
|
|
5256
|
+
if (forward) {
|
|
5257
|
+
return {
|
|
5258
|
+
schema_version: 1,
|
|
5259
|
+
project_dir: projectDir,
|
|
5260
|
+
generated_at: nowIso(),
|
|
5261
|
+
from,
|
|
5262
|
+
to,
|
|
5263
|
+
resolved_from: resolvedFrom,
|
|
5264
|
+
resolved_to: resolvedTo,
|
|
5265
|
+
relation: "source_depends_on_target",
|
|
5266
|
+
path: forward.path,
|
|
5267
|
+
edges: forward.edges,
|
|
5268
|
+
distance: Math.max(0, forward.path.length - 1),
|
|
5269
|
+
summary: `${resolvedFrom} depends on ${resolvedTo} through ${Math.max(0, forward.path.length - 1)} import edge(s).`,
|
|
5270
|
+
warnings,
|
|
5271
|
+
};
|
|
5272
|
+
}
|
|
5273
|
+
const reverse = shortestDependencyPath(graph, resolvedTo, resolvedFrom, "forward");
|
|
5274
|
+
if (reverse) {
|
|
5275
|
+
return {
|
|
5276
|
+
schema_version: 1,
|
|
5277
|
+
project_dir: projectDir,
|
|
5278
|
+
generated_at: nowIso(),
|
|
5279
|
+
from,
|
|
5280
|
+
to,
|
|
5281
|
+
resolved_from: resolvedFrom,
|
|
5282
|
+
resolved_to: resolvedTo,
|
|
5283
|
+
relation: "target_depends_on_source",
|
|
5284
|
+
path: reverse.path.slice().reverse(),
|
|
5285
|
+
edges: reverse.edges.slice().reverse(),
|
|
5286
|
+
distance: Math.max(0, reverse.path.length - 1),
|
|
5287
|
+
summary: `${resolvedTo} depends on ${resolvedFrom}; changing ${resolvedFrom} may affect ${resolvedTo}.`,
|
|
5288
|
+
warnings,
|
|
5289
|
+
};
|
|
5290
|
+
}
|
|
5291
|
+
const undirected = shortestDependencyPath(graph, resolvedFrom, resolvedTo, "undirected");
|
|
5292
|
+
if (undirected) {
|
|
5293
|
+
return {
|
|
5294
|
+
schema_version: 1,
|
|
5295
|
+
project_dir: projectDir,
|
|
5296
|
+
generated_at: nowIso(),
|
|
5297
|
+
from,
|
|
5298
|
+
to,
|
|
5299
|
+
resolved_from: resolvedFrom,
|
|
5300
|
+
resolved_to: resolvedTo,
|
|
5301
|
+
relation: "connected_undirected",
|
|
5302
|
+
path: undirected.path,
|
|
5303
|
+
edges: undirected.edges,
|
|
5304
|
+
distance: Math.max(0, undirected.path.length - 1),
|
|
5305
|
+
summary: `${resolvedFrom} and ${resolvedTo} are connected in the import graph, but not by a direct dependency direction from source to target.`,
|
|
5306
|
+
warnings,
|
|
5307
|
+
};
|
|
5308
|
+
}
|
|
5309
|
+
return {
|
|
5310
|
+
schema_version: 1,
|
|
5311
|
+
project_dir: projectDir,
|
|
5312
|
+
generated_at: nowIso(),
|
|
5313
|
+
from,
|
|
5314
|
+
to,
|
|
5315
|
+
resolved_from: resolvedFrom,
|
|
5316
|
+
resolved_to: resolvedTo,
|
|
5317
|
+
relation: "none",
|
|
5318
|
+
path: [],
|
|
5319
|
+
edges: [],
|
|
5320
|
+
distance: null,
|
|
5321
|
+
summary: `${resolvedFrom} and ${resolvedTo} are not connected in the current code graph.`,
|
|
5322
|
+
warnings,
|
|
5323
|
+
};
|
|
5324
|
+
}
|
|
5325
|
+
function isEntrypointLike(path) {
|
|
5326
|
+
const name = (0, node_path_1.basename)(path).replace(/\.[^.]+$/, "").toLowerCase();
|
|
5327
|
+
if (["index", "main", "server", "app", "cli", "bin", "daemon", "worker", "setup", "config"].includes(name))
|
|
5328
|
+
return true;
|
|
5329
|
+
return /(^|\/)(bin|scripts|commands|pages|app|routes)\//.test(path);
|
|
5330
|
+
}
|
|
5331
|
+
function cleanupConfidence(score) {
|
|
5332
|
+
if (score >= 0.8)
|
|
5333
|
+
return "high";
|
|
5334
|
+
if (score >= 0.55)
|
|
5335
|
+
return "medium";
|
|
5336
|
+
return "low";
|
|
5337
|
+
}
|
|
5338
|
+
function runtimeReferenceNeedles(path) {
|
|
5339
|
+
const withoutExtension = path.replace(/\.[^.]+$/, "");
|
|
5340
|
+
const compiledJs = /\.(?:ts|tsx|mts|cts)$/.test(path) ? path.replace(/\.(?:ts|tsx|mts|cts)$/, ".js") : path;
|
|
5341
|
+
return unique([
|
|
5342
|
+
path,
|
|
5343
|
+
compiledJs,
|
|
5344
|
+
(0, node_path_1.basename)(path),
|
|
5345
|
+
(0, node_path_1.basename)(compiledJs),
|
|
5346
|
+
(0, node_path_1.basename)(withoutExtension),
|
|
5347
|
+
]).filter((item) => item.length >= 4);
|
|
5348
|
+
}
|
|
5349
|
+
function hasRuntimePathReference(projectDir, graph, target) {
|
|
5350
|
+
const needles = runtimeReferenceNeedles(target);
|
|
5351
|
+
for (const file of graph.files) {
|
|
5352
|
+
if (file.path === target)
|
|
5353
|
+
continue;
|
|
5354
|
+
if (!["source", "config", "manifest"].includes(file.kind))
|
|
5355
|
+
continue;
|
|
5356
|
+
if (file.size_bytes > MAX_CODE_FILE_BYTES)
|
|
5357
|
+
continue;
|
|
5358
|
+
const absolutePath = (0, node_path_1.join)(projectDir, file.path);
|
|
5359
|
+
if (!(0, node_fs_1.existsSync)(absolutePath))
|
|
5360
|
+
continue;
|
|
5361
|
+
const text = (0, node_fs_1.readFileSync)(absolutePath, "utf8");
|
|
5362
|
+
if (needles.some((needle) => text.includes(needle)))
|
|
5363
|
+
return true;
|
|
5364
|
+
}
|
|
5365
|
+
return false;
|
|
5366
|
+
}
|
|
5367
|
+
function kageCleanupCandidates(projectDir) {
|
|
5368
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5369
|
+
const fileByPath = new Map(graph.files.map((file) => [file.path, file]));
|
|
5370
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5371
|
+
const inbound = new Map();
|
|
5372
|
+
const outbound = new Map();
|
|
5373
|
+
for (const edge of graph.imports) {
|
|
5374
|
+
if (!edge.to_path)
|
|
5375
|
+
continue;
|
|
5376
|
+
const inList = inbound.get(edge.to_path) ?? [];
|
|
5377
|
+
inList.push(edge);
|
|
5378
|
+
inbound.set(edge.to_path, inList);
|
|
5379
|
+
const outList = outbound.get(edge.from_path) ?? [];
|
|
5380
|
+
outList.push(edge);
|
|
5381
|
+
outbound.set(edge.from_path, outList);
|
|
5382
|
+
}
|
|
5383
|
+
const routeFiles = new Set(graph.routes.map((route) => route.file_path));
|
|
5384
|
+
const skippedEntryPoints = [];
|
|
5385
|
+
const warnings = [];
|
|
5386
|
+
const hasGit = Boolean(gitHead(projectDir));
|
|
5387
|
+
if (!hasGit)
|
|
5388
|
+
warnings.push("Git history is unavailable, so cleanup confidence does not use recency.");
|
|
5389
|
+
const candidates = [];
|
|
5390
|
+
const skippedRuntimeReferences = [];
|
|
5391
|
+
for (const file of graph.files) {
|
|
5392
|
+
if (file.kind !== "source")
|
|
5393
|
+
continue;
|
|
5394
|
+
if (isEntrypointLike(file.path) || routeFiles.has(file.path)) {
|
|
5395
|
+
skippedEntryPoints.push(file.path);
|
|
5396
|
+
continue;
|
|
5397
|
+
}
|
|
5398
|
+
const inboundEdges = inbound.get(file.path) ?? [];
|
|
5399
|
+
const sourceInbound = inboundEdges.filter((edge) => fileByPath.get(edge.from_path)?.kind === "source");
|
|
5400
|
+
if (inboundEdges.length > 0 || sourceInbound.length > 0)
|
|
5401
|
+
continue;
|
|
5402
|
+
if (hasRuntimePathReference(projectDir, graph, file.path)) {
|
|
5403
|
+
skippedRuntimeReferences.push(file.path);
|
|
5404
|
+
continue;
|
|
5405
|
+
}
|
|
5406
|
+
const coveredByTests = hasTestCoverage(file.path, graph);
|
|
5407
|
+
const git = hasGit ? gitFileSignal(projectDir, file.path, graphPaths) : null;
|
|
5408
|
+
const reasons = [
|
|
5409
|
+
"no inbound imports in the current code graph",
|
|
5410
|
+
"not recognized as an entrypoint or route file",
|
|
5411
|
+
];
|
|
5412
|
+
let score = 0.55;
|
|
5413
|
+
if (!coveredByTests) {
|
|
5414
|
+
score += 0.15;
|
|
5415
|
+
reasons.push("no direct test coverage signal");
|
|
5416
|
+
}
|
|
5417
|
+
else {
|
|
5418
|
+
reasons.push("has a test coverage signal, verify before cleanup");
|
|
5419
|
+
}
|
|
5420
|
+
if (git && git.commit_count_90d === 0) {
|
|
5421
|
+
score += 0.15;
|
|
5422
|
+
reasons.push("no commits in the last 90 days");
|
|
5423
|
+
}
|
|
5424
|
+
else if (git && git.commit_count_90d > 0) {
|
|
5425
|
+
score -= 0.1;
|
|
5426
|
+
reasons.push(`${git.commit_count_90d} commit(s) in the last 90 days`);
|
|
5427
|
+
}
|
|
5428
|
+
if (file.line_count <= 20)
|
|
5429
|
+
score += 0.05;
|
|
5430
|
+
score = Number(Math.max(0, Math.min(1, score)).toFixed(2));
|
|
5431
|
+
candidates.push({
|
|
5432
|
+
path: file.path,
|
|
5433
|
+
kind: "unreferenced_file",
|
|
5434
|
+
confidence: cleanupConfidence(score),
|
|
5435
|
+
score,
|
|
5436
|
+
reasons,
|
|
5437
|
+
inbound_imports: inboundEdges.length,
|
|
5438
|
+
source_inbound_imports: sourceInbound.length,
|
|
5439
|
+
outbound_imports: (outbound.get(file.path) ?? []).length,
|
|
5440
|
+
covered_by_tests: coveredByTests,
|
|
5441
|
+
last_commit_at: git?.last_commit_at ?? null,
|
|
5442
|
+
});
|
|
5443
|
+
}
|
|
5444
|
+
candidates.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
5445
|
+
return {
|
|
5446
|
+
schema_version: 1,
|
|
5447
|
+
project_dir: projectDir,
|
|
5448
|
+
generated_at: nowIso(),
|
|
5449
|
+
candidates,
|
|
5450
|
+
skipped_entrypoints: skippedEntryPoints.sort(),
|
|
5451
|
+
skipped_runtime_references: skippedRuntimeReferences.sort(),
|
|
5452
|
+
warnings,
|
|
5453
|
+
summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
|
|
5454
|
+
};
|
|
5455
|
+
}
|
|
5456
|
+
function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
|
|
5457
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5458
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5459
|
+
const resolvedTargets = unique((targets.length ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
|
|
5460
|
+
.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
|
|
5461
|
+
.filter((path) => path && !isNoisePath(path)));
|
|
5462
|
+
const warnings = [];
|
|
5463
|
+
if (!gitHead(projectDir))
|
|
5464
|
+
warnings.push("Git history is unavailable, so reviewer suggestions cannot be computed.");
|
|
5465
|
+
if (!resolvedTargets.length)
|
|
5466
|
+
warnings.push("No targets supplied and no changed files detected.");
|
|
5467
|
+
const scores = new Map();
|
|
5468
|
+
const ensure = (reviewer) => {
|
|
5469
|
+
const existing = scores.get(reviewer);
|
|
5470
|
+
if (existing)
|
|
5471
|
+
return existing;
|
|
5472
|
+
const created = {
|
|
5473
|
+
reviewer,
|
|
5474
|
+
score: 0,
|
|
5475
|
+
reasons: [],
|
|
5476
|
+
authored_targets: [],
|
|
5477
|
+
cochange_targets: [],
|
|
5478
|
+
commit_count_total: 0,
|
|
5479
|
+
commit_count_90d: 0,
|
|
5480
|
+
};
|
|
5481
|
+
scores.set(reviewer, created);
|
|
5482
|
+
return created;
|
|
5483
|
+
};
|
|
5484
|
+
for (const target of resolvedTargets) {
|
|
5485
|
+
const allAuthors = gitAuthorCountsForPath(projectDir, target);
|
|
5486
|
+
const recentAuthors = gitAuthorCountsForPath(projectDir, target, "90 days ago");
|
|
5487
|
+
for (const [author, count] of allAuthors.entries()) {
|
|
5488
|
+
const item = ensure(author);
|
|
5489
|
+
item.score += Math.min(30, count * 4);
|
|
5490
|
+
item.commit_count_total += count;
|
|
5491
|
+
if (!item.authored_targets.includes(target))
|
|
5492
|
+
item.authored_targets.push(target);
|
|
5493
|
+
item.reasons.push(`${count} historical commit(s) on ${target}`);
|
|
5494
|
+
}
|
|
5495
|
+
for (const [author, count] of recentAuthors.entries()) {
|
|
5496
|
+
const item = ensure(author);
|
|
5497
|
+
item.score += Math.min(20, count * 5);
|
|
5498
|
+
item.commit_count_90d += count;
|
|
5499
|
+
item.reasons.push(`${count} recent commit(s) on ${target}`);
|
|
5500
|
+
}
|
|
5501
|
+
for (const partner of gitCoChangePartnersForPath(projectDir, target, graphPaths)) {
|
|
5502
|
+
const owner = gitPrimaryOwnerForPath(projectDir, partner.file_path).primary_owner;
|
|
5503
|
+
if (!owner)
|
|
5504
|
+
continue;
|
|
5505
|
+
const item = ensure(owner);
|
|
5506
|
+
item.score += Math.min(12, partner.count * 3);
|
|
5507
|
+
if (!item.cochange_targets.includes(partner.file_path))
|
|
5508
|
+
item.cochange_targets.push(partner.file_path);
|
|
5509
|
+
item.reasons.push(`${partner.file_path} changed with ${target} ${partner.count} time(s)`);
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
const suggestions = [...scores.values()]
|
|
5513
|
+
.map((item) => ({
|
|
5514
|
+
...item,
|
|
5515
|
+
score: Number(Math.min(100, item.score).toFixed(2)),
|
|
5516
|
+
reasons: unique(item.reasons).slice(0, 8),
|
|
5517
|
+
authored_targets: item.authored_targets.sort(),
|
|
5518
|
+
cochange_targets: item.cochange_targets.sort(),
|
|
5519
|
+
}))
|
|
5520
|
+
.sort((a, b) => b.score - a.score || a.reviewer.localeCompare(b.reviewer))
|
|
5521
|
+
.slice(0, 5);
|
|
5522
|
+
return {
|
|
5523
|
+
schema_version: 1,
|
|
5524
|
+
project_dir: projectDir,
|
|
5525
|
+
generated_at: nowIso(),
|
|
5526
|
+
targets: resolvedTargets,
|
|
5527
|
+
suggestions,
|
|
5528
|
+
warnings,
|
|
5529
|
+
summary: suggestions.length
|
|
5530
|
+
? `Suggested ${suggestions.length} reviewer(s) for ${resolvedTargets.length} target file(s).`
|
|
5531
|
+
: `No reviewer suggestions for ${resolvedTargets.length} target file(s).`,
|
|
5532
|
+
};
|
|
5533
|
+
}
|
|
5534
|
+
function kageContributors(projectDir) {
|
|
5535
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5536
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5537
|
+
const warnings = [];
|
|
5538
|
+
if (!gitHead(projectDir))
|
|
5539
|
+
warnings.push("Git history is unavailable, so contributor profiles cannot be computed.");
|
|
5540
|
+
const byContributor = new Map();
|
|
5541
|
+
const ensure = (contributor) => {
|
|
5542
|
+
const existing = byContributor.get(contributor);
|
|
5543
|
+
if (existing)
|
|
5544
|
+
return existing;
|
|
5545
|
+
const created = { commits_total: 0, files: new Map(), categories: new Map() };
|
|
5546
|
+
byContributor.set(contributor, created);
|
|
5547
|
+
return created;
|
|
5548
|
+
};
|
|
5549
|
+
for (const record of gitCommitRecords(projectDir)) {
|
|
5550
|
+
if (!record.author)
|
|
5551
|
+
continue;
|
|
5552
|
+
const item = ensure(record.author);
|
|
5553
|
+
item.commits_total += 1;
|
|
5554
|
+
const category = commitCategory(record.subject);
|
|
5555
|
+
item.categories.set(category, (item.categories.get(category) ?? 0) + 1);
|
|
5556
|
+
for (const file of unique(record.files.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)).filter((path) => graphPaths.has(path))) {
|
|
5557
|
+
item.files.set(file, (item.files.get(file) ?? 0) + 1);
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
5560
|
+
const recentCommits = new Map();
|
|
5561
|
+
for (const author of gitLines(projectDir, ["log", "--since=90 days ago", "--format=%an <%ae>"])) {
|
|
5562
|
+
recentCommits.set(author, (recentCommits.get(author) ?? 0) + 1);
|
|
5563
|
+
}
|
|
5564
|
+
const ownedFiles = new Map();
|
|
5565
|
+
for (const file of graph.files.filter((item) => item.kind === "source")) {
|
|
5566
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file.path);
|
|
5567
|
+
if (!owner.primary_owner || owner.primary_owner_pct == null)
|
|
5568
|
+
continue;
|
|
5569
|
+
const commits = gitCommitCountForPath(projectDir, file.path);
|
|
5570
|
+
const list = ownedFiles.get(owner.primary_owner) ?? [];
|
|
5571
|
+
list.push({ path: file.path, ownership_pct: owner.primary_owner_pct, commits });
|
|
5572
|
+
ownedFiles.set(owner.primary_owner, list);
|
|
5573
|
+
}
|
|
5574
|
+
const hotspots = globalGitHotspots(projectDir, graph);
|
|
5575
|
+
const profiles = [...byContributor.entries()].map(([contributor, item]) => {
|
|
5576
|
+
const filesTouched = [...item.files.entries()]
|
|
5577
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
5578
|
+
.slice(0, 8)
|
|
5579
|
+
.map(([path, commits]) => ({ path, commits }));
|
|
5580
|
+
const modules = countBy([...item.files.keys()], moduleNameForPath);
|
|
5581
|
+
const modulesTouched = Object.entries(modules)
|
|
5582
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
5583
|
+
.slice(0, 6)
|
|
5584
|
+
.map(([module, files]) => ({ module, files }));
|
|
5585
|
+
const owned = (ownedFiles.get(contributor) ?? []).sort((a, b) => b.ownership_pct - a.ownership_pct || b.commits - a.commits || a.path.localeCompare(b.path));
|
|
5586
|
+
const siloFiles = owned.filter((file) => file.ownership_pct >= 0.8 && file.commits >= 5).slice(0, 8);
|
|
5587
|
+
const hotspotFiles = hotspots
|
|
5588
|
+
.filter((hotspot) => hotspot.primary_owner === contributor)
|
|
5589
|
+
.map((hotspot) => ({ path: hotspot.file_path, hotspot_score: hotspot.hotspot_score, commits_90d: hotspot.commit_count_90d }))
|
|
5590
|
+
.slice(0, 6);
|
|
5591
|
+
const categories = Object.fromEntries([...item.categories.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
|
|
5592
|
+
return {
|
|
5593
|
+
contributor,
|
|
5594
|
+
commits_total: item.commits_total,
|
|
5595
|
+
commits_90d: recentCommits.get(contributor) ?? 0,
|
|
5596
|
+
files_touched: filesTouched,
|
|
5597
|
+
modules_touched: modulesTouched,
|
|
5598
|
+
primary_owned_files: owned.length,
|
|
5599
|
+
silo_files: siloFiles,
|
|
5600
|
+
hotspot_files: hotspotFiles,
|
|
5601
|
+
commit_categories: categories,
|
|
5602
|
+
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).`,
|
|
5603
|
+
};
|
|
5604
|
+
})
|
|
5605
|
+
.sort((a, b) => b.commits_90d - a.commits_90d || b.commits_total - a.commits_total || a.contributor.localeCompare(b.contributor))
|
|
5606
|
+
.slice(0, 20);
|
|
5607
|
+
return {
|
|
5608
|
+
schema_version: 1,
|
|
5609
|
+
project_dir: projectDir,
|
|
5610
|
+
generated_at: nowIso(),
|
|
5611
|
+
contributors: profiles,
|
|
5612
|
+
warnings,
|
|
5613
|
+
summary: profiles.length
|
|
5614
|
+
? `${profiles.length} contributor profile(s). Most active: ${profiles[0].contributor} with ${profiles[0].commits_90d} commit(s) in 90d.`
|
|
5615
|
+
: "No contributor profiles could be computed.",
|
|
5616
|
+
};
|
|
5617
|
+
}
|
|
5618
|
+
const DECISION_INTELLIGENCE_TYPES = new Set([
|
|
5619
|
+
"bug_fix",
|
|
5620
|
+
"code_explanation",
|
|
5621
|
+
"constraint",
|
|
5622
|
+
"convention",
|
|
5623
|
+
"decision",
|
|
5624
|
+
"gotcha",
|
|
5625
|
+
"negative_result",
|
|
5626
|
+
"policy",
|
|
5627
|
+
"rationale",
|
|
5628
|
+
"runbook",
|
|
5629
|
+
"workflow",
|
|
5630
|
+
]);
|
|
5631
|
+
function decisionContextValue(packet, key) {
|
|
5632
|
+
const value = packet.context?.[key];
|
|
5633
|
+
if (Array.isArray(value))
|
|
5634
|
+
return value.join("; ");
|
|
5635
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
5636
|
+
}
|
|
5637
|
+
function qualityScore(packet) {
|
|
5638
|
+
const score = Number(packet.quality.score);
|
|
5639
|
+
return Number.isFinite(score) ? score : null;
|
|
5640
|
+
}
|
|
5641
|
+
function kageDecisionIntelligence(projectDir) {
|
|
5642
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5643
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
5644
|
+
const sourcePaths = new Set(graph.files.filter((file) => file.kind === "source" || file.kind === "test").map((file) => file.path));
|
|
5645
|
+
const approved = loadApprovedPackets(projectDir);
|
|
5646
|
+
const decisionPackets = approved.filter((packet) => DECISION_INTELLIGENCE_TYPES.has(packet.type));
|
|
5647
|
+
const warnings = [];
|
|
5648
|
+
const packetsByPath = new Map();
|
|
5649
|
+
const byType = countBy(decisionPackets, (packet) => packet.type);
|
|
5650
|
+
for (const packet of decisionPackets) {
|
|
5651
|
+
for (const path of packet.paths.filter((item) => sourcePaths.has(item))) {
|
|
5652
|
+
const list = packetsByPath.get(path) ?? [];
|
|
5653
|
+
list.push(packet);
|
|
5654
|
+
packetsByPath.set(path, list);
|
|
5655
|
+
}
|
|
5656
|
+
}
|
|
5657
|
+
const { forward, reverse } = codeGraphAdjacency(graph);
|
|
5658
|
+
const rank = filePageRank(graph, forward);
|
|
5659
|
+
const hotspots = new Map(globalGitHotspots(projectDir, graph).map((hotspot) => [hotspot.file_path, hotspot]));
|
|
5660
|
+
const coverageGaps = graph.files
|
|
5661
|
+
.filter((file) => sourcePaths.has(file.path) && !packetsByPath.has(file.path))
|
|
5662
|
+
.map((file) => {
|
|
5663
|
+
const hotspot = hotspots.get(file.path);
|
|
5664
|
+
const dependents = reverse.get(file.path)?.size ?? 0;
|
|
5665
|
+
const churn90d = hotspot?.commit_count_90d ?? (gitHead(projectDir) ? gitCommitCountForPath(projectDir, file.path, "90 days ago") : 0);
|
|
5666
|
+
const signals = [];
|
|
5667
|
+
if (dependents)
|
|
5668
|
+
signals.push(`${dependents} dependent(s)`);
|
|
5669
|
+
if (churn90d)
|
|
5670
|
+
signals.push(`${churn90d} commit(s) in 90d`);
|
|
5671
|
+
if (file.kind === "source" && !hasTestCoverage(file.path, graph))
|
|
5672
|
+
signals.push("no direct test signal");
|
|
5673
|
+
return {
|
|
5674
|
+
path: file.path,
|
|
5675
|
+
reason: signals.length ? `No decision memory despite ${signals.join(", ")}.` : "No decision memory is linked to this code path.",
|
|
5676
|
+
dependents,
|
|
5677
|
+
churn_90d: churn90d,
|
|
5678
|
+
primary_owner: hotspot?.primary_owner ?? gitPrimaryOwnerForPath(projectDir, file.path).primary_owner,
|
|
5679
|
+
};
|
|
5680
|
+
})
|
|
5681
|
+
.sort((a, b) => {
|
|
5682
|
+
const aScore = (rank.get(a.path) ?? 0) * 1000 + a.dependents * 10 + a.churn_90d;
|
|
5683
|
+
const bScore = (rank.get(b.path) ?? 0) * 1000 + b.dependents * 10 + b.churn_90d;
|
|
5684
|
+
return bScore - aScore || a.path.localeCompare(b.path);
|
|
5685
|
+
})
|
|
5686
|
+
.slice(0, 20);
|
|
5687
|
+
const topDecisions = decisionPackets
|
|
5688
|
+
.map((packet) => ({
|
|
5689
|
+
packet_id: packet.id,
|
|
5690
|
+
title: packet.title,
|
|
5691
|
+
type: packet.type,
|
|
5692
|
+
paths: packet.paths.filter((path) => graphPaths.has(path)).slice(0, 8),
|
|
5693
|
+
summary: packet.summary,
|
|
5694
|
+
why: decisionContextValue(packet, "why"),
|
|
5695
|
+
risk_if_forgotten: decisionContextValue(packet, "risk_if_forgotten"),
|
|
5696
|
+
verification: decisionContextValue(packet, "verification"),
|
|
5697
|
+
quality_score: qualityScore(packet),
|
|
5698
|
+
}))
|
|
5699
|
+
.sort((a, b) => (b.quality_score ?? 0) - (a.quality_score ?? 0) || b.paths.length - a.paths.length || a.title.localeCompare(b.title))
|
|
5700
|
+
.slice(0, 20);
|
|
5701
|
+
const weakOrStale = decisionPackets
|
|
5702
|
+
.map((packet) => {
|
|
5703
|
+
const quality = evaluateMemoryQuality(projectDir, packet);
|
|
5704
|
+
const reasons = unique([
|
|
5705
|
+
...staleMemoryReasons(projectDir, packet),
|
|
5706
|
+
...(quality.risks ?? []),
|
|
5707
|
+
]);
|
|
5708
|
+
if (qualityScore(packet) !== null && Number(quality.score) < 72 && !reasons.includes(`quality score ${quality.score}`)) {
|
|
5709
|
+
reasons.push(`quality score ${quality.score}`);
|
|
5710
|
+
}
|
|
5711
|
+
return { packet, reasons };
|
|
5712
|
+
})
|
|
5713
|
+
.filter((item) => item.reasons.length > 0)
|
|
5714
|
+
.sort((a, b) => b.reasons.length - a.reasons.length || a.packet.title.localeCompare(b.packet.title))
|
|
5715
|
+
.slice(0, 20)
|
|
5716
|
+
.map((item) => ({
|
|
5717
|
+
packet_id: item.packet.id,
|
|
5718
|
+
title: item.packet.title,
|
|
5719
|
+
type: item.packet.type,
|
|
5720
|
+
reasons: item.reasons,
|
|
5721
|
+
paths: item.packet.paths.slice(0, 8),
|
|
5722
|
+
}));
|
|
5723
|
+
if (!gitHead(projectDir))
|
|
5724
|
+
warnings.push("Git history is unavailable, so coverage gaps do not include churn or ownership signals.");
|
|
5725
|
+
const coveragePercent = percent(packetsByPath.size, sourcePaths.size);
|
|
5726
|
+
return {
|
|
5727
|
+
schema_version: 1,
|
|
5728
|
+
project_dir: projectDir,
|
|
5729
|
+
generated_at: nowIso(),
|
|
5730
|
+
decision_memory_count: decisionPackets.length,
|
|
5731
|
+
code_paths_with_memory: packetsByPath.size,
|
|
5732
|
+
code_paths_total: sourcePaths.size,
|
|
5733
|
+
coverage_percent: coveragePercent,
|
|
5734
|
+
by_type: byType,
|
|
5735
|
+
top_decisions: topDecisions,
|
|
5736
|
+
coverage_gaps: coverageGaps,
|
|
5737
|
+
weak_or_stale_memory: weakOrStale,
|
|
5738
|
+
warnings,
|
|
5739
|
+
summary: `${decisionPackets.length} why-memory packet(s) cover ${packetsByPath.size}/${sourcePaths.size} code path(s) (${coveragePercent}%). ${coverageGaps.length} high-signal uncovered path(s) surfaced.`,
|
|
5740
|
+
};
|
|
5741
|
+
}
|
|
5742
|
+
function moduleNameForPath(path) {
|
|
5743
|
+
const parts = path.split("/");
|
|
5744
|
+
if (parts.length <= 1)
|
|
5745
|
+
return "(root)";
|
|
5746
|
+
if (parts[0] === "mcp" && parts.length > 2)
|
|
5747
|
+
return `${parts[0]}/${parts[1]}`;
|
|
5748
|
+
return parts[0];
|
|
5749
|
+
}
|
|
5750
|
+
function moduleGrade(score) {
|
|
5751
|
+
if (score >= 85)
|
|
5752
|
+
return "A";
|
|
5753
|
+
if (score >= 70)
|
|
5754
|
+
return "B";
|
|
5755
|
+
if (score >= 50)
|
|
5756
|
+
return "C";
|
|
5757
|
+
return "D";
|
|
5758
|
+
}
|
|
5759
|
+
function kageModuleHealth(projectDir) {
|
|
5760
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
5761
|
+
const cleanup = kageCleanupCandidates(projectDir);
|
|
5762
|
+
const cleanupByModule = countBy(cleanup.candidates, (candidate) => moduleNameForPath(candidate.path));
|
|
5763
|
+
const warnings = [...cleanup.warnings];
|
|
5764
|
+
const hasGit = Boolean(gitHead(projectDir));
|
|
5765
|
+
if (!hasGit)
|
|
5766
|
+
warnings.push("Git history is unavailable, so module health excludes churn and ownership signals.");
|
|
5767
|
+
const modules = new Map();
|
|
5768
|
+
for (const file of graph.files) {
|
|
5769
|
+
const name = moduleNameForPath(file.path);
|
|
5770
|
+
const list = modules.get(name) ?? [];
|
|
5771
|
+
list.push(file);
|
|
5772
|
+
modules.set(name, list);
|
|
5773
|
+
}
|
|
5774
|
+
const items = [];
|
|
5775
|
+
for (const [module, files] of modules.entries()) {
|
|
5776
|
+
const paths = new Set(files.map((file) => file.path));
|
|
5777
|
+
const sourceFiles = files.filter((file) => file.kind === "source");
|
|
5778
|
+
const testFiles = files.filter((file) => file.kind === "test");
|
|
5779
|
+
const symbols = graph.symbols.filter((symbol) => paths.has(symbol.path)).length;
|
|
5780
|
+
const imports = graph.imports.filter((edge) => paths.has(edge.from_path)).length;
|
|
5781
|
+
const routes = graph.routes.filter((route) => paths.has(route.file_path)).length;
|
|
5782
|
+
const tests = graph.tests.filter((test) => paths.has(test.test_path)).length;
|
|
5783
|
+
const cleanupCandidates = cleanupByModule[module] ?? 0;
|
|
5784
|
+
const testGapFiles = sourceFiles.filter((file) => !hasTestCoverage(file.path, graph)).length;
|
|
5785
|
+
let churn90d = 0;
|
|
5786
|
+
const ownerCounts = new Map();
|
|
5787
|
+
if (hasGit) {
|
|
5788
|
+
for (const file of sourceFiles) {
|
|
5789
|
+
churn90d += gitCommitCountForPath(projectDir, file.path, "90 days ago");
|
|
5790
|
+
const owner = gitPrimaryOwnerForPath(projectDir, file.path).primary_owner;
|
|
5791
|
+
if (owner)
|
|
5792
|
+
ownerCounts.set(owner, (ownerCounts.get(owner) ?? 0) + 1);
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
const primaryOwners = [...ownerCounts.entries()]
|
|
5796
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
5797
|
+
.slice(0, 3)
|
|
5798
|
+
.map(([owner, count]) => ({ owner, files: count }));
|
|
5799
|
+
const singleOwnerPenalty = primaryOwners[0] && sourceFiles.length >= 3 && primaryOwners[0].files / sourceFiles.length >= 0.8 ? 10 : 0;
|
|
5800
|
+
let score = 100;
|
|
5801
|
+
score -= Math.min(30, churn90d * 2);
|
|
5802
|
+
score -= Math.min(25, testGapFiles * 5);
|
|
5803
|
+
score -= Math.min(20, cleanupCandidates * 10);
|
|
5804
|
+
score -= singleOwnerPenalty;
|
|
5805
|
+
score = Number(Math.max(0, Math.min(100, score)).toFixed(2));
|
|
5806
|
+
const reasons = [];
|
|
5807
|
+
if (churn90d)
|
|
5808
|
+
reasons.push(`${churn90d} commit(s) in 90 days`);
|
|
5809
|
+
if (testGapFiles)
|
|
5810
|
+
reasons.push(`${testGapFiles} source file(s) lack direct test signal`);
|
|
5811
|
+
if (cleanupCandidates)
|
|
5812
|
+
reasons.push(`${cleanupCandidates} cleanup candidate(s)`);
|
|
5813
|
+
if (singleOwnerPenalty)
|
|
5814
|
+
reasons.push("ownership concentrated in one primary owner");
|
|
5815
|
+
if (!reasons.length)
|
|
5816
|
+
reasons.push("low churn, no cleanup candidates, and no source test gaps detected");
|
|
5817
|
+
items.push({
|
|
5818
|
+
module,
|
|
5819
|
+
score,
|
|
5820
|
+
grade: moduleGrade(score),
|
|
5821
|
+
files: files.length,
|
|
5822
|
+
source_files: sourceFiles.length,
|
|
5823
|
+
test_files: testFiles.length,
|
|
5824
|
+
symbols,
|
|
5825
|
+
imports,
|
|
5826
|
+
routes,
|
|
5827
|
+
tests,
|
|
5828
|
+
cleanup_candidates: cleanupCandidates,
|
|
5829
|
+
test_gap_files: testGapFiles,
|
|
5830
|
+
churn_90d: churn90d,
|
|
5831
|
+
primary_owners: primaryOwners,
|
|
5832
|
+
reasons,
|
|
5833
|
+
});
|
|
5834
|
+
}
|
|
5835
|
+
items.sort((a, b) => a.score - b.score || b.files - a.files || a.module.localeCompare(b.module));
|
|
5836
|
+
return {
|
|
5837
|
+
schema_version: 1,
|
|
5838
|
+
project_dir: projectDir,
|
|
5839
|
+
generated_at: nowIso(),
|
|
5840
|
+
modules: items,
|
|
5841
|
+
warnings: unique(warnings),
|
|
5842
|
+
summary: `${items.length} module health scorecard(s). Lowest score: ${items[0] ? `${items[0].module} ${items[0].score}` : "none"}.`,
|
|
5843
|
+
};
|
|
5844
|
+
}
|
|
5845
|
+
function codeGraphAdjacency(graph) {
|
|
5846
|
+
const paths = new Set(graph.files.map((file) => file.path));
|
|
5847
|
+
const forward = new Map();
|
|
5848
|
+
const reverse = new Map();
|
|
5849
|
+
for (const file of graph.files) {
|
|
5850
|
+
forward.set(file.path, new Set());
|
|
5851
|
+
reverse.set(file.path, new Set());
|
|
5852
|
+
}
|
|
5853
|
+
for (const edge of graph.imports) {
|
|
5854
|
+
if (!edge.to_path || !paths.has(edge.from_path) || !paths.has(edge.to_path))
|
|
5855
|
+
continue;
|
|
5856
|
+
forward.get(edge.from_path).add(edge.to_path);
|
|
5857
|
+
reverse.get(edge.to_path).add(edge.from_path);
|
|
5858
|
+
}
|
|
5859
|
+
return { forward, reverse };
|
|
5860
|
+
}
|
|
5861
|
+
function filePageRank(graph, forward) {
|
|
5862
|
+
const paths = graph.files.map((file) => file.path);
|
|
5863
|
+
const n = paths.length || 1;
|
|
5864
|
+
const rank = new Map(paths.map((path) => [path, 1 / n]));
|
|
5865
|
+
const damping = 0.85;
|
|
5866
|
+
for (let iteration = 0; iteration < 20; iteration += 1) {
|
|
5867
|
+
const next = new Map(paths.map((path) => [path, (1 - damping) / n]));
|
|
5868
|
+
for (const path of paths) {
|
|
5869
|
+
const outs = [...(forward.get(path) ?? [])];
|
|
5870
|
+
const share = (rank.get(path) ?? 0) / Math.max(1, outs.length);
|
|
5871
|
+
if (!outs.length) {
|
|
5872
|
+
for (const target of paths)
|
|
5873
|
+
next.set(target, (next.get(target) ?? 0) + damping * share / n);
|
|
5874
|
+
}
|
|
5875
|
+
else {
|
|
5876
|
+
for (const target of outs)
|
|
5877
|
+
next.set(target, (next.get(target) ?? 0) + damping * share);
|
|
5878
|
+
}
|
|
5879
|
+
}
|
|
5880
|
+
rank.clear();
|
|
5881
|
+
for (const [path, score] of next)
|
|
5882
|
+
rank.set(path, score);
|
|
5883
|
+
}
|
|
5884
|
+
return rank;
|
|
5885
|
+
}
|
|
5886
|
+
function dependencyCycles(graph, forward) {
|
|
5887
|
+
const paths = graph.files.map((file) => file.path);
|
|
5888
|
+
const indexByPath = new Map();
|
|
5889
|
+
const lowByPath = new Map();
|
|
5890
|
+
const stack = [];
|
|
5891
|
+
const onStack = new Set();
|
|
5892
|
+
const components = [];
|
|
5893
|
+
let index = 0;
|
|
5894
|
+
const strongConnect = (path) => {
|
|
5895
|
+
indexByPath.set(path, index);
|
|
5896
|
+
lowByPath.set(path, index);
|
|
5897
|
+
index += 1;
|
|
5898
|
+
stack.push(path);
|
|
5899
|
+
onStack.add(path);
|
|
5900
|
+
for (const next of forward.get(path) ?? []) {
|
|
5901
|
+
if (!indexByPath.has(next)) {
|
|
5902
|
+
strongConnect(next);
|
|
5903
|
+
lowByPath.set(path, Math.min(lowByPath.get(path) ?? 0, lowByPath.get(next) ?? 0));
|
|
5904
|
+
}
|
|
5905
|
+
else if (onStack.has(next)) {
|
|
5906
|
+
lowByPath.set(path, Math.min(lowByPath.get(path) ?? 0, indexByPath.get(next) ?? 0));
|
|
5907
|
+
}
|
|
5908
|
+
}
|
|
5909
|
+
if (lowByPath.get(path) === indexByPath.get(path)) {
|
|
5910
|
+
const component = [];
|
|
5911
|
+
while (stack.length) {
|
|
5912
|
+
const next = stack.pop();
|
|
5913
|
+
onStack.delete(next);
|
|
5914
|
+
component.push(next);
|
|
5915
|
+
if (next === path)
|
|
5916
|
+
break;
|
|
5917
|
+
}
|
|
5918
|
+
if (component.length > 1 || (forward.get(path) ?? new Set()).has(path))
|
|
5919
|
+
components.push(component.sort());
|
|
5920
|
+
}
|
|
5921
|
+
};
|
|
5922
|
+
for (const path of paths)
|
|
5923
|
+
if (!indexByPath.has(path))
|
|
5924
|
+
strongConnect(path);
|
|
5925
|
+
return components
|
|
5926
|
+
.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]))
|
|
5927
|
+
.slice(0, 10)
|
|
5928
|
+
.map((files) => ({ files, size: files.length }));
|
|
5929
|
+
}
|
|
5930
|
+
function graphCommunities(graph, forward, reverse) {
|
|
5931
|
+
const visited = new Set();
|
|
5932
|
+
const fileByPath = new Map(graph.files.map((file) => [file.path, file]));
|
|
5933
|
+
const routeByFile = new Map();
|
|
5934
|
+
for (const route of graph.routes) {
|
|
5935
|
+
const list = routeByFile.get(route.file_path) ?? [];
|
|
5936
|
+
list.push(`${route.method} ${route.path}`);
|
|
5937
|
+
routeByFile.set(route.file_path, list);
|
|
5938
|
+
}
|
|
5939
|
+
const components = [];
|
|
5940
|
+
for (const file of graph.files) {
|
|
5941
|
+
if (visited.has(file.path))
|
|
5942
|
+
continue;
|
|
5943
|
+
const queue = [file.path];
|
|
5944
|
+
visited.add(file.path);
|
|
5945
|
+
const component = [];
|
|
5946
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
5947
|
+
const current = queue[index];
|
|
5948
|
+
component.push(current);
|
|
5949
|
+
const neighbors = new Set([...(forward.get(current) ?? []), ...(reverse.get(current) ?? [])]);
|
|
5950
|
+
for (const next of neighbors) {
|
|
5951
|
+
if (visited.has(next))
|
|
5952
|
+
continue;
|
|
5953
|
+
visited.add(next);
|
|
5954
|
+
queue.push(next);
|
|
5955
|
+
}
|
|
5956
|
+
}
|
|
5957
|
+
components.push(component.sort());
|
|
5958
|
+
}
|
|
5959
|
+
return components
|
|
5960
|
+
.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]))
|
|
5961
|
+
.slice(0, 12)
|
|
5962
|
+
.map((files, index) => {
|
|
5963
|
+
const moduleCounts = countBy(files, moduleNameForPath);
|
|
5964
|
+
const label = Object.entries(moduleCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? `community-${index + 1}`;
|
|
5965
|
+
return {
|
|
5966
|
+
id: index + 1,
|
|
5967
|
+
label,
|
|
5968
|
+
files,
|
|
5969
|
+
entrypoints: files.filter((path) => isEntrypointLike(path) || fileByPath.get(path)?.kind === "manifest").slice(0, 10),
|
|
5970
|
+
routes: files.flatMap((path) => routeByFile.get(path) ?? []).slice(0, 10),
|
|
5971
|
+
};
|
|
5972
|
+
});
|
|
5973
|
+
}
|
|
5974
|
+
function entryFlows(graph, forward) {
|
|
5975
|
+
const routeEntries = graph.routes.map((route) => route.file_path);
|
|
5976
|
+
const entryFiles = graph.files
|
|
5977
|
+
.filter((file) => file.kind === "source" && isEntrypointLike(file.path))
|
|
5978
|
+
.map((file) => file.path);
|
|
5979
|
+
const entries = unique([...routeEntries, ...entryFiles]).slice(0, 8);
|
|
5980
|
+
const flows = [];
|
|
5981
|
+
for (const entry of entries) {
|
|
5982
|
+
const path = [entry];
|
|
5983
|
+
const seen = new Set(path);
|
|
5984
|
+
let current = entry;
|
|
5985
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
5986
|
+
const next = [...(forward.get(current) ?? [])]
|
|
5987
|
+
.filter((candidate) => !seen.has(candidate))
|
|
5988
|
+
.sort((a, b) => (forward.get(b)?.size ?? 0) - (forward.get(a)?.size ?? 0) || a.localeCompare(b))[0];
|
|
5989
|
+
if (!next)
|
|
5990
|
+
break;
|
|
5991
|
+
path.push(next);
|
|
5992
|
+
seen.add(next);
|
|
5993
|
+
current = next;
|
|
5994
|
+
}
|
|
5995
|
+
if (path.length > 1)
|
|
5996
|
+
flows.push({ entry, path });
|
|
5997
|
+
}
|
|
5998
|
+
return flows;
|
|
5999
|
+
}
|
|
6000
|
+
function graphLanguageCoverage(graph) {
|
|
6001
|
+
const byLanguage = new Map();
|
|
6002
|
+
for (const file of graph.files) {
|
|
6003
|
+
if (file.kind !== "source" && file.kind !== "test")
|
|
6004
|
+
continue;
|
|
6005
|
+
const list = byLanguage.get(file.language) ?? [];
|
|
6006
|
+
list.push(file);
|
|
6007
|
+
byLanguage.set(file.language, list);
|
|
6008
|
+
}
|
|
6009
|
+
const preciseParsers = ["scip", "lsif", "lsp"];
|
|
6010
|
+
const astParsers = ["typescript-ast", "tree-sitter"];
|
|
6011
|
+
return [...byLanguage.entries()]
|
|
6012
|
+
.map(([language, files]) => {
|
|
6013
|
+
const precise = files.filter((file) => preciseParsers.includes(file.parser)).length;
|
|
6014
|
+
const ast = files.filter((file) => astParsers.includes(file.parser)).length;
|
|
6015
|
+
const generic = files.filter((file) => file.parser === "generic-static").length;
|
|
6016
|
+
const metadata = files.filter((file) => file.parser === "metadata").length;
|
|
6017
|
+
return {
|
|
6018
|
+
language,
|
|
6019
|
+
files: files.length,
|
|
6020
|
+
precise_files: precise,
|
|
6021
|
+
ast_files: ast,
|
|
6022
|
+
generic_files: generic,
|
|
6023
|
+
metadata_files: metadata,
|
|
6024
|
+
coverage_percent: percent(files.length - metadata, files.length),
|
|
6025
|
+
};
|
|
6026
|
+
})
|
|
6027
|
+
.sort((a, b) => b.files - a.files || a.language.localeCompare(b.language));
|
|
6028
|
+
}
|
|
6029
|
+
function kageGraphInsights(projectDir) {
|
|
6030
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
6031
|
+
const { forward, reverse } = codeGraphAdjacency(graph);
|
|
6032
|
+
const rank = filePageRank(graph, forward);
|
|
6033
|
+
const centralFiles = graph.files
|
|
6034
|
+
.map((file) => ({
|
|
6035
|
+
path: file.path,
|
|
6036
|
+
pagerank: Number((rank.get(file.path) ?? 0).toFixed(6)),
|
|
6037
|
+
dependents: reverse.get(file.path)?.size ?? 0,
|
|
6038
|
+
imports: forward.get(file.path)?.size ?? 0,
|
|
6039
|
+
kind: file.kind,
|
|
6040
|
+
}))
|
|
6041
|
+
.sort((a, b) => b.pagerank - a.pagerank || b.dependents - a.dependents || a.path.localeCompare(b.path))
|
|
6042
|
+
.slice(0, 15);
|
|
6043
|
+
const cycles = dependencyCycles(graph, forward);
|
|
6044
|
+
const communities = graphCommunities(graph, forward, reverse);
|
|
6045
|
+
const flows = entryFlows(graph, forward);
|
|
6046
|
+
return {
|
|
6047
|
+
schema_version: 1,
|
|
6048
|
+
project_dir: projectDir,
|
|
6049
|
+
generated_at: nowIso(),
|
|
6050
|
+
language_coverage: graphLanguageCoverage(graph),
|
|
6051
|
+
edge_mix: {
|
|
6052
|
+
imports: graph.imports.length,
|
|
6053
|
+
calls: graph.calls.length,
|
|
6054
|
+
routes: graph.routes.length,
|
|
6055
|
+
tests: graph.tests.length,
|
|
6056
|
+
packages: graph.packages.length,
|
|
6057
|
+
},
|
|
6058
|
+
central_files: centralFiles,
|
|
6059
|
+
dependency_cycles: cycles,
|
|
6060
|
+
communities,
|
|
6061
|
+
entry_flows: flows,
|
|
6062
|
+
warnings: [],
|
|
6063
|
+
summary: `${centralFiles.length} central file(s), ${cycles.length} dependency cycle(s), ${communities.length} communit${communities.length === 1 ? "y" : "ies"}, ${flows.length} entry flow(s).`,
|
|
6064
|
+
};
|
|
6065
|
+
}
|
|
6066
|
+
const WORKSPACE_SKIP_DIRS = new Set([
|
|
6067
|
+
".agent_memory",
|
|
6068
|
+
".git",
|
|
6069
|
+
".hg",
|
|
6070
|
+
".next",
|
|
6071
|
+
".repowise",
|
|
6072
|
+
".repowise-workspace",
|
|
6073
|
+
"coverage",
|
|
6074
|
+
"dist",
|
|
6075
|
+
"node_modules",
|
|
6076
|
+
"target",
|
|
6077
|
+
"vendor",
|
|
6078
|
+
]);
|
|
6079
|
+
function discoverWorkspaceRepos(rootDir, maxDepth = 3) {
|
|
6080
|
+
const root = (0, node_path_1.resolve)(rootDir);
|
|
6081
|
+
const repos = [];
|
|
6082
|
+
const visit = (dir, depth) => {
|
|
6083
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".git"))) {
|
|
6084
|
+
repos.push(dir);
|
|
6085
|
+
if (depth > 0) {
|
|
6086
|
+
// Keep scanning nested repos for frontend/backend layouts inside a mono root.
|
|
6087
|
+
}
|
|
6088
|
+
}
|
|
6089
|
+
if (depth >= maxDepth)
|
|
6090
|
+
return;
|
|
6091
|
+
let entries = [];
|
|
6092
|
+
try {
|
|
6093
|
+
entries = (0, node_fs_1.readdirSync)(dir).sort();
|
|
6094
|
+
}
|
|
6095
|
+
catch {
|
|
6096
|
+
return;
|
|
6097
|
+
}
|
|
6098
|
+
for (const entry of entries) {
|
|
6099
|
+
if (WORKSPACE_SKIP_DIRS.has(entry) || entry.startsWith("."))
|
|
6100
|
+
continue;
|
|
6101
|
+
const path = (0, node_path_1.join)(dir, entry);
|
|
6102
|
+
let stats;
|
|
6103
|
+
try {
|
|
6104
|
+
stats = (0, node_fs_1.lstatSync)(path);
|
|
6105
|
+
}
|
|
6106
|
+
catch {
|
|
6107
|
+
continue;
|
|
6108
|
+
}
|
|
6109
|
+
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
6110
|
+
continue;
|
|
6111
|
+
visit(path, depth + 1);
|
|
6112
|
+
}
|
|
6113
|
+
};
|
|
6114
|
+
visit(root, 0);
|
|
6115
|
+
return unique(repos).sort((a, b) => (0, node_path_1.relative)(root, a).localeCompare((0, node_path_1.relative)(root, b)));
|
|
6116
|
+
}
|
|
6117
|
+
function workspaceAlias(rootDir, repoPath, used) {
|
|
6118
|
+
const root = (0, node_path_1.resolve)(rootDir);
|
|
6119
|
+
const rel = (0, node_path_1.relative)(root, repoPath).replace(/\\/g, "/");
|
|
6120
|
+
const base = rel && rel !== "" ? (0, node_path_1.basename)(rel) : (0, node_path_1.basename)(repoPath);
|
|
6121
|
+
let alias = slugify(base || "repo");
|
|
6122
|
+
if (!alias)
|
|
6123
|
+
alias = "repo";
|
|
6124
|
+
let next = alias;
|
|
6125
|
+
let suffix = 2;
|
|
6126
|
+
while (used.has(next)) {
|
|
6127
|
+
next = `${alias}-${suffix}`;
|
|
6128
|
+
suffix += 1;
|
|
6129
|
+
}
|
|
6130
|
+
used.add(next);
|
|
6131
|
+
return next;
|
|
6132
|
+
}
|
|
6133
|
+
function packageInfo(projectDir) {
|
|
6134
|
+
const pkgPath = (0, node_path_1.join)(projectDir, "package.json");
|
|
6135
|
+
if (!(0, node_fs_1.existsSync)(pkgPath))
|
|
6136
|
+
return { name: null, dependencies: [] };
|
|
6137
|
+
try {
|
|
6138
|
+
const pkg = readJson(pkgPath);
|
|
6139
|
+
const dependencyBlocks = [
|
|
6140
|
+
pkg.dependencies,
|
|
6141
|
+
pkg.devDependencies,
|
|
6142
|
+
pkg.peerDependencies,
|
|
6143
|
+
pkg.optionalDependencies,
|
|
6144
|
+
].filter((block) => Boolean(block && typeof block === "object" && !Array.isArray(block)));
|
|
6145
|
+
return {
|
|
6146
|
+
name: typeof pkg.name === "string" ? pkg.name : null,
|
|
6147
|
+
dependencies: unique(dependencyBlocks.flatMap((block) => Object.keys(block))).sort(),
|
|
6148
|
+
};
|
|
6149
|
+
}
|
|
6150
|
+
catch {
|
|
6151
|
+
return { name: null, dependencies: [] };
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
6154
|
+
function workspaceRepoSummary(rootDir, repoPath, alias) {
|
|
6155
|
+
const pkg = packageInfo(repoPath);
|
|
6156
|
+
const graph = readCurrentCodeGraph(repoPath);
|
|
6157
|
+
return {
|
|
6158
|
+
alias,
|
|
6159
|
+
path: (0, node_path_1.relative)((0, node_path_1.resolve)(rootDir), repoPath).replace(/\\/g, "/") || ".",
|
|
6160
|
+
package_name: pkg.name,
|
|
6161
|
+
indexed: (0, node_fs_1.existsSync)(memoryRoot(repoPath)),
|
|
6162
|
+
approved_packets: loadApprovedPackets(repoPath).length,
|
|
6163
|
+
pending_packets: loadPendingPackets(repoPath).length,
|
|
6164
|
+
code_files: graph?.files.length ?? 0,
|
|
6165
|
+
code_symbols: graph?.symbols.length ?? 0,
|
|
6166
|
+
branch: gitBranch(repoPath),
|
|
6167
|
+
head: gitHead(repoPath),
|
|
6168
|
+
dependencies: pkg.dependencies,
|
|
6169
|
+
};
|
|
6170
|
+
}
|
|
6171
|
+
function routeNeedles(routePath) {
|
|
6172
|
+
if (routePath.length < 3 || routePath === "/:dynamic")
|
|
6173
|
+
return [];
|
|
6174
|
+
const normalized = routePath.replace(/:[A-Za-z0-9_]+/g, "");
|
|
6175
|
+
return unique([
|
|
6176
|
+
routePath,
|
|
6177
|
+
normalized,
|
|
6178
|
+
normalized.replace(/\/+$/, ""),
|
|
6179
|
+
].filter((needle) => needle.length >= 3 && needle !== "/"));
|
|
6180
|
+
}
|
|
6181
|
+
function workspaceRouteContracts(workspaceDir, repos) {
|
|
6182
|
+
const graphs = new Map();
|
|
6183
|
+
for (const repo of repos) {
|
|
6184
|
+
const repoPath = repo.path === "." ? workspaceDir : (0, node_path_1.join)(workspaceDir, repo.path);
|
|
6185
|
+
const graph = readCurrentCodeGraph(repoPath);
|
|
6186
|
+
if (graph)
|
|
6187
|
+
graphs.set(repo.alias, graph);
|
|
6188
|
+
}
|
|
6189
|
+
const contracts = [];
|
|
6190
|
+
for (const provider of repos) {
|
|
6191
|
+
const providerGraph = graphs.get(provider.alias);
|
|
6192
|
+
if (!providerGraph?.routes.length)
|
|
6193
|
+
continue;
|
|
6194
|
+
for (const route of providerGraph.routes) {
|
|
6195
|
+
const needles = routeNeedles(route.path);
|
|
6196
|
+
if (!needles.length)
|
|
6197
|
+
continue;
|
|
6198
|
+
for (const consumer of repos) {
|
|
6199
|
+
if (consumer.alias === provider.alias)
|
|
6200
|
+
continue;
|
|
6201
|
+
const consumerGraph = graphs.get(consumer.alias);
|
|
6202
|
+
if (!consumerGraph)
|
|
6203
|
+
continue;
|
|
6204
|
+
const consumerRoot = consumer.path === "." ? workspaceDir : (0, node_path_1.join)(workspaceDir, consumer.path);
|
|
6205
|
+
for (const file of consumerGraph.files) {
|
|
6206
|
+
if (!["source", "config", "manifest"].includes(file.kind))
|
|
6207
|
+
continue;
|
|
6208
|
+
if (file.size_bytes > MAX_CODE_FILE_BYTES)
|
|
6209
|
+
continue;
|
|
6210
|
+
const absolutePath = (0, node_path_1.join)(consumerRoot, file.path);
|
|
6211
|
+
if (!(0, node_fs_1.existsSync)(absolutePath))
|
|
6212
|
+
continue;
|
|
6213
|
+
let text = "";
|
|
6214
|
+
try {
|
|
6215
|
+
text = (0, node_fs_1.readFileSync)(absolutePath, "utf8");
|
|
6216
|
+
}
|
|
6217
|
+
catch {
|
|
6218
|
+
continue;
|
|
6219
|
+
}
|
|
6220
|
+
const matched = needles.find((needle) => text.includes(needle));
|
|
6221
|
+
if (!matched)
|
|
6222
|
+
continue;
|
|
6223
|
+
contracts.push({
|
|
6224
|
+
provider_repo: provider.alias,
|
|
6225
|
+
provider_file: route.file_path,
|
|
6226
|
+
method: route.method,
|
|
6227
|
+
path: route.path,
|
|
6228
|
+
consumer_repo: consumer.alias,
|
|
6229
|
+
consumer_file: file.path,
|
|
6230
|
+
confidence: matched === route.path ? "high" : "medium",
|
|
6231
|
+
evidence: `consumer source mentions ${matched}`,
|
|
6232
|
+
});
|
|
6233
|
+
break;
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
}
|
|
6237
|
+
}
|
|
6238
|
+
return contracts
|
|
6239
|
+
.sort((a, b) => a.provider_repo.localeCompare(b.provider_repo) || a.path.localeCompare(b.path) || a.consumer_repo.localeCompare(b.consumer_repo))
|
|
6240
|
+
.slice(0, 50);
|
|
6241
|
+
}
|
|
6242
|
+
function kageWorkspace(projectDir) {
|
|
6243
|
+
const root = (0, node_path_1.resolve)(projectDir);
|
|
6244
|
+
const warnings = [];
|
|
6245
|
+
const repoPaths = discoverWorkspaceRepos(root);
|
|
6246
|
+
if (!repoPaths.length)
|
|
6247
|
+
warnings.push("No git repositories found under the workspace directory.");
|
|
6248
|
+
const usedAliases = new Set();
|
|
6249
|
+
const rawRepos = repoPaths.map((repoPath) => workspaceRepoSummary(root, repoPath, workspaceAlias(root, repoPath, usedAliases)));
|
|
6250
|
+
const packageOwners = new Map();
|
|
6251
|
+
for (const repo of rawRepos) {
|
|
6252
|
+
if (repo.package_name)
|
|
6253
|
+
packageOwners.set(repo.package_name, { alias: repo.alias, package_name: repo.package_name });
|
|
6254
|
+
}
|
|
6255
|
+
const packageDependencies = [];
|
|
6256
|
+
const repos = rawRepos.map((repo) => {
|
|
6257
|
+
const deps = repo.dependencies
|
|
6258
|
+
.map((dep) => packageOwners.get(dep))
|
|
6259
|
+
.filter((dep) => Boolean(dep && dep.alias !== repo.alias))
|
|
6260
|
+
.sort((a, b) => a.alias.localeCompare(b.alias));
|
|
6261
|
+
for (const dep of deps)
|
|
6262
|
+
packageDependencies.push({ from: repo.alias, to: dep.alias, package_name: dep.package_name });
|
|
6263
|
+
const { dependencies: _dependencies, ...rest } = repo;
|
|
6264
|
+
return { ...rest, dependencies_on_workspace_repos: deps };
|
|
6265
|
+
});
|
|
6266
|
+
const routeContracts = workspaceRouteContracts(root, repos);
|
|
6267
|
+
if (repos.length && repos.every((repo) => !repo.indexed))
|
|
6268
|
+
warnings.push("Workspace repos were found, but none has .agent_memory yet. Run kage init or kage refresh in each repo you want searchable.");
|
|
6269
|
+
return {
|
|
6270
|
+
schema_version: 1,
|
|
6271
|
+
workspace_dir: root,
|
|
6272
|
+
generated_at: nowIso(),
|
|
6273
|
+
repos,
|
|
6274
|
+
package_dependencies: packageDependencies.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
|
|
6275
|
+
route_contracts: routeContracts,
|
|
6276
|
+
warnings,
|
|
6277
|
+
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).`,
|
|
6278
|
+
};
|
|
6279
|
+
}
|
|
6280
|
+
function kageWorkspaceRecall(projectDir, query, limit = 8) {
|
|
6281
|
+
const workspace = kageWorkspace(projectDir);
|
|
6282
|
+
const warnings = [...workspace.warnings];
|
|
6283
|
+
const hits = [];
|
|
6284
|
+
for (const repo of workspace.repos) {
|
|
6285
|
+
if (!repo.indexed)
|
|
6286
|
+
continue;
|
|
6287
|
+
const repoPath = repo.path === "." ? workspace.workspace_dir : (0, node_path_1.join)(workspace.workspace_dir, repo.path);
|
|
6288
|
+
let result;
|
|
6289
|
+
try {
|
|
6290
|
+
result = recall(repoPath, query, Math.max(1, Math.min(limit, 5)), false);
|
|
6291
|
+
}
|
|
6292
|
+
catch (error) {
|
|
6293
|
+
warnings.push(`Recall failed for ${repo.alias}: ${error instanceof Error ? error.message : String(error)}`);
|
|
6294
|
+
continue;
|
|
6295
|
+
}
|
|
6296
|
+
for (const hit of result.results) {
|
|
6297
|
+
hits.push({
|
|
6298
|
+
repo: repo.alias,
|
|
6299
|
+
repo_path: repo.path,
|
|
6300
|
+
title: hit.packet.title,
|
|
6301
|
+
type: hit.packet.type,
|
|
6302
|
+
score: hit.score,
|
|
6303
|
+
summary: hit.packet.summary,
|
|
6304
|
+
paths: hit.packet.paths,
|
|
6305
|
+
why_matched: hit.why_matched,
|
|
6306
|
+
});
|
|
6307
|
+
}
|
|
6308
|
+
}
|
|
6309
|
+
hits.sort((a, b) => b.score - a.score || a.repo.localeCompare(b.repo) || a.title.localeCompare(b.title));
|
|
6310
|
+
const topHits = hits.slice(0, Math.max(1, limit));
|
|
6311
|
+
const contextLines = topHits.length
|
|
6312
|
+
? topHits.map((hit, index) => `${index + 1}. [${hit.repo}] ${hit.title} (${hit.type}, score ${hit.score.toFixed(2)})\n ${hit.summary}`).join("\n")
|
|
6313
|
+
: "No workspace memory matched.";
|
|
6314
|
+
return {
|
|
6315
|
+
schema_version: 1,
|
|
6316
|
+
workspace_dir: workspace.workspace_dir,
|
|
6317
|
+
query,
|
|
6318
|
+
generated_at: nowIso(),
|
|
6319
|
+
repos_searched: workspace.repos.filter((repo) => repo.indexed).length,
|
|
6320
|
+
hits: topHits,
|
|
6321
|
+
warnings,
|
|
6322
|
+
context_block: `# Kage Workspace Context\n\nQuery: ${query}\n\n${contextLines}`,
|
|
6323
|
+
};
|
|
6324
|
+
}
|
|
4811
6325
|
function queryGraph(projectDir, query, limit = 10, graph) {
|
|
4812
6326
|
graph = graph ?? readCurrentGraphs(projectDir)?.knowledgeGraph ?? buildKnowledgeGraph(projectDir);
|
|
4813
6327
|
const terms = tokenize(query);
|
|
@@ -6706,6 +8220,156 @@ function prCheck(projectDir) {
|
|
|
6706
8220
|
required_actions: requiredActions,
|
|
6707
8221
|
};
|
|
6708
8222
|
}
|
|
8223
|
+
const KAGE_POST_COMMIT_HOOK_START = "# >>> KAGE_POST_COMMIT_HOOK_V1";
|
|
8224
|
+
const KAGE_POST_COMMIT_HOOK_END = "# <<< KAGE_POST_COMMIT_HOOK_V1";
|
|
8225
|
+
function regexpEscape(value) {
|
|
8226
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8227
|
+
}
|
|
8228
|
+
function shellQuote(value) {
|
|
8229
|
+
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
8230
|
+
}
|
|
8231
|
+
function gitHookPath(projectDir) {
|
|
8232
|
+
const raw = readGit(projectDir, ["rev-parse", "--git-path", "hooks/post-commit"]);
|
|
8233
|
+
if (!raw)
|
|
8234
|
+
return null;
|
|
8235
|
+
return (0, node_path_1.resolve)(projectDir, raw);
|
|
8236
|
+
}
|
|
8237
|
+
function hasKageHookBlock(content) {
|
|
8238
|
+
return content.includes(KAGE_POST_COMMIT_HOOK_START) && content.includes(KAGE_POST_COMMIT_HOOK_END);
|
|
8239
|
+
}
|
|
8240
|
+
function stripKageHookBlock(content) {
|
|
8241
|
+
const pattern = new RegExp(`\\n?${regexpEscape(KAGE_POST_COMMIT_HOOK_START)}[\\s\\S]*?${regexpEscape(KAGE_POST_COMMIT_HOOK_END)}\\n?`, "g");
|
|
8242
|
+
const stripped = content.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
8243
|
+
return stripped ? `${stripped}\n` : "";
|
|
8244
|
+
}
|
|
8245
|
+
function kagePostCommitHookBlock(projectDir) {
|
|
8246
|
+
const project = shellQuote((0, node_path_1.resolve)(projectDir));
|
|
8247
|
+
return [
|
|
8248
|
+
KAGE_POST_COMMIT_HOOK_START,
|
|
8249
|
+
"# Kage post-commit hook: keep repo memory and review summary current.",
|
|
8250
|
+
"# Set KAGE_SKIP_HOOK=1 to bypass, or KAGE_BIN=/path/to/kage to override.",
|
|
8251
|
+
"if [ \"${KAGE_SKIP_HOOK:-0}\" != \"1\" ]; then",
|
|
8252
|
+
" KAGE_BIN=\"${KAGE_BIN:-kage}\"",
|
|
8253
|
+
" if command -v \"$KAGE_BIN\" >/dev/null 2>&1; then",
|
|
8254
|
+
" (",
|
|
8255
|
+
` "$KAGE_BIN" refresh --project ${project} --json >/dev/null 2>&1 || true`,
|
|
8256
|
+
` "$KAGE_BIN" pr summarize --project ${project} --json >/dev/null 2>&1 || true`,
|
|
8257
|
+
" ) &",
|
|
8258
|
+
" fi",
|
|
8259
|
+
"fi",
|
|
8260
|
+
KAGE_POST_COMMIT_HOOK_END,
|
|
8261
|
+
].join("\n");
|
|
8262
|
+
}
|
|
8263
|
+
function kageHookStatus(projectDir) {
|
|
8264
|
+
const hookPath = gitHookPath(projectDir);
|
|
8265
|
+
if (!hookPath) {
|
|
8266
|
+
return {
|
|
8267
|
+
ok: false,
|
|
8268
|
+
action: "status",
|
|
8269
|
+
project_dir: projectDir,
|
|
8270
|
+
hook_path: null,
|
|
8271
|
+
installed: false,
|
|
8272
|
+
changed: false,
|
|
8273
|
+
message: "Not a git repository or git is unavailable.",
|
|
8274
|
+
errors: ["Not a git repository or git is unavailable."],
|
|
8275
|
+
warnings: [],
|
|
8276
|
+
};
|
|
8277
|
+
}
|
|
8278
|
+
const content = safeReadText(hookPath) ?? "";
|
|
8279
|
+
const installed = hasKageHookBlock(content);
|
|
8280
|
+
return {
|
|
8281
|
+
ok: true,
|
|
8282
|
+
action: "status",
|
|
8283
|
+
project_dir: projectDir,
|
|
8284
|
+
hook_path: hookPath,
|
|
8285
|
+
installed,
|
|
8286
|
+
changed: false,
|
|
8287
|
+
message: installed ? "Kage post-commit hook is installed." : "Kage post-commit hook is not installed.",
|
|
8288
|
+
errors: [],
|
|
8289
|
+
warnings: (0, node_fs_1.existsSync)(hookPath) ? [] : ["No post-commit hook file exists yet."],
|
|
8290
|
+
};
|
|
8291
|
+
}
|
|
8292
|
+
function kageHookInstall(projectDir) {
|
|
8293
|
+
const hookPath = gitHookPath(projectDir);
|
|
8294
|
+
if (!hookPath) {
|
|
8295
|
+
return {
|
|
8296
|
+
ok: false,
|
|
8297
|
+
action: "install",
|
|
8298
|
+
project_dir: projectDir,
|
|
8299
|
+
hook_path: null,
|
|
8300
|
+
installed: false,
|
|
8301
|
+
changed: false,
|
|
8302
|
+
message: "Not a git repository or git is unavailable.",
|
|
8303
|
+
errors: ["Not a git repository or git is unavailable."],
|
|
8304
|
+
warnings: [],
|
|
8305
|
+
};
|
|
8306
|
+
}
|
|
8307
|
+
ensureDir((0, node_path_1.dirname)(hookPath));
|
|
8308
|
+
const existing = safeReadText(hookPath) ?? "";
|
|
8309
|
+
const base = stripKageHookBlock(existing);
|
|
8310
|
+
const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
|
|
8311
|
+
const next = `${prefix}\n\n${kagePostCommitHookBlock(projectDir)}\n`;
|
|
8312
|
+
const changed = existing !== next;
|
|
8313
|
+
if (changed)
|
|
8314
|
+
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
8315
|
+
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
8316
|
+
return {
|
|
8317
|
+
ok: true,
|
|
8318
|
+
action: "install",
|
|
8319
|
+
project_dir: projectDir,
|
|
8320
|
+
hook_path: hookPath,
|
|
8321
|
+
installed: true,
|
|
8322
|
+
changed,
|
|
8323
|
+
message: changed ? "Installed Kage post-commit hook." : "Kage post-commit hook is already current.",
|
|
8324
|
+
errors: [],
|
|
8325
|
+
warnings: [],
|
|
8326
|
+
};
|
|
8327
|
+
}
|
|
8328
|
+
function kageHookUninstall(projectDir) {
|
|
8329
|
+
const hookPath = gitHookPath(projectDir);
|
|
8330
|
+
if (!hookPath) {
|
|
8331
|
+
return {
|
|
8332
|
+
ok: false,
|
|
8333
|
+
action: "uninstall",
|
|
8334
|
+
project_dir: projectDir,
|
|
8335
|
+
hook_path: null,
|
|
8336
|
+
installed: false,
|
|
8337
|
+
changed: false,
|
|
8338
|
+
message: "Not a git repository or git is unavailable.",
|
|
8339
|
+
errors: ["Not a git repository or git is unavailable."],
|
|
8340
|
+
warnings: [],
|
|
8341
|
+
};
|
|
8342
|
+
}
|
|
8343
|
+
const existing = safeReadText(hookPath) ?? "";
|
|
8344
|
+
const installed = hasKageHookBlock(existing);
|
|
8345
|
+
if (!installed) {
|
|
8346
|
+
return {
|
|
8347
|
+
ok: true,
|
|
8348
|
+
action: "uninstall",
|
|
8349
|
+
project_dir: projectDir,
|
|
8350
|
+
hook_path: hookPath,
|
|
8351
|
+
installed: false,
|
|
8352
|
+
changed: false,
|
|
8353
|
+
message: "Kage post-commit hook was not installed.",
|
|
8354
|
+
errors: [],
|
|
8355
|
+
warnings: (0, node_fs_1.existsSync)(hookPath) ? [] : ["No post-commit hook file exists."],
|
|
8356
|
+
};
|
|
8357
|
+
}
|
|
8358
|
+
const next = stripKageHookBlock(existing);
|
|
8359
|
+
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
8360
|
+
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
8361
|
+
return {
|
|
8362
|
+
ok: true,
|
|
8363
|
+
action: "uninstall",
|
|
8364
|
+
project_dir: projectDir,
|
|
8365
|
+
hook_path: hookPath,
|
|
8366
|
+
installed: false,
|
|
8367
|
+
changed: true,
|
|
8368
|
+
message: "Removed Kage post-commit hook.",
|
|
8369
|
+
errors: [],
|
|
8370
|
+
warnings: [],
|
|
8371
|
+
};
|
|
8372
|
+
}
|
|
6709
8373
|
function exportPublicBundle(projectDir) {
|
|
6710
8374
|
ensureMemoryDirs(projectDir);
|
|
6711
8375
|
const candidates = loadPacketsFromDir(publicCandidatesDir(projectDir));
|