@kage-core/kage-graph-mcp 1.1.25 → 1.1.26

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