@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/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), `${slugify(rel)}-${hash}.json`);
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 `${rel}\0${hash}`;
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 (!TS_AST_EXTENSIONS.has(extensionOf(file.path)))
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
- calls.push(...extractCalls(rel, content, fileSymbols, symbolByName).slice(0, Math.max(0, MAX_CODE_GRAPH_CALLS - calls.length)));
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));