gitnexus 1.5.3 → 1.6.0

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 (201) hide show
  1. package/README.md +10 -0
  2. package/dist/_shared/graph/types.d.ts +1 -1
  3. package/dist/_shared/graph/types.d.ts.map +1 -1
  4. package/dist/_shared/index.d.ts +1 -0
  5. package/dist/_shared/index.d.ts.map +1 -1
  6. package/dist/_shared/language-detection.d.ts.map +1 -1
  7. package/dist/_shared/language-detection.js +2 -0
  8. package/dist/_shared/language-detection.js.map +1 -1
  9. package/dist/_shared/languages.d.ts +1 -0
  10. package/dist/_shared/languages.d.ts.map +1 -1
  11. package/dist/_shared/languages.js +1 -0
  12. package/dist/_shared/languages.js.map +1 -1
  13. package/dist/_shared/lbug/schema-constants.d.ts +1 -1
  14. package/dist/_shared/lbug/schema-constants.d.ts.map +1 -1
  15. package/dist/_shared/lbug/schema-constants.js +3 -1
  16. package/dist/_shared/lbug/schema-constants.js.map +1 -1
  17. package/dist/_shared/mro-strategy.d.ts +19 -0
  18. package/dist/_shared/mro-strategy.d.ts.map +1 -0
  19. package/dist/_shared/mro-strategy.js +2 -0
  20. package/dist/_shared/mro-strategy.js.map +1 -0
  21. package/dist/cli/ai-context.d.ts +1 -0
  22. package/dist/cli/ai-context.js +28 -4
  23. package/dist/cli/analyze.d.ts +2 -0
  24. package/dist/cli/analyze.js +2 -1
  25. package/dist/cli/group.d.ts +2 -0
  26. package/dist/cli/group.js +233 -0
  27. package/dist/cli/index.js +3 -0
  28. package/dist/cli/serve.js +4 -1
  29. package/dist/cli/setup.js +34 -3
  30. package/dist/config/ignore-service.js +8 -3
  31. package/dist/core/augmentation/engine.js +1 -1
  32. package/dist/core/git-staleness.d.ts +13 -0
  33. package/dist/core/git-staleness.js +29 -0
  34. package/dist/core/group/bridge-db.d.ts +82 -0
  35. package/dist/core/group/bridge-db.js +460 -0
  36. package/dist/core/group/bridge-schema.d.ts +27 -0
  37. package/dist/core/group/bridge-schema.js +55 -0
  38. package/dist/core/group/config-parser.d.ts +3 -0
  39. package/dist/core/group/config-parser.js +83 -0
  40. package/dist/core/group/contract-extractor.d.ts +7 -0
  41. package/dist/core/group/contract-extractor.js +1 -0
  42. package/dist/core/group/extractors/grpc-extractor.d.ts +16 -0
  43. package/dist/core/group/extractors/grpc-extractor.js +264 -0
  44. package/dist/core/group/extractors/http-route-extractor.d.ts +24 -0
  45. package/dist/core/group/extractors/http-route-extractor.js +428 -0
  46. package/dist/core/group/extractors/topic-extractor.d.ts +9 -0
  47. package/dist/core/group/extractors/topic-extractor.js +234 -0
  48. package/dist/core/group/matching.d.ts +13 -0
  49. package/dist/core/group/matching.js +198 -0
  50. package/dist/core/group/normalization.d.ts +3 -0
  51. package/dist/core/group/normalization.js +115 -0
  52. package/dist/core/group/service-boundary-detector.d.ts +8 -0
  53. package/dist/core/group/service-boundary-detector.js +155 -0
  54. package/dist/core/group/service.d.ts +46 -0
  55. package/dist/core/group/service.js +160 -0
  56. package/dist/core/group/storage.d.ts +9 -0
  57. package/dist/core/group/storage.js +91 -0
  58. package/dist/core/group/sync.d.ts +21 -0
  59. package/dist/core/group/sync.js +148 -0
  60. package/dist/core/group/types.d.ts +130 -0
  61. package/dist/core/group/types.js +1 -0
  62. package/dist/core/ingestion/binding-accumulator.d.ts +207 -0
  63. package/dist/core/ingestion/binding-accumulator.js +332 -0
  64. package/dist/core/ingestion/call-processor.d.ts +155 -24
  65. package/dist/core/ingestion/call-processor.js +1129 -247
  66. package/dist/core/ingestion/class-extractors/generic.d.ts +2 -0
  67. package/dist/core/ingestion/class-extractors/generic.js +135 -0
  68. package/dist/core/ingestion/class-types.d.ts +34 -0
  69. package/dist/core/ingestion/class-types.js +1 -0
  70. package/dist/core/ingestion/entry-point-scoring.d.ts +1 -0
  71. package/dist/core/ingestion/entry-point-scoring.js +1 -0
  72. package/dist/core/ingestion/field-types.d.ts +2 -2
  73. package/dist/core/ingestion/filesystem-walker.js +8 -0
  74. package/dist/core/ingestion/framework-detection.d.ts +1 -0
  75. package/dist/core/ingestion/framework-detection.js +1 -0
  76. package/dist/core/ingestion/heritage-processor.d.ts +8 -15
  77. package/dist/core/ingestion/heritage-processor.js +15 -28
  78. package/dist/core/ingestion/import-processor.d.ts +1 -11
  79. package/dist/core/ingestion/import-processor.js +0 -12
  80. package/dist/core/ingestion/import-resolvers/utils.js +1 -0
  81. package/dist/core/ingestion/import-resolvers/vue.d.ts +8 -0
  82. package/dist/core/ingestion/import-resolvers/vue.js +9 -0
  83. package/dist/core/ingestion/language-provider.d.ts +6 -3
  84. package/dist/core/ingestion/languages/c-cpp.js +168 -1
  85. package/dist/core/ingestion/languages/csharp.js +20 -0
  86. package/dist/core/ingestion/languages/dart.js +26 -4
  87. package/dist/core/ingestion/languages/go.js +22 -0
  88. package/dist/core/ingestion/languages/index.d.ts +1 -0
  89. package/dist/core/ingestion/languages/index.js +2 -0
  90. package/dist/core/ingestion/languages/java.js +17 -0
  91. package/dist/core/ingestion/languages/kotlin.js +24 -1
  92. package/dist/core/ingestion/languages/php.js +23 -11
  93. package/dist/core/ingestion/languages/python.js +9 -0
  94. package/dist/core/ingestion/languages/ruby.js +28 -0
  95. package/dist/core/ingestion/languages/rust.js +38 -0
  96. package/dist/core/ingestion/languages/swift.js +31 -0
  97. package/dist/core/ingestion/languages/typescript.d.ts +1 -0
  98. package/dist/core/ingestion/languages/typescript.js +52 -3
  99. package/dist/core/ingestion/languages/vue.d.ts +13 -0
  100. package/dist/core/ingestion/languages/vue.js +81 -0
  101. package/dist/core/ingestion/method-extractors/configs/c-cpp.d.ts +3 -0
  102. package/dist/core/ingestion/method-extractors/configs/c-cpp.js +387 -0
  103. package/dist/core/ingestion/method-extractors/configs/csharp.js +5 -1
  104. package/dist/core/ingestion/method-extractors/configs/dart.d.ts +2 -0
  105. package/dist/core/ingestion/method-extractors/configs/dart.js +376 -0
  106. package/dist/core/ingestion/method-extractors/configs/go.d.ts +2 -0
  107. package/dist/core/ingestion/method-extractors/configs/go.js +176 -0
  108. package/dist/core/ingestion/method-extractors/configs/jvm.js +13 -4
  109. package/dist/core/ingestion/method-extractors/configs/php.d.ts +2 -0
  110. package/dist/core/ingestion/method-extractors/configs/php.js +304 -0
  111. package/dist/core/ingestion/method-extractors/configs/python.d.ts +2 -0
  112. package/dist/core/ingestion/method-extractors/configs/python.js +309 -0
  113. package/dist/core/ingestion/method-extractors/configs/ruby.d.ts +2 -0
  114. package/dist/core/ingestion/method-extractors/configs/ruby.js +285 -0
  115. package/dist/core/ingestion/method-extractors/configs/rust.d.ts +2 -0
  116. package/dist/core/ingestion/method-extractors/configs/rust.js +195 -0
  117. package/dist/core/ingestion/method-extractors/configs/swift.d.ts +2 -0
  118. package/dist/core/ingestion/method-extractors/configs/swift.js +277 -0
  119. package/dist/core/ingestion/method-extractors/configs/typescript-javascript.js +85 -8
  120. package/dist/core/ingestion/method-extractors/generic.js +38 -15
  121. package/dist/core/ingestion/method-types.d.ts +25 -0
  122. package/dist/core/ingestion/model/field-registry.d.ts +18 -0
  123. package/dist/core/ingestion/model/field-registry.js +22 -0
  124. package/dist/core/ingestion/model/heritage-map.d.ts +70 -0
  125. package/dist/core/ingestion/model/heritage-map.js +159 -0
  126. package/dist/core/ingestion/model/index.d.ts +20 -0
  127. package/dist/core/ingestion/model/index.js +41 -0
  128. package/dist/core/ingestion/model/method-registry.d.ts +62 -0
  129. package/dist/core/ingestion/model/method-registry.js +130 -0
  130. package/dist/core/ingestion/model/registration-table.d.ts +139 -0
  131. package/dist/core/ingestion/model/registration-table.js +224 -0
  132. package/dist/core/ingestion/model/resolution-context.d.ts +93 -0
  133. package/dist/core/ingestion/model/resolution-context.js +337 -0
  134. package/dist/core/ingestion/model/resolve.d.ts +56 -0
  135. package/dist/core/ingestion/model/resolve.js +242 -0
  136. package/dist/core/ingestion/model/semantic-model.d.ts +86 -0
  137. package/dist/core/ingestion/model/semantic-model.js +120 -0
  138. package/dist/core/ingestion/model/symbol-table.d.ts +222 -0
  139. package/dist/core/ingestion/model/symbol-table.js +206 -0
  140. package/dist/core/ingestion/model/type-registry.d.ts +39 -0
  141. package/dist/core/ingestion/model/type-registry.js +62 -0
  142. package/dist/core/ingestion/mro-processor.d.ts +4 -3
  143. package/dist/core/ingestion/mro-processor.js +310 -106
  144. package/dist/core/ingestion/parsing-processor.d.ts +5 -4
  145. package/dist/core/ingestion/parsing-processor.js +210 -85
  146. package/dist/core/ingestion/pipeline.d.ts +2 -0
  147. package/dist/core/ingestion/pipeline.js +192 -68
  148. package/dist/core/ingestion/tree-sitter-queries.d.ts +5 -5
  149. package/dist/core/ingestion/tree-sitter-queries.js +21 -0
  150. package/dist/core/ingestion/type-env.d.ts +15 -2
  151. package/dist/core/ingestion/type-env.js +163 -102
  152. package/dist/core/ingestion/type-extractors/csharp.js +17 -0
  153. package/dist/core/ingestion/type-extractors/jvm.js +11 -0
  154. package/dist/core/ingestion/type-extractors/php.js +0 -55
  155. package/dist/core/ingestion/type-extractors/ruby.js +0 -32
  156. package/dist/core/ingestion/type-extractors/swift.js +13 -0
  157. package/dist/core/ingestion/type-extractors/types.d.ts +8 -8
  158. package/dist/core/ingestion/type-extractors/typescript.js +66 -69
  159. package/dist/core/ingestion/utils/ast-helpers.d.ts +33 -43
  160. package/dist/core/ingestion/utils/ast-helpers.js +129 -572
  161. package/dist/core/ingestion/utils/method-props.d.ts +32 -0
  162. package/dist/core/ingestion/utils/method-props.js +147 -0
  163. package/dist/core/ingestion/vue-sfc-extractor.d.ts +44 -0
  164. package/dist/core/ingestion/vue-sfc-extractor.js +94 -0
  165. package/dist/core/ingestion/workers/parse-worker.d.ts +31 -19
  166. package/dist/core/ingestion/workers/parse-worker.js +463 -198
  167. package/dist/core/lbug/lbug-adapter.d.ts +6 -0
  168. package/dist/core/lbug/lbug-adapter.js +68 -3
  169. package/dist/core/lbug/pool-adapter.d.ts +76 -0
  170. package/dist/core/lbug/pool-adapter.js +522 -0
  171. package/dist/core/run-analyze.d.ts +2 -0
  172. package/dist/core/run-analyze.js +1 -1
  173. package/dist/core/search/bm25-index.js +1 -1
  174. package/dist/core/tree-sitter/parser-loader.js +1 -0
  175. package/dist/core/wiki/graph-queries.js +1 -1
  176. package/dist/mcp/core/embedder.js +6 -5
  177. package/dist/mcp/core/lbug-adapter.d.ts +3 -63
  178. package/dist/mcp/core/lbug-adapter.js +3 -484
  179. package/dist/mcp/local/local-backend.d.ts +31 -2
  180. package/dist/mcp/local/local-backend.js +255 -46
  181. package/dist/mcp/resources.js +5 -4
  182. package/dist/mcp/staleness.d.ts +3 -13
  183. package/dist/mcp/staleness.js +2 -31
  184. package/dist/mcp/tools.js +80 -4
  185. package/dist/server/analyze-job.d.ts +2 -0
  186. package/dist/server/analyze-job.js +4 -0
  187. package/dist/server/api.d.ts +20 -1
  188. package/dist/server/api.js +306 -71
  189. package/dist/server/git-clone.d.ts +2 -1
  190. package/dist/server/git-clone.js +98 -5
  191. package/dist/storage/git.d.ts +13 -0
  192. package/dist/storage/git.js +25 -0
  193. package/dist/storage/repo-manager.js +1 -1
  194. package/package.json +8 -2
  195. package/scripts/patch-tree-sitter-swift.cjs +78 -0
  196. package/dist/core/ingestion/named-binding-processor.d.ts +0 -18
  197. package/dist/core/ingestion/named-binding-processor.js +0 -42
  198. package/dist/core/ingestion/resolution-context.d.ts +0 -58
  199. package/dist/core/ingestion/resolution-context.js +0 -135
  200. package/dist/core/ingestion/symbol-table.d.ts +0 -79
  201. package/dist/core/ingestion/symbol-table.js +0 -115
