gitnexus 1.4.1 → 1.4.6

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.
Files changed (169) hide show
  1. package/README.md +215 -194
  2. package/dist/cli/ai-context.d.ts +2 -1
  3. package/dist/cli/ai-context.js +117 -90
  4. package/dist/cli/analyze.d.ts +2 -0
  5. package/dist/cli/analyze.js +57 -30
  6. package/dist/cli/augment.js +1 -1
  7. package/dist/cli/eval-server.d.ts +1 -1
  8. package/dist/cli/eval-server.js +14 -6
  9. package/dist/cli/index.js +18 -25
  10. package/dist/cli/lazy-action.d.ts +6 -0
  11. package/dist/cli/lazy-action.js +18 -0
  12. package/dist/cli/mcp.js +1 -1
  13. package/dist/cli/setup.js +42 -32
  14. package/dist/cli/skill-gen.d.ts +26 -0
  15. package/dist/cli/skill-gen.js +549 -0
  16. package/dist/cli/status.js +13 -4
  17. package/dist/cli/tool.d.ts +3 -2
  18. package/dist/cli/tool.js +48 -13
  19. package/dist/cli/wiki.js +2 -2
  20. package/dist/config/ignore-service.d.ts +25 -0
  21. package/dist/config/ignore-service.js +76 -0
  22. package/dist/config/supported-languages.d.ts +1 -0
  23. package/dist/config/supported-languages.js +1 -1
  24. package/dist/core/augmentation/engine.js +99 -72
  25. package/dist/core/embeddings/embedder.d.ts +1 -1
  26. package/dist/core/embeddings/embedder.js +1 -1
  27. package/dist/core/embeddings/embedding-pipeline.d.ts +3 -3
  28. package/dist/core/embeddings/embedding-pipeline.js +74 -47
  29. package/dist/core/embeddings/types.d.ts +1 -1
  30. package/dist/core/graph/types.d.ts +5 -2
  31. package/dist/core/ingestion/ast-cache.js +3 -2
  32. package/dist/core/ingestion/call-processor.d.ts +5 -7
  33. package/dist/core/ingestion/call-processor.js +430 -283
  34. package/dist/core/ingestion/call-routing.d.ts +53 -0
  35. package/dist/core/ingestion/call-routing.js +108 -0
  36. package/dist/core/ingestion/cluster-enricher.js +16 -16
  37. package/dist/core/ingestion/constants.d.ts +16 -0
  38. package/dist/core/ingestion/constants.js +16 -0
  39. package/dist/core/ingestion/entry-point-scoring.d.ts +2 -1
  40. package/dist/core/ingestion/entry-point-scoring.js +94 -24
  41. package/dist/core/ingestion/export-detection.d.ts +18 -0
  42. package/dist/core/ingestion/export-detection.js +231 -0
  43. package/dist/core/ingestion/filesystem-walker.js +4 -3
  44. package/dist/core/ingestion/framework-detection.d.ts +5 -1
  45. package/dist/core/ingestion/framework-detection.js +48 -8
  46. package/dist/core/ingestion/heritage-processor.d.ts +13 -5
  47. package/dist/core/ingestion/heritage-processor.js +109 -55
  48. package/dist/core/ingestion/import-processor.d.ts +16 -20
  49. package/dist/core/ingestion/import-processor.js +202 -696
  50. package/dist/core/ingestion/language-config.d.ts +46 -0
  51. package/dist/core/ingestion/language-config.js +167 -0
  52. package/dist/core/ingestion/mro-processor.d.ts +45 -0
  53. package/dist/core/ingestion/mro-processor.js +369 -0
  54. package/dist/core/ingestion/named-binding-extraction.d.ts +61 -0
  55. package/dist/core/ingestion/named-binding-extraction.js +363 -0
  56. package/dist/core/ingestion/parsing-processor.d.ts +3 -11
  57. package/dist/core/ingestion/parsing-processor.js +85 -181
  58. package/dist/core/ingestion/pipeline.d.ts +5 -1
  59. package/dist/core/ingestion/pipeline.js +192 -116
  60. package/dist/core/ingestion/process-processor.js +2 -1
  61. package/dist/core/ingestion/resolution-context.d.ts +53 -0
  62. package/dist/core/ingestion/resolution-context.js +132 -0
  63. package/dist/core/ingestion/resolvers/csharp.d.ts +22 -0
  64. package/dist/core/ingestion/resolvers/csharp.js +109 -0
  65. package/dist/core/ingestion/resolvers/go.d.ts +19 -0
  66. package/dist/core/ingestion/resolvers/go.js +42 -0
  67. package/dist/core/ingestion/resolvers/index.d.ts +18 -0
  68. package/dist/core/ingestion/resolvers/index.js +13 -0
  69. package/dist/core/ingestion/resolvers/jvm.d.ts +23 -0
  70. package/dist/core/ingestion/resolvers/jvm.js +87 -0
  71. package/dist/core/ingestion/resolvers/php.d.ts +15 -0
  72. package/dist/core/ingestion/resolvers/php.js +35 -0
  73. package/dist/core/ingestion/resolvers/python.d.ts +19 -0
  74. package/dist/core/ingestion/resolvers/python.js +52 -0
  75. package/dist/core/ingestion/resolvers/ruby.d.ts +12 -0
  76. package/dist/core/ingestion/resolvers/ruby.js +15 -0
  77. package/dist/core/ingestion/resolvers/rust.d.ts +15 -0
  78. package/dist/core/ingestion/resolvers/rust.js +73 -0
  79. package/dist/core/ingestion/resolvers/standard.d.ts +28 -0
  80. package/dist/core/ingestion/resolvers/standard.js +123 -0
  81. package/dist/core/ingestion/resolvers/utils.d.ts +33 -0
  82. package/dist/core/ingestion/resolvers/utils.js +122 -0
  83. package/dist/core/ingestion/symbol-table.d.ts +21 -1
  84. package/dist/core/ingestion/symbol-table.js +40 -12
  85. package/dist/core/ingestion/tree-sitter-queries.d.ts +12 -11
  86. package/dist/core/ingestion/tree-sitter-queries.js +642 -485
  87. package/dist/core/ingestion/type-env.d.ts +49 -0
  88. package/dist/core/ingestion/type-env.js +611 -0
  89. package/dist/core/ingestion/type-extractors/c-cpp.d.ts +2 -0
  90. package/dist/core/ingestion/type-extractors/c-cpp.js +385 -0
  91. package/dist/core/ingestion/type-extractors/csharp.d.ts +2 -0
  92. package/dist/core/ingestion/type-extractors/csharp.js +383 -0
  93. package/dist/core/ingestion/type-extractors/go.d.ts +2 -0
  94. package/dist/core/ingestion/type-extractors/go.js +467 -0
  95. package/dist/core/ingestion/type-extractors/index.d.ts +22 -0
  96. package/dist/core/ingestion/type-extractors/index.js +31 -0
  97. package/dist/core/ingestion/type-extractors/jvm.d.ts +3 -0
  98. package/dist/core/ingestion/type-extractors/jvm.js +681 -0
  99. package/dist/core/ingestion/type-extractors/php.d.ts +2 -0
  100. package/dist/core/ingestion/type-extractors/php.js +549 -0
  101. package/dist/core/ingestion/type-extractors/python.d.ts +2 -0
  102. package/dist/core/ingestion/type-extractors/python.js +406 -0
  103. package/dist/core/ingestion/type-extractors/ruby.d.ts +2 -0
  104. package/dist/core/ingestion/type-extractors/ruby.js +389 -0
  105. package/dist/core/ingestion/type-extractors/rust.d.ts +2 -0
  106. package/dist/core/ingestion/type-extractors/rust.js +449 -0
  107. package/dist/core/ingestion/type-extractors/shared.d.ts +133 -0
  108. package/dist/core/ingestion/type-extractors/shared.js +703 -0
  109. package/dist/core/ingestion/type-extractors/swift.d.ts +2 -0
  110. package/dist/core/ingestion/type-extractors/swift.js +137 -0
  111. package/dist/core/ingestion/type-extractors/types.d.ts +127 -0
  112. package/dist/core/ingestion/type-extractors/types.js +1 -0
  113. package/dist/core/ingestion/type-extractors/typescript.d.ts +2 -0
  114. package/dist/core/ingestion/type-extractors/typescript.js +494 -0
  115. package/dist/core/ingestion/utils.d.ts +98 -0
  116. package/dist/core/ingestion/utils.js +1064 -9
  117. package/dist/core/ingestion/workers/parse-worker.d.ts +38 -4
  118. package/dist/core/ingestion/workers/parse-worker.js +251 -359
  119. package/dist/core/ingestion/workers/worker-pool.js +8 -0
  120. package/dist/core/{kuzu → lbug}/csv-generator.d.ts +1 -1
  121. package/dist/core/{kuzu → lbug}/csv-generator.js +20 -4
  122. package/dist/core/{kuzu/kuzu-adapter.d.ts → lbug/lbug-adapter.d.ts} +19 -19
  123. package/dist/core/{kuzu/kuzu-adapter.js → lbug/lbug-adapter.js} +82 -82
  124. package/dist/core/{kuzu → lbug}/schema.d.ts +4 -4
  125. package/dist/core/{kuzu → lbug}/schema.js +304 -289
  126. package/dist/core/search/bm25-index.d.ts +4 -4
  127. package/dist/core/search/bm25-index.js +17 -16
  128. package/dist/core/search/hybrid-search.d.ts +2 -2
  129. package/dist/core/search/hybrid-search.js +9 -9
  130. package/dist/core/tree-sitter/parser-loader.js +9 -2
  131. package/dist/core/wiki/generator.d.ts +4 -52
  132. package/dist/core/wiki/generator.js +53 -552
  133. package/dist/core/wiki/graph-queries.d.ts +4 -46
  134. package/dist/core/wiki/graph-queries.js +103 -282
  135. package/dist/core/wiki/html-viewer.js +192 -192
  136. package/dist/core/wiki/llm-client.js +11 -73
  137. package/dist/core/wiki/prompts.d.ts +8 -52
  138. package/dist/core/wiki/prompts.js +86 -200
  139. package/dist/mcp/compatible-stdio-transport.d.ts +25 -0
  140. package/dist/mcp/compatible-stdio-transport.js +200 -0
  141. package/dist/mcp/core/{kuzu-adapter.d.ts → lbug-adapter.d.ts} +7 -9
  142. package/dist/mcp/core/{kuzu-adapter.js → lbug-adapter.js} +77 -79
  143. package/dist/mcp/local/local-backend.d.ts +7 -6
  144. package/dist/mcp/local/local-backend.js +176 -147
  145. package/dist/mcp/resources.js +42 -42
  146. package/dist/mcp/server.js +18 -19
  147. package/dist/mcp/tools.js +103 -104
  148. package/dist/server/api.js +12 -12
  149. package/dist/server/mcp-http.d.ts +1 -1
  150. package/dist/server/mcp-http.js +1 -1
  151. package/dist/storage/repo-manager.d.ts +20 -2
  152. package/dist/storage/repo-manager.js +55 -1
  153. package/dist/types/pipeline.d.ts +1 -1
  154. package/hooks/claude/gitnexus-hook.cjs +238 -155
  155. package/hooks/claude/pre-tool-use.sh +79 -79
  156. package/hooks/claude/session-start.sh +42 -42
  157. package/package.json +99 -96
  158. package/scripts/patch-tree-sitter-swift.cjs +74 -74
  159. package/skills/gitnexus-cli.md +82 -82
  160. package/skills/gitnexus-debugging.md +89 -89
  161. package/skills/gitnexus-exploring.md +78 -78
  162. package/skills/gitnexus-guide.md +64 -64
  163. package/skills/gitnexus-impact-analysis.md +97 -97
  164. package/skills/gitnexus-pr-review.md +163 -163
  165. package/skills/gitnexus-refactoring.md +121 -121
  166. package/vendor/leiden/index.cjs +355 -355
  167. package/vendor/leiden/utils.cjs +392 -392
  168. package/dist/core/wiki/diagrams.d.ts +0 -27
  169. package/dist/core/wiki/diagrams.js +0 -163
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Skill File Generator
3
+ *
4
+ * Generates repo-specific SKILL.md files from detected Leiden communities.
5
+ * Each significant community becomes a skill that describes a functional area
6
+ * of the codebase, including key files, entry points, execution flows, and
7
+ * cross-community connections.
8
+ */
9
+ import fs from 'fs/promises';
10
+ import path from 'path';
11
+ // ============================================================================
12
+ // MAIN EXPORT
13
+ // ============================================================================
14
+ /**
15
+ * @brief Generate repo-specific skill files from detected communities
16
+ * @param {string} repoPath - Absolute path to the repository root
17
+ * @param {string} projectName - Human-readable project name
18
+ * @param {PipelineResult} pipelineResult - In-memory pipeline data with communities, processes, graph
19
+ * @returns {Promise<{ skills: GeneratedSkillInfo[], outputPath: string }>} Generated skill metadata
20
+ */
21
+ export const generateSkillFiles = async (repoPath, projectName, pipelineResult) => {
22
+ const { communityResult, processResult, graph } = pipelineResult;
23
+ const outputDir = path.join(repoPath, '.claude', 'skills', 'generated');
24
+ if (!communityResult || !communityResult.memberships.length) {
25
+ console.log('\n Skills: no communities detected, skipping skill generation');
26
+ return { skills: [], outputPath: outputDir };
27
+ }
28
+ console.log('\n Generating repo-specific skills...');
29
+ // Step 1: Build communities from memberships (not the filtered communities array).
30
+ // The community processor skips singletons from its communities array but memberships
31
+ // include ALL assignments. For repos with sparse CALLS edges, the communities array
32
+ // can be empty while memberships still has useful groupings.
33
+ const communities = communityResult.communities.length > 0
34
+ ? communityResult.communities
35
+ : buildCommunitiesFromMemberships(communityResult.memberships, graph, repoPath);
36
+ const aggregated = aggregateCommunities(communities);
37
+ // Step 2: Filter to significant communities
38
+ // Keep communities with >= 3 symbols after aggregation.
39
+ const significant = aggregated
40
+ .filter(c => c.symbolCount >= 3)
41
+ .sort((a, b) => b.symbolCount - a.symbolCount)
42
+ .slice(0, 20);
43
+ if (significant.length === 0) {
44
+ console.log('\n Skills: no significant communities found (all below 3-symbol threshold)');
45
+ return { skills: [], outputPath: outputDir };
46
+ }
47
+ // Step 3: Build lookup maps
48
+ const membershipsByComm = buildMembershipMap(communityResult.memberships);
49
+ const nodeIdToCommunityLabel = buildNodeCommunityLabelMap(communityResult.memberships, communities);
50
+ // Step 4: Clear and recreate output directory
51
+ try {
52
+ await fs.rm(outputDir, { recursive: true, force: true });
53
+ }
54
+ catch { /* may not exist */ }
55
+ await fs.mkdir(outputDir, { recursive: true });
56
+ // Step 5: Generate skill files
57
+ const skills = [];
58
+ const usedNames = new Set();
59
+ for (const community of significant) {
60
+ // Gather member symbols
61
+ const members = gatherMembers(community.rawIds, membershipsByComm, graph);
62
+ if (members.length === 0)
63
+ continue;
64
+ // Gather file info
65
+ const files = gatherFiles(members, repoPath);
66
+ // Gather entry points
67
+ const entryPoints = gatherEntryPoints(members);
68
+ // Gather execution flows
69
+ const flows = gatherFlows(community.rawIds, processResult?.processes || []);
70
+ // Gather cross-community connections
71
+ const connections = gatherCrossConnections(community.rawIds, community.label, membershipsByComm, nodeIdToCommunityLabel, graph);
72
+ // Generate kebab name
73
+ const kebabName = toKebabName(community.label, usedNames);
74
+ usedNames.add(kebabName);
75
+ // Generate SKILL.md content
76
+ const content = renderSkillMarkdown(community, projectName, members, files, entryPoints, flows, connections, kebabName);
77
+ // Write file
78
+ const skillDir = path.join(outputDir, kebabName);
79
+ await fs.mkdir(skillDir, { recursive: true });
80
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
81
+ const info = {
82
+ name: kebabName,
83
+ label: community.label,
84
+ symbolCount: community.symbolCount,
85
+ fileCount: files.length,
86
+ };
87
+ skills.push(info);
88
+ console.log(` \u2713 ${community.label} (${community.symbolCount} symbols, ${files.length} files)`);
89
+ }
90
+ console.log(`\n ${skills.length} skills generated \u2192 .claude/skills/generated/`);
91
+ return { skills, outputPath: outputDir };
92
+ };
93
+ // ============================================================================
94
+ // FALLBACK COMMUNITY BUILDER
95
+ // ============================================================================
96
+ /**
97
+ * @brief Build CommunityNode-like objects from raw memberships when the community
98
+ * processor's communities array is empty (all singletons were filtered out)
99
+ * @param {CommunityMembership[]} memberships - All node-to-community assignments
100
+ * @param {KnowledgeGraph} graph - The knowledge graph for resolving node metadata
101
+ * @param {string} repoPath - Repository root for path normalization
102
+ * @returns {CommunityNode[]} Synthetic community nodes built from membership data
103
+ */
104
+ const buildCommunitiesFromMemberships = (memberships, graph, repoPath) => {
105
+ // Group memberships by communityId
106
+ const groups = new Map();
107
+ for (const m of memberships) {
108
+ const arr = groups.get(m.communityId);
109
+ if (arr) {
110
+ arr.push(m.nodeId);
111
+ }
112
+ else {
113
+ groups.set(m.communityId, [m.nodeId]);
114
+ }
115
+ }
116
+ const communities = [];
117
+ for (const [commId, nodeIds] of groups) {
118
+ // Derive a heuristic label from the most common parent directory
119
+ const folderCounts = new Map();
120
+ for (const nodeId of nodeIds) {
121
+ const node = graph.getNode(nodeId);
122
+ if (!node?.properties.filePath)
123
+ continue;
124
+ const normalized = node.properties.filePath.replace(/\\/g, '/');
125
+ const parts = normalized.split('/').filter(Boolean);
126
+ if (parts.length >= 2) {
127
+ const folder = parts[parts.length - 2];
128
+ if (!['src', 'lib', 'core', 'utils', 'common', 'shared', 'helpers'].includes(folder.toLowerCase())) {
129
+ folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1);
130
+ }
131
+ }
132
+ }
133
+ let bestFolder = '';
134
+ let bestCount = 0;
135
+ for (const [folder, count] of folderCounts) {
136
+ if (count > bestCount) {
137
+ bestCount = count;
138
+ bestFolder = folder;
139
+ }
140
+ }
141
+ const label = bestFolder
142
+ ? bestFolder.charAt(0).toUpperCase() + bestFolder.slice(1)
143
+ : `Cluster_${commId.replace('comm_', '')}`;
144
+ // Compute cohesion as internal-edge ratio (matches backend calculateCohesion).
145
+ // For each member node, count edges that stay inside the community vs total.
146
+ const nodeSet = new Set(nodeIds);
147
+ let internalEdges = 0;
148
+ let totalEdges = 0;
149
+ graph.forEachRelationship(rel => {
150
+ if (nodeSet.has(rel.sourceId)) {
151
+ totalEdges++;
152
+ if (nodeSet.has(rel.targetId))
153
+ internalEdges++;
154
+ }
155
+ });
156
+ const cohesion = totalEdges > 0 ? Math.min(1.0, internalEdges / totalEdges) : 1.0;
157
+ communities.push({
158
+ id: commId,
159
+ label,
160
+ heuristicLabel: label,
161
+ cohesion,
162
+ symbolCount: nodeIds.length,
163
+ });
164
+ }
165
+ return communities.sort((a, b) => b.symbolCount - a.symbolCount);
166
+ };
167
+ // ============================================================================
168
+ // AGGREGATION
169
+ // ============================================================================
170
+ /**
171
+ * @brief Aggregate raw Leiden communities by heuristicLabel
172
+ * @param {CommunityNode[]} communities - Raw community nodes from Leiden detection
173
+ * @returns {AggregatedCommunity[]} Aggregated communities grouped by label
174
+ */
175
+ const aggregateCommunities = (communities) => {
176
+ const groups = new Map();
177
+ for (const c of communities) {
178
+ const label = c.heuristicLabel || c.label || 'Unknown';
179
+ const symbols = c.symbolCount || 0;
180
+ const cohesion = c.cohesion || 0;
181
+ const existing = groups.get(label);
182
+ if (!existing) {
183
+ groups.set(label, {
184
+ rawIds: [c.id],
185
+ totalSymbols: symbols,
186
+ weightedCohesion: cohesion * symbols,
187
+ });
188
+ }
189
+ else {
190
+ existing.rawIds.push(c.id);
191
+ existing.totalSymbols += symbols;
192
+ existing.weightedCohesion += cohesion * symbols;
193
+ }
194
+ }
195
+ return Array.from(groups.entries()).map(([label, g]) => ({
196
+ label,
197
+ rawIds: g.rawIds,
198
+ symbolCount: g.totalSymbols,
199
+ cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0,
200
+ }));
201
+ };
202
+ // ============================================================================
203
+ // LOOKUP MAP BUILDERS
204
+ // ============================================================================
205
+ /**
206
+ * @brief Build a map from communityId to member nodeIds
207
+ * @param {CommunityMembership[]} memberships - All membership records
208
+ * @returns {Map<string, string[]>} Map of communityId -> nodeId[]
209
+ */
210
+ const buildMembershipMap = (memberships) => {
211
+ const map = new Map();
212
+ for (const m of memberships) {
213
+ const arr = map.get(m.communityId);
214
+ if (arr) {
215
+ arr.push(m.nodeId);
216
+ }
217
+ else {
218
+ map.set(m.communityId, [m.nodeId]);
219
+ }
220
+ }
221
+ return map;
222
+ };
223
+ /**
224
+ * @brief Build a map from nodeId to aggregated community label
225
+ * @param {CommunityMembership[]} memberships - All membership records
226
+ * @param {CommunityNode[]} communities - Community nodes with labels
227
+ * @returns {Map<string, string>} Map of nodeId -> community label
228
+ */
229
+ const buildNodeCommunityLabelMap = (memberships, communities) => {
230
+ const commIdToLabel = new Map();
231
+ for (const c of communities) {
232
+ commIdToLabel.set(c.id, c.heuristicLabel || c.label || 'Unknown');
233
+ }
234
+ const map = new Map();
235
+ for (const m of memberships) {
236
+ const label = commIdToLabel.get(m.communityId);
237
+ if (label) {
238
+ map.set(m.nodeId, label);
239
+ }
240
+ }
241
+ return map;
242
+ };
243
+ // ============================================================================
244
+ // DATA GATHERING
245
+ // ============================================================================
246
+ /**
247
+ * @brief Gather member symbols for an aggregated community
248
+ * @param {string[]} rawIds - Raw community IDs belonging to this aggregated community
249
+ * @param {Map<string, string[]>} membershipsByComm - communityId -> nodeIds
250
+ * @param {KnowledgeGraph} graph - The knowledge graph
251
+ * @returns {MemberSymbol[]} Array of member symbol information
252
+ */
253
+ const gatherMembers = (rawIds, membershipsByComm, graph) => {
254
+ const seen = new Set();
255
+ const members = [];
256
+ for (const commId of rawIds) {
257
+ const nodeIds = membershipsByComm.get(commId) || [];
258
+ for (const nodeId of nodeIds) {
259
+ if (seen.has(nodeId))
260
+ continue;
261
+ seen.add(nodeId);
262
+ const node = graph.getNode(nodeId);
263
+ if (!node)
264
+ continue;
265
+ members.push({
266
+ id: node.id,
267
+ name: node.properties.name,
268
+ label: node.label,
269
+ filePath: node.properties.filePath || '',
270
+ startLine: node.properties.startLine || 0,
271
+ isExported: node.properties.isExported === true,
272
+ });
273
+ }
274
+ }
275
+ return members;
276
+ };
277
+ /**
278
+ * @brief Gather deduplicated file info with per-file symbol names
279
+ * @param {MemberSymbol[]} members - Member symbols
280
+ * @param {string} repoPath - Repository root for relative path computation
281
+ * @returns {FileInfo[]} Sorted by symbol count descending
282
+ */
283
+ const gatherFiles = (members, repoPath) => {
284
+ const fileMap = new Map();
285
+ for (const m of members) {
286
+ if (!m.filePath)
287
+ continue;
288
+ const rel = toRelativePath(m.filePath, repoPath);
289
+ const arr = fileMap.get(rel);
290
+ if (arr) {
291
+ arr.push(m.name);
292
+ }
293
+ else {
294
+ fileMap.set(rel, [m.name]);
295
+ }
296
+ }
297
+ return Array.from(fileMap.entries())
298
+ .map(([relativePath, symbols]) => ({ relativePath, symbols }))
299
+ .sort((a, b) => b.symbols.length - a.symbols.length);
300
+ };
301
+ /**
302
+ * @brief Gather exported entry points prioritized by type
303
+ * @param {MemberSymbol[]} members - Member symbols
304
+ * @returns {MemberSymbol[]} Exported symbols sorted by type priority
305
+ */
306
+ const gatherEntryPoints = (members) => {
307
+ const typePriority = {
308
+ Function: 0,
309
+ Class: 1,
310
+ Method: 2,
311
+ Interface: 3,
312
+ };
313
+ return members
314
+ .filter(m => m.isExported)
315
+ .sort((a, b) => {
316
+ const pa = typePriority[a.label] ?? 99;
317
+ const pb = typePriority[b.label] ?? 99;
318
+ return pa - pb;
319
+ });
320
+ };
321
+ /**
322
+ * @brief Gather execution flows touching this community
323
+ * @param {string[]} rawIds - Raw community IDs for this aggregated community
324
+ * @param {ProcessNode[]} processes - All detected processes
325
+ * @returns {ProcessNode[]} Processes whose communities intersect rawIds, sorted by stepCount
326
+ */
327
+ const gatherFlows = (rawIds, processes) => {
328
+ const rawIdSet = new Set(rawIds);
329
+ return processes
330
+ .filter(proc => proc.communities.some(cid => rawIdSet.has(cid)))
331
+ .sort((a, b) => b.stepCount - a.stepCount);
332
+ };
333
+ /**
334
+ * @brief Gather cross-community call connections
335
+ * @param {string[]} rawIds - Raw community IDs for this aggregated community
336
+ * @param {string} ownLabel - This community's aggregated label
337
+ * @param {Map<string, string[]>} membershipsByComm - communityId -> nodeIds
338
+ * @param {Map<string, string>} nodeIdToCommunityLabel - nodeId -> community label
339
+ * @param {KnowledgeGraph} graph - The knowledge graph
340
+ * @returns {CrossConnection[]} Aggregated cross-community connections sorted by count
341
+ */
342
+ const gatherCrossConnections = (rawIds, ownLabel, membershipsByComm, nodeIdToCommunityLabel, graph) => {
343
+ // Collect all node IDs in this aggregated community
344
+ const ownNodeIds = new Set();
345
+ for (const commId of rawIds) {
346
+ const nodeIds = membershipsByComm.get(commId) || [];
347
+ for (const nid of nodeIds) {
348
+ ownNodeIds.add(nid);
349
+ }
350
+ }
351
+ // Count outgoing CALLS to nodes in different communities
352
+ const targetCounts = new Map();
353
+ graph.forEachRelationship(rel => {
354
+ if (rel.type !== 'CALLS')
355
+ return;
356
+ if (!ownNodeIds.has(rel.sourceId))
357
+ return;
358
+ if (ownNodeIds.has(rel.targetId))
359
+ return; // same community
360
+ const targetLabel = nodeIdToCommunityLabel.get(rel.targetId);
361
+ if (!targetLabel || targetLabel === ownLabel)
362
+ return;
363
+ targetCounts.set(targetLabel, (targetCounts.get(targetLabel) || 0) + 1);
364
+ });
365
+ return Array.from(targetCounts.entries())
366
+ .map(([targetLabel, count]) => ({ targetLabel, count }))
367
+ .sort((a, b) => b.count - a.count);
368
+ };
369
+ // ============================================================================
370
+ // MARKDOWN RENDERING
371
+ // ============================================================================
372
+ /**
373
+ * @brief Render SKILL.md content for a single community
374
+ * @param {AggregatedCommunity} community - The aggregated community data
375
+ * @param {string} projectName - Project name for the description
376
+ * @param {MemberSymbol[]} members - All member symbols
377
+ * @param {FileInfo[]} files - File info with symbol names
378
+ * @param {MemberSymbol[]} entryPoints - Exported entry point symbols
379
+ * @param {ProcessNode[]} flows - Execution flows touching this community
380
+ * @param {CrossConnection[]} connections - Cross-community connections
381
+ * @param {string} kebabName - Kebab-case name for the skill
382
+ * @returns {string} Full SKILL.md content
383
+ */
384
+ const renderSkillMarkdown = (community, projectName, members, files, entryPoints, flows, connections, kebabName) => {
385
+ const cohesionPct = Math.round(community.cohesion * 100);
386
+ // Dominant directory: most common top-level directory
387
+ const dominantDir = getDominantDirectory(files);
388
+ // Top symbol names for "When to Use"
389
+ const topNames = entryPoints.slice(0, 3).map(e => e.name);
390
+ if (topNames.length === 0) {
391
+ // Fallback to any members
392
+ topNames.push(...members.slice(0, 3).map(m => m.name));
393
+ }
394
+ const lines = [];
395
+ // Frontmatter
396
+ lines.push('---');
397
+ lines.push(`name: ${kebabName}`);
398
+ lines.push(`description: "Skill for the ${community.label} area of ${projectName}. ${community.symbolCount} symbols across ${files.length} files."`);
399
+ lines.push('---');
400
+ lines.push('');
401
+ // Title
402
+ lines.push(`# ${community.label}`);
403
+ lines.push('');
404
+ lines.push(`${community.symbolCount} symbols | ${files.length} files | Cohesion: ${cohesionPct}%`);
405
+ lines.push('');
406
+ // When to Use
407
+ lines.push('## When to Use');
408
+ lines.push('');
409
+ if (dominantDir) {
410
+ lines.push(`- Working with code in \`${dominantDir}/\``);
411
+ }
412
+ if (topNames.length > 0) {
413
+ lines.push(`- Understanding how ${topNames.join(', ')} work`);
414
+ }
415
+ lines.push(`- Modifying ${community.label.toLowerCase()}-related functionality`);
416
+ lines.push('');
417
+ // Key Files (top 10)
418
+ lines.push('## Key Files');
419
+ lines.push('');
420
+ lines.push('| File | Symbols |');
421
+ lines.push('|------|---------|');
422
+ for (const f of files.slice(0, 10)) {
423
+ const symbolList = f.symbols.slice(0, 5).join(', ');
424
+ const suffix = f.symbols.length > 5 ? ` (+${f.symbols.length - 5})` : '';
425
+ lines.push(`| \`${f.relativePath}\` | ${symbolList}${suffix} |`);
426
+ }
427
+ lines.push('');
428
+ // Entry Points (top 5)
429
+ if (entryPoints.length > 0) {
430
+ lines.push('## Entry Points');
431
+ lines.push('');
432
+ lines.push('Start here when exploring this area:');
433
+ lines.push('');
434
+ for (const ep of entryPoints.slice(0, 5)) {
435
+ lines.push(`- **\`${ep.name}\`** (${ep.label}) \u2014 \`${ep.filePath}:${ep.startLine}\``);
436
+ }
437
+ lines.push('');
438
+ }
439
+ // Key Symbols (top 20, exported first, then by type)
440
+ lines.push('## Key Symbols');
441
+ lines.push('');
442
+ lines.push('| Symbol | Type | File | Line |');
443
+ lines.push('|--------|------|------|------|');
444
+ const sortedMembers = [...members].sort((a, b) => {
445
+ if (a.isExported !== b.isExported)
446
+ return a.isExported ? -1 : 1;
447
+ return a.label.localeCompare(b.label);
448
+ });
449
+ for (const m of sortedMembers.slice(0, 20)) {
450
+ lines.push(`| \`${m.name}\` | ${m.label} | \`${m.filePath}\` | ${m.startLine} |`);
451
+ }
452
+ lines.push('');
453
+ // Execution Flows
454
+ if (flows.length > 0) {
455
+ lines.push('## Execution Flows');
456
+ lines.push('');
457
+ lines.push('| Flow | Type | Steps |');
458
+ lines.push('|------|------|-------|');
459
+ for (const f of flows.slice(0, 10)) {
460
+ lines.push(`| \`${f.heuristicLabel}\` | ${f.processType} | ${f.stepCount} |`);
461
+ }
462
+ lines.push('');
463
+ }
464
+ // Connected Areas
465
+ if (connections.length > 0) {
466
+ lines.push('## Connected Areas');
467
+ lines.push('');
468
+ lines.push('| Area | Connections |');
469
+ lines.push('|------|-------------|');
470
+ for (const c of connections.slice(0, 8)) {
471
+ lines.push(`| ${c.targetLabel} | ${c.count} calls |`);
472
+ }
473
+ lines.push('');
474
+ }
475
+ // How to Explore
476
+ const firstEntry = entryPoints.length > 0 ? entryPoints[0].name : (members.length > 0 ? members[0].name : community.label);
477
+ lines.push('## How to Explore');
478
+ lines.push('');
479
+ lines.push(`1. \`gitnexus_context({name: "${firstEntry}"})\` \u2014 see callers and callees`);
480
+ lines.push(`2. \`gitnexus_query({query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`);
481
+ lines.push('3. Read key files listed above for implementation details');
482
+ lines.push('');
483
+ return lines.join('\n');
484
+ };
485
+ // ============================================================================
486
+ // UTILITY HELPERS
487
+ // ============================================================================
488
+ /**
489
+ * @brief Convert a community label to a kebab-case directory name
490
+ * @param {string} label - The community label
491
+ * @param {Set<string>} usedNames - Already-used names for collision detection
492
+ * @returns {string} Unique kebab-case name capped at 50 characters
493
+ */
494
+ const toKebabName = (label, usedNames) => {
495
+ let name = label
496
+ .toLowerCase()
497
+ .replace(/[^a-z0-9]+/g, '-')
498
+ .replace(/^-+|-+$/g, '')
499
+ .slice(0, 50);
500
+ if (!name)
501
+ name = 'skill';
502
+ let candidate = name;
503
+ let counter = 2;
504
+ while (usedNames.has(candidate)) {
505
+ candidate = `${name}-${counter}`;
506
+ counter++;
507
+ }
508
+ return candidate;
509
+ };
510
+ /**
511
+ * @brief Convert an absolute or repo-relative file path to a clean relative path
512
+ * @param {string} filePath - The file path from the graph node
513
+ * @param {string} repoPath - Repository root path
514
+ * @returns {string} Relative path using forward slashes
515
+ */
516
+ const toRelativePath = (filePath, repoPath) => {
517
+ // Normalize to forward slashes for cross-platform consistency
518
+ const normalizedFile = filePath.replace(/\\/g, '/');
519
+ const normalizedRepo = repoPath.replace(/\\/g, '/');
520
+ if (normalizedFile.startsWith(normalizedRepo)) {
521
+ return normalizedFile.slice(normalizedRepo.length).replace(/^\//, '');
522
+ }
523
+ // Already relative or different root
524
+ return normalizedFile.replace(/^\//, '');
525
+ };
526
+ /**
527
+ * @brief Find the dominant (most common) top-level directory across files
528
+ * @param {FileInfo[]} files - File info entries
529
+ * @returns {string | null} Most common directory or null
530
+ */
531
+ const getDominantDirectory = (files) => {
532
+ const dirCounts = new Map();
533
+ for (const f of files) {
534
+ const parts = f.relativePath.split('/');
535
+ if (parts.length >= 2) {
536
+ const dir = parts[0];
537
+ dirCounts.set(dir, (dirCounts.get(dir) || 0) + f.symbols.length);
538
+ }
539
+ }
540
+ let best = null;
541
+ let bestCount = 0;
542
+ for (const [dir, count] of dirCounts) {
543
+ if (count > bestCount) {
544
+ bestCount = count;
545
+ best = dir;
546
+ }
547
+ }
548
+ return best;
549
+ };
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Shows the indexing status of the current repository.
5
5
  */
6
- import { findRepo } from '../storage/repo-manager.js';
7
- import { getCurrentCommit, isGitRepo } from '../storage/git.js';
6
+ import { findRepo, getStoragePaths, hasKuzuIndex } from '../storage/repo-manager.js';
7
+ import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
8
8
  export const statusCommand = async () => {
9
9
  const cwd = process.cwd();
10
10
  if (!isGitRepo(cwd)) {
@@ -13,8 +13,17 @@ export const statusCommand = async () => {
13
13
  }
14
14
  const repo = await findRepo(cwd);
15
15
  if (!repo) {
16
- console.log('Repository not indexed.');
17
- console.log('Run: gitnexus analyze');
16
+ // Check if there's a stale KuzuDB index that needs migration
17
+ const repoRoot = getGitRoot(cwd) ?? cwd;
18
+ const { storagePath } = getStoragePaths(repoRoot);
19
+ if (await hasKuzuIndex(storagePath)) {
20
+ console.log('Repository has a stale KuzuDB index from a previous version.');
21
+ console.log('Run: gitnexus analyze (rebuilds the index with LadybugDB)');
22
+ }
23
+ else {
24
+ console.log('Repository not indexed.');
25
+ console.log('Run: gitnexus analyze');
26
+ }
18
27
  return;
19
28
  }
20
29
  const currentCommit = getCurrentCommit(repo.repoPath);
@@ -10,8 +10,9 @@
10
10
  * gitnexus impact --target "AuthService" --direction upstream
11
11
  * gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
12
12
  *
13
- * Note: Output goes to stderr because KuzuDB's native module captures stdout
14
- * at the OS level during init. This is consistent with augment.ts.
13
+ * Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
14
+ * native module which captures the Node.js process.stdout stream during init.
15
+ * See the output() function for details (#324).
15
16
  */
16
17
  export declare function queryCommand(queryText: string, options?: {
17
18
  repo?: string;
package/dist/cli/tool.js CHANGED
@@ -10,9 +10,11 @@
10
10
  * gitnexus impact --target "AuthService" --direction upstream
11
11
  * gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
12
12
  *
13
- * Note: Output goes to stderr because KuzuDB's native module captures stdout
14
- * at the OS level during init. This is consistent with augment.ts.
13
+ * Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
14
+ * native module which captures the Node.js process.stdout stream during init.
15
+ * See the output() function for details (#324).
15
16
  */
17
+ import { writeSync } from 'node:fs';
16
18
  import { LocalBackend } from '../mcp/local/local-backend.js';
17
19
  let _backend = null;
18
20
  async function getBackend() {
@@ -26,10 +28,30 @@ async function getBackend() {
26
28
  }
27
29
  return _backend;
28
30
  }
31
+ /**
32
+ * Write tool output to stdout using low-level fd write.
33
+ *
34
+ * LadybugDB's native module captures Node.js process.stdout during init,
35
+ * but the underlying OS file descriptor 1 (stdout) remains intact.
36
+ * By using fs.writeSync(1, ...) we bypass the Node.js stream layer
37
+ * and write directly to the real stdout fd (#324).
38
+ *
39
+ * Falls back to stderr if the fd write fails (e.g., broken pipe).
40
+ */
29
41
  function output(data) {
30
42
  const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
31
- // stderr because KuzuDB captures stdout at OS level
32
- process.stderr.write(text + '\n');
43
+ try {
44
+ writeSync(1, text + '\n');
45
+ }
46
+ catch (err) {
47
+ if (err?.code === 'EPIPE') {
48
+ // Consumer closed the pipe (e.g., `gitnexus cypher ... | head -1`)
49
+ // Exit cleanly per Unix convention
50
+ process.exit(0);
51
+ }
52
+ // Fallback: stderr (previous behavior, works on all platforms)
53
+ process.stderr.write(text + '\n');
54
+ }
33
55
  }
34
56
  export async function queryCommand(queryText, options) {
35
57
  if (!queryText?.trim()) {
@@ -67,15 +89,28 @@ export async function impactCommand(target, options) {
67
89
  console.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
68
90
  process.exit(1);
69
91
  }
70
- const backend = await getBackend();
71
- const result = await backend.callTool('impact', {
72
- target,
73
- direction: options?.direction || 'upstream',
74
- maxDepth: options?.depth ? parseInt(options.depth) : undefined,
75
- includeTests: options?.includeTests ?? false,
76
- repo: options?.repo,
77
- });
78
- output(result);
92
+ try {
93
+ const backend = await getBackend();
94
+ const result = await backend.callTool('impact', {
95
+ target,
96
+ direction: options?.direction || 'upstream',
97
+ maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
98
+ includeTests: options?.includeTests ?? false,
99
+ repo: options?.repo,
100
+ });
101
+ output(result);
102
+ }
103
+ catch (err) {
104
+ // Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
105
+ // The backend's impact() already returns structured errors for graph query failures
106
+ output({
107
+ error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed unexpectedly',
108
+ target: { name: target },
109
+ direction: options?.direction || 'upstream',
110
+ suggestion: 'Try reducing --depth or using gitnexus context <symbol> as a fallback',
111
+ });
112
+ process.exit(1);
113
+ }
79
114
  }
80
115
  export async function cypherCommand(query, options) {
81
116
  if (!query?.trim()) {