@@ -55,6 +55,10 @@ export class JobManager {
55
55
  getJob(id) {
56
56
  return this.jobs.get(id);
57
57
  }
58
+ /** Return a snapshot of all tracked jobs for inspection. */
59
+ listJobs() {
60
+ return Array.from(this.jobs.values());
61
+ }
58
62
  updateJob(id, update) {
59
63
  const job = this.jobs.get(id);
60
64
  if (!job)
@@ -4,9 +4,11 @@
4
4
  * REST API for browser-based clients to query the local .gitnexus/ index.
5
5
  * Also hosts the MCP server over StreamableHTTP for remote AI tool access.
6
6
  *
7
- * Security: binds to 127.0.0.1 by default (use --host to override).
7
+ * Security: binds to localhost by default (use --host to override).
8
8
  * CORS is restricted to localhost, private/LAN networks, and the deployed site.
9
9
  */
10
+ import express from 'express';
11
+ import { type GraphNode, type GraphRelationship } from '../_shared/index.js';
10
12
  /**
11
13
  * Determine whether an HTTP Origin header value is allowed by CORS policy.
12
14
  *
@@ -25,4 +27,21 @@
25
27
  * @returns `true` if the origin is allowed, `false` otherwise.
26
28
  */
27
29
  export declare const isAllowedOrigin: (origin: string | undefined) => boolean;
30
+ type GraphStreamRecord = {
31
+ type: 'node';
32
+ data: GraphNode;
33
+ } | {
34
+ type: 'relationship';
35
+ data: GraphRelationship;
36
+ } | {
37
+ type: 'error';
38
+ error: string;
39
+ };
40
+ export declare class ClientDisconnectedError extends Error {
41
+ constructor();
42
+ }
43
+ export declare const isIgnorableGraphQueryError: (err: unknown) => boolean;
44
+ export declare const writeNdjsonRecord: (res: express.Response, record: GraphStreamRecord, signal?: AbortSignal) => Promise<void>;
45
+ export declare const streamGraphNdjson: (res: express.Response, includeContent?: boolean, signal?: AbortSignal) => Promise<void>;
28
46
  export declare const createServer: (port: number, host?: string) => Promise<void>;
47
+ export {};
@@ -4,7 +4,7 @@
4
4
  * REST API for browser-based clients to query the local .gitnexus/ index.
5
5
  * Also hosts the MCP server over StreamableHTTP for remote AI tool access.
6
6
  *
7
- * Security: binds to 127.0.0.1 by default (use --host to override).
7
+ * Security: binds to localhost by default (use --host to override).
8
8
  * CORS is restricted to localhost, private/LAN networks, and the deployed site.
9
9
  */
10
10
  import express from 'express';
@@ -13,8 +13,8 @@ import path from 'path';
13
13
  import fs from 'fs/promises';
14
14
  import { createRequire } from 'node:module';
15
15
  import { loadMeta, listRegisteredRepos, getStoragePath } from '../storage/repo-manager.js';
16
- import { executeQuery, executePrepared, executeWithReusedStatement, closeLbug, withLbugDb, } from '../core/lbug/lbug-adapter.js';
17
- import { isWriteQuery } from '../mcp/core/lbug-adapter.js';
16
+ import { executeQuery, executePrepared, executeWithReusedStatement, streamQuery, closeLbug, withLbugDb, } from '../core/lbug/lbug-adapter.js';
17
+ import { isWriteQuery } from '../core/lbug/pool-adapter.js';
18
18
  import { NODE_TABLES } from '../_shared/index.js';
19
19
  import { searchFTSFromLbug } from '../core/search/bm25-index.js';
20
20
  import { hybridSearch } from '../core/search/hybrid-search.js';
@@ -91,72 +91,181 @@ export const isAllowedOrigin = (origin) => {
91
91
  return true;
92
92
  return false;
93
93
  };
94
+ export class ClientDisconnectedError extends Error {
95
+ constructor() {
96
+ super('Client disconnected during graph stream');
97
+ this.name = 'ClientDisconnectedError';
98
+ }
99
+ }
100
+ export const isIgnorableGraphQueryError = (err) => {
101
+ const message = err instanceof Error ? err.message : String(err);
102
+ return (message.includes('does not exist') ||
103
+ message.includes('not found') ||
104
+ message.includes('No table named'));
105
+ };
106
+ const ensureStreamIsWritable = (res, signal) => {
107
+ if (signal?.aborted || res.destroyed || res.writableEnded) {
108
+ throw new ClientDisconnectedError();
109
+ }
110
+ };
111
+ const waitForDrain = async (res, signal) => {
112
+ ensureStreamIsWritable(res, signal);
113
+ await new Promise((resolve, reject) => {
114
+ const cleanup = () => {
115
+ res.off('drain', onDrain);
116
+ res.off('close', onClose);
117
+ signal?.removeEventListener('abort', onAbort);
118
+ };
119
+ const onDrain = () => {
120
+ cleanup();
121
+ resolve();
122
+ };
123
+ const onClose = () => {
124
+ cleanup();
125
+ reject(new ClientDisconnectedError());
126
+ };
127
+ const onAbort = () => {
128
+ cleanup();
129
+ reject(new ClientDisconnectedError());
130
+ };
131
+ res.once('drain', onDrain);
132
+ res.once('close', onClose);
133
+ signal?.addEventListener('abort', onAbort, { once: true });
134
+ if (signal?.aborted || res.destroyed || res.writableEnded) {
135
+ onAbort();
136
+ }
137
+ });
138
+ ensureStreamIsWritable(res, signal);
139
+ };
140
+ const isClientDisconnectWriteError = (err) => {
141
+ if (!(err instanceof Error))
142
+ return false;
143
+ return (err.code === 'ERR_STREAM_DESTROYED' ||
144
+ err.code === 'EPIPE' ||
145
+ err.code === 'ECONNRESET' ||
146
+ err.message.includes('write after end'));
147
+ };
148
+ export const writeNdjsonRecord = async (res, record, signal) => {
149
+ ensureStreamIsWritable(res, signal);
150
+ try {
151
+ const canContinue = res.write(JSON.stringify(record) + '\n');
152
+ if (!canContinue) {
153
+ await waitForDrain(res, signal);
154
+ }
155
+ }
156
+ catch (err) {
157
+ if (isClientDisconnectWriteError(err)) {
158
+ throw new ClientDisconnectedError();
159
+ }
160
+ throw err;
161
+ }
162
+ };
94
163
  const buildGraph = async (includeContent = false) => {
95
164
  const nodes = [];
96
165
  for (const table of NODE_TABLES) {
97
166
  try {
98
- let query = '';
99
- if (table === 'File') {
100
- query = includeContent
101
- ? `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
102
- : `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
103
- }
104
- else if (table === 'Folder') {
105
- query = `MATCH (n:Folder) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
106
- }
107
- else if (table === 'Community') {
108
- query = `MATCH (n:Community) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`;
109
- }
110
- else if (table === 'Process') {
111
- query = `MATCH (n:Process) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
112
- }
113
- else {
114
- query = includeContent
115
- ? `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`
116
- : `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
117
- }
118
- const rows = await executeQuery(query);
167
+ const rows = await executeQuery(getNodeQuery(table, includeContent));
119
168
  for (const row of rows) {
120
- nodes.push({
121
- id: row.id ?? row[0],
122
- label: table,
123
- properties: {
124
- name: row.name ?? row.label ?? row[1],
125
- filePath: row.filePath ?? row[2],
126
- startLine: row.startLine,
127
- endLine: row.endLine,
128
- content: includeContent ? row.content : undefined,
129
- heuristicLabel: row.heuristicLabel,
130
- cohesion: row.cohesion,
131
- symbolCount: row.symbolCount,
132
- processType: row.processType,
133
- stepCount: row.stepCount,
134
- communities: row.communities,
135
- entryPointId: row.entryPointId,
136
- terminalId: row.terminalId,
137
- },
138
- });
169
+ nodes.push(mapGraphNodeRow(table, row, includeContent));
139
170
  }
140
171
  }
141
- catch {
142
- // ignore empty tables
172
+ catch (err) {
173
+ if (!isIgnorableGraphQueryError(err)) {
174
+ throw err;
175
+ }
143
176
  }
144
177
  }
145
178
  const relationships = [];
146
- const relRows = await executeQuery(`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`);
179
+ const relRows = await executeQuery(GRAPH_RELATIONSHIP_QUERY);
147
180
  for (const row of relRows) {
148
- relationships.push({
149
- id: `${row.sourceId}_${row.type}_${row.targetId}`,
150
- type: row.type,
151
- sourceId: row.sourceId,
152
- targetId: row.targetId,
153
- confidence: row.confidence,
154
- reason: row.reason,
155
- step: row.step,
156
- });
181
+ relationships.push(mapGraphRelationshipRow(row));
157
182
  }
158
183
  return { nodes, relationships };
159
184
  };
185
+ const GRAPH_RELATIONSHIP_QUERY = `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, ` +
186
+ `r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`;
187
+ const quoteNodeTable = (table) => `\`${table.replace(/`/g, '``')}\``;
188
+ const getNodeQuery = (table, includeContent) => {
189
+ const tableLabel = quoteNodeTable(table);
190
+ if (table === 'File') {
191
+ return includeContent
192
+ ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
193
+ : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
194
+ }
195
+ if (table === 'Folder') {
196
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
197
+ }
198
+ if (table === 'Community') {
199
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`;
200
+ }
201
+ if (table === 'Process') {
202
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
203
+ }
204
+ if (table === 'Route') {
205
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware`;
206
+ }
207
+ if (table === 'Tool') {
208
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description`;
209
+ }
210
+ return includeContent
211
+ ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`
212
+ : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
213
+ };
214
+ const mapGraphNodeRow = (table, row, includeContent) => ({
215
+ id: row.id ?? row[0],
216
+ label: table,
217
+ properties: {
218
+ name: row.name ?? row.label ?? row[1],
219
+ filePath: row.filePath ?? row[2],
220
+ startLine: row.startLine,
221
+ endLine: row.endLine,
222
+ content: includeContent ? row.content : undefined,
223
+ responseKeys: row.responseKeys,
224
+ errorKeys: row.errorKeys,
225
+ middleware: row.middleware,
226
+ heuristicLabel: row.heuristicLabel,
227
+ cohesion: row.cohesion,
228
+ symbolCount: row.symbolCount,
229
+ description: row.description,
230
+ processType: row.processType,
231
+ stepCount: row.stepCount,
232
+ communities: row.communities,
233
+ entryPointId: row.entryPointId,
234
+ terminalId: row.terminalId,
235
+ },
236
+ });
237
+ const mapGraphRelationshipRow = (row) => ({
238
+ id: `${row.sourceId}_${row.type}_${row.targetId}`,
239
+ type: row.type,
240
+ sourceId: row.sourceId,
241
+ targetId: row.targetId,
242
+ confidence: row.confidence,
243
+ reason: row.reason,
244
+ step: row.step,
245
+ });
246
+ export const streamGraphNdjson = async (res, includeContent = false, signal) => {
247
+ for (const table of NODE_TABLES) {
248
+ try {
249
+ await streamQuery(getNodeQuery(table, includeContent), async (row) => {
250
+ await writeNdjsonRecord(res, {
251
+ type: 'node',
252
+ data: mapGraphNodeRow(table, row, includeContent),
253
+ }, signal);
254
+ });
255
+ }
256
+ catch (err) {
257
+ if (!isIgnorableGraphQueryError(err)) {
258
+ throw err;
259
+ }
260
+ }
261
+ }
262
+ await streamQuery(GRAPH_RELATIONSHIP_QUERY, async (row) => {
263
+ await writeNdjsonRecord(res, {
264
+ type: 'relationship',
265
+ data: mapGraphRelationshipRow(row),
266
+ }, signal);
267
+ });
268
+ };
160
269
  /**
161
270
  * Mount an SSE progress endpoint for a JobManager.
162
271
  * Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
@@ -249,17 +358,29 @@ export const createServer = async (port, host = '127.0.0.1') => {
249
358
  app.disable('x-powered-by');
250
359
  // CORS: allow localhost, private/LAN networks, and the deployed site.
251
360
  // Non-browser requests (curl, server-to-server) have no origin and are allowed.
361
+ // Disallowed origins get the response without Access-Control-Allow-Origin,
362
+ // so the browser blocks it. We pass `false` instead of throwing an Error to
363
+ // avoid crashing into Express's default error handler (which returned 500).
252
364
  app.use(cors({
253
365
  origin: (origin, callback) => {
254
- if (isAllowedOrigin(origin)) {
255
- callback(null, true);
256
- }
257
- else {
258
- callback(new Error('Not allowed by CORS'));
259
- }
366
+ callback(null, isAllowedOrigin(origin));
260
367
  },
261
368
  }));
262
369
  app.use(express.json({ limit: '10mb' }));
370
+ // Support Chromium Private Network Access (required since Chrome 130+).
371
+ // Without this header, Chrome/Edge/Brave/Arc block public->loopback requests
372
+ // which breaks bridge mode entirely.
373
+ app.use((_req, res, next) => {
374
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
375
+ next();
376
+ });
377
+ // Handle PNA preflight: Chromium sends Access-Control-Request-Private-Network
378
+ // on OPTIONS requests and expects the allow header in the response.
379
+ // Note: the actual Allow-Private-Network header is already set by the global
380
+ // middleware above, so we just need to call next() here.
381
+ app.options('*', (_req, res, next) => {
382
+ next();
383
+ });
263
384
  // Initialize MCP backend (multi-repo, shared across all MCP sessions)
264
385
  const backend = new LocalBackend();
265
386
  await backend.init();
@@ -278,14 +399,75 @@ export const createServer = async (port, host = '127.0.0.1') => {
278
399
  const releaseRepoLock = (repoPath) => {
279
400
  activeRepoPaths.delete(repoPath);
280
401
  };
281
- // Helper: resolve a repo by name from the global registry, or default to first
282
- const resolveRepo = async (repoName) => {
402
+ /**
403
+ * Maximum time the hold-queue will wait for an active analysis job to complete.
404
+ * Must stay in sync with the frontend's `fetchRepoInfo({ awaitAnalysis: true })` timeout.
405
+ */
406
+ const HOLD_QUEUE_TIMEOUT_SECS = 300; // 5 minutes
407
+ // Helper: resolve a repo by name from the global registry, or default to first.
408
+ // Pass `req` to enable early exit if the client disconnects during the hold-queue wait.
409
+ const resolveRepo = async (repoName, isRetry = false, req) => {
283
410
  const repos = await listRegisteredRepos();
284
- if (repos.length === 0)
285
- return null;
286
- if (repoName)
287
- return repos.find((r) => r.name === repoName) || null;
288
- return repos[0]; // default to first
411
+ let found = null;
412
+ // Normalize: if a full path is passed, extract just the basename.
413
+ // e.g. "C:\Users\LENOVO\.gitnexus\repos\todo.txt-cli" -> "todo.txt-cli"
414
+ const normalizedName = repoName ? path.basename(repoName) : undefined;
415
+ if (normalizedName) {
416
+ found =
417
+ repos.find((r) => r.name === normalizedName) ||
418
+ repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) ||
419
+ null;
420
+ }
421
+ else if (repos.length > 0) {
422
+ found = repos[0]; // default to first repo
423
+ }
424
+ // If not yet in the registry, check whether a background job is actively cloning or
425
+ // analyzing this repo. Hold the connection open (up to 5 minutes) until it completes.
426
+ // We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job
427
+ // whose repo is still missing means the registry sync failed; the fallback below handles it.
428
+ if (!found && normalizedName) {
429
+ const lower = normalizedName.toLowerCase();
430
+ // Track client disconnect to cancel the wait early
431
+ let clientGone = false;
432
+ req?.on('close', () => {
433
+ clientGone = true;
434
+ });
435
+ for (const job of jobManager.listJobs()) {
436
+ const isMatch = job.repoName?.toLowerCase() === lower ||
437
+ (job.repoUrl && path.basename(job.repoUrl).replace('.git', '').toLowerCase() === lower) ||
438
+ (job.repoPath && path.basename(job.repoPath).toLowerCase() === lower);
439
+ if (isMatch && ['queued', 'cloning', 'analyzing'].includes(job.status)) {
440
+ if (process.env.DEBUG) {
441
+ console.log(`[debug] resolveRepo waiting for active job ${job.id} (${normalizedName})...`);
442
+ }
443
+ for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) {
444
+ if (clientGone)
445
+ return null; // client disconnected — stop polling
446
+ const currentJob = jobManager.getJob(job.id);
447
+ if (!currentJob || currentJob.status === 'failed')
448
+ break;
449
+ if (currentJob.status === 'complete') {
450
+ await backend.init();
451
+ const freshRepos = await listRegisteredRepos();
452
+ return freshRepos.find((r) => r.name === normalizedName) || null;
453
+ }
454
+ await new Promise((r) => setTimeout(r, 1000));
455
+ }
456
+ // Timed out — signal to the caller with a specific message
457
+ return { __timedOut: true, repoName: normalizedName };
458
+ }
459
+ }
460
+ }
461
+ // Emergency fallback: re-sync the registry to handle Windows file-system race conditions
462
+ // (e.g. registry file not yet flushed after clone completes).
463
+ if (!found && normalizedName && !isRetry) {
464
+ if (process.env.DEBUG) {
465
+ console.log(`[debug] resolveRepo 404 for "${normalizedName}". Triggering deep init...`);
466
+ }
467
+ await backend.init();
468
+ return await resolveRepo(normalizedName, true, req);
469
+ }
470
+ return found;
289
471
  };
290
472
  // SSE heartbeat — clients connect to detect server liveness instantly.
291
473
  // When the server shuts down, the TCP connection drops and the client's
@@ -341,11 +523,18 @@ export const createServer = async (port, host = '127.0.0.1') => {
341
523
  // Get repo info
342
524
  app.get('/api/repo', async (req, res) => {
343
525
  try {
344
- const entry = await resolveRepo(requestedRepo(req));
526
+ const entry = await resolveRepo(requestedRepo(req), false, req);
345
527
  if (!entry) {
346
528
  res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' });
347
529
  return;
348
530
  }
531
+ // Timed out waiting for an active analysis job
532
+ if (entry.__timedOut) {
533
+ res.status(503).json({
534
+ error: `Repository analysis for "${entry.repoName}" is taking longer than expected. Please try again in a moment.`,
535
+ });
536
+ return;
537
+ }
349
538
  const meta = await loadMeta(entry.storagePath);
350
539
  res.json({
351
540
  name: entry.name,
@@ -423,11 +612,56 @@ export const createServer = async (port, host = '127.0.0.1') => {
423
612
  }
424
613
  const lbugPath = path.join(entry.storagePath, 'lbug');
425
614
  const includeContent = req.query.includeContent === 'true';
615
+ const stream = req.query.stream === 'true';
616
+ if (stream) {
617
+ const abortController = new AbortController();
618
+ let responseFinished = false;
619
+ const markFinished = () => {
620
+ responseFinished = true;
621
+ };
622
+ const abortStreaming = () => {
623
+ if (!responseFinished) {
624
+ abortController.abort();
625
+ }
626
+ };
627
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
628
+ res.setHeader('Cache-Control', 'no-cache');
629
+ res.flushHeaders();
630
+ req.once('aborted', abortStreaming);
631
+ res.once('finish', markFinished);
632
+ res.once('close', abortStreaming);
633
+ try {
634
+ await withLbugDb(lbugPath, async () => streamGraphNdjson(res, includeContent, abortController.signal));
635
+ if (!abortController.signal.aborted && !res.writableEnded) {
636
+ res.end();
637
+ }
638
+ }
639
+ finally {
640
+ req.off('aborted', abortStreaming);
641
+ res.off('finish', markFinished);
642
+ res.off('close', abortStreaming);
643
+ }
644
+ return;
645
+ }
426
646
  const graph = await withLbugDb(lbugPath, async () => buildGraph(includeContent));
427
647
  res.json(graph);
428
648
  }
429
649
  catch (err) {
430
- res.status(500).json({ error: err.message || 'Failed to build graph' });
650
+ if (err instanceof ClientDisconnectedError) {
651
+ return;
652
+ }
653
+ const message = err.message || 'Failed to build graph';
654
+ if (res.headersSent) {
655
+ try {
656
+ res.write(JSON.stringify({ type: 'error', error: message }) + '\n');
657
+ }
658
+ catch {
659
+ // Best-effort only after streaming has started.
660
+ }
661
+ res.end();
662
+ return;
663
+ }
664
+ res.status(500).json({ error: message });
431
665
  }
432
666
  });
433
667
  // Execute Cypher query
@@ -1134,7 +1368,8 @@ export const createServer = async (port, host = '127.0.0.1') => {
1134
1368
  // to the caller instead of crashing with an unhandled 'error' event.
1135
1369
  await new Promise((resolve, reject) => {
1136
1370
  const server = app.listen(port, host, () => {
1137
- console.log(`GitNexus server running on http://${host}:${port}`);
1371
+ const displayHost = host === '::' || host === '0.0.0.0' ? 'localhost' : host;
1372
+ console.log(`GitNexus server running on http://${displayHost}:${port}`);
1138
1373
  resolve();
1139
1374
  });
1140
1375
  server.on('error', (err) => reject(err));
@@ -10,7 +10,8 @@ export declare function extractRepoName(url: string): string;
10
10
  export declare function getCloneDir(repoName: string): string;
11
11
  /**
12
12
  * Validate a git URL to prevent SSRF attacks.
13
- * Only allows https:// and http:// schemes. Blocks private/internal addresses.
13
+ * Only allows https:// and http:// schemes. Blocks private/internal addresses,
14
+ * IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
14
15
  */
15
16
  export declare function validateGitUrl(url: string): void;
16
17
  export interface CloneProgress {
@@ -8,6 +8,7 @@ import { spawn } from 'child_process';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import fs from 'fs/promises';
11
+ import { isIP } from 'net';
11
12
  /** Extract the repository name from a git URL (HTTPS or SSH). */
12
13
  export function extractRepoName(url) {
13
14
  const cleaned = url.replace(/\/+$/, '');
@@ -18,9 +19,17 @@ export function extractRepoName(url) {
18
19
  export function getCloneDir(repoName) {
19
20
  return path.join(os.homedir(), '.gitnexus', 'repos', repoName);
20
21
  }
22
+ // Cloud metadata hostnames that must never be reachable via user-supplied URLs
23
+ const BLOCKED_HOSTNAMES = new Set([
24
+ 'localhost',
25
+ 'metadata.google.internal',
26
+ 'metadata.azure.com',
27
+ 'metadata.internal',
28
+ ]);
21
29
  /**
22
30
  * Validate a git URL to prevent SSRF attacks.
23
- * Only allows https:// and http:// schemes. Blocks private/internal addresses.
31
+ * Only allows https:// and http:// schemes. Blocks private/internal addresses,
32
+ * IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
24
33
  */
25
34
  export function validateGitUrl(url) {
26
35
  let parsed;
@@ -34,14 +43,91 @@ export function validateGitUrl(url) {
34
43
  throw new Error('Only https:// and http:// git URLs are allowed');
35
44
  }
36
45
  const host = parsed.hostname.toLowerCase();
37
- if (host === 'localhost' ||
38
- host === '[::1]' ||
39
- /^127\./.test(host) ||
46
+ // Block known dangerous hostnames (cloud metadata services)
47
+ if (BLOCKED_HOSTNAMES.has(host)) {
48
+ throw new Error('Cloning from private/internal addresses is not allowed');
49
+ }
50
+ // Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
51
+ let normalizedHost = host;
52
+ if (host.startsWith('[') && host.endsWith(']')) {
53
+ normalizedHost = host.slice(1, -1);
54
+ }
55
+ // Check if this is an IPv6 address
56
+ // Use manual colon detection as fallback since isIP may return 0 for some
57
+ // normalized IPv6 forms (e.g. ::ffff:7f00:1)
58
+ const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
59
+ if (isIPv6) {
60
+ assertNotPrivateIPv6(normalizedHost);
61
+ return;
62
+ }
63
+ // Check if this is an IPv4 address (including numeric encodings)
64
+ if (isIP(normalizedHost) === 4) {
65
+ assertNotPrivateIPv4(normalizedHost);
66
+ return;
67
+ }
68
+ // For non-IP hostnames, check for numeric IP tricks
69
+ // Decimal encoding: 2130706433 = 127.0.0.1
70
+ // Hex encoding: 0x7f000001 = 127.0.0.1
71
+ if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
72
+ throw new Error('Cloning from private/internal addresses is not allowed');
73
+ }
74
+ // Standard IPv4 regex checks for dotted notation
75
+ if (/^127\./.test(host) ||
40
76
  /^10\./.test(host) ||
41
77
  /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
42
78
  /^192\.168\./.test(host) ||
43
79
  /^169\.254\./.test(host) ||
44
- /^0\./.test(host)) {
80
+ /^0\./.test(host) ||
81
+ host === '0.0.0.0' ||
82
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
83
+ /^198\.1[89]\./.test(host)) {
84
+ throw new Error('Cloning from private/internal addresses is not allowed');
85
+ }
86
+ }
87
+ function assertNotPrivateIPv6(ip) {
88
+ // Expand common compressed forms for comparison
89
+ const lower = ip.toLowerCase();
90
+ // IPv6 loopback
91
+ if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
92
+ throw new Error('Cloning from private/internal addresses is not allowed');
93
+ }
94
+ // Unspecified address
95
+ if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
96
+ throw new Error('Cloning from private/internal addresses is not allowed');
97
+ }
98
+ // IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
99
+ if (lower.startsWith('fc') || lower.startsWith('fd')) {
100
+ throw new Error('Cloning from private/internal addresses is not allowed');
101
+ }
102
+ // IPv6 link-local (fe80::/10)
103
+ if (lower.startsWith('fe80') ||
104
+ lower.startsWith('fe8') ||
105
+ lower.startsWith('fe9') ||
106
+ lower.startsWith('fea') ||
107
+ lower.startsWith('feb')) {
108
+ throw new Error('Cloning from private/internal addresses is not allowed');
109
+ }
110
+ // IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
111
+ // Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
112
+ if (lower.startsWith('::ffff:')) {
113
+ throw new Error('Cloning from private/internal addresses is not allowed');
114
+ }
115
+ // Also catch the expanded form: 0:0:0:0:0:ffff:
116
+ if (lower.includes(':ffff:')) {
117
+ throw new Error('Cloning from private/internal addresses is not allowed');
118
+ }
119
+ }
120
+ function assertNotPrivateIPv4(ip) {
121
+ const parts = ip.split('.').map(Number);
122
+ const [a, b] = parts;
123
+ if (a === 127 ||
124
+ a === 10 ||
125
+ (a === 172 && b >= 16 && b <= 31) ||
126
+ (a === 192 && b === 168) ||
127
+ (a === 169 && b === 254) ||
128
+ a === 0 ||
129
+ (a === 100 && b >= 64 && b <= 127) ||
130
+ (a === 198 && (b === 18 || b === 19))) {
45
131
  throw new Error('Cloning from private/internal addresses is not allowed');
46
132
  }
47
133
  }
@@ -69,6 +155,13 @@ function runGit(args, cwd) {
69
155
  const proc = spawn('git', args, {
70
156
  cwd,
71
157
  stdio: ['ignore', 'pipe', 'pipe'],
158
+ env: {
159
+ ...process.env,
160
+ // Prevent git from prompting for credentials (hangs the process)
161
+ GIT_TERMINAL_PROMPT: '0',
162
+ // Ensure no credential helper tries to open a GUI prompt
163
+ GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true',
164
+ },
72
165
  });
73
166
  let stderr = '';
74
167
  proc.stderr.on('data', (chunk) => {
@@ -16,3 +16,16 @@ export declare const getGitRoot: (fromPath: string) => string | null;
16
16
  * @returns `true` when `.git` is present, `false` otherwise.
17
17
  */
18
18
  export declare const hasGitDir: (dirPath: string) => boolean;
19
+ export interface DiffHunk {
20
+ startLine: number;
21
+ endLine: number;
22
+ }
23
+ export interface FileDiff {
24
+ filePath: string;
25
+ hunks: DiffHunk[];
26
+ }
27
+ /**
28
+ * Parse unified diff output (with -U0) into per-file hunk ranges.
29
+ * Extracts the new-file line ranges from @@ hunk headers.
30
+ */
31
+ export declare function parseDiffHunks(diffOutput: string): FileDiff[];