lynkr 8.0.0 → 9.0.1

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 (128) hide show
  1. package/.lynkr/telemetry.db +0 -0
  2. package/.lynkr/telemetry.db-shm +0 -0
  3. package/.lynkr/telemetry.db-wal +0 -0
  4. package/README.md +196 -322
  5. package/lynkr-skill.tar.gz +0 -0
  6. package/package.json +4 -3
  7. package/src/api/openai-router.js +64 -13
  8. package/src/api/providers-handler.js +171 -3
  9. package/src/api/router.js +9 -2
  10. package/src/clients/circuit-breaker.js +10 -247
  11. package/src/clients/codex-process.js +342 -0
  12. package/src/clients/codex-utils.js +143 -0
  13. package/src/clients/databricks.js +210 -63
  14. package/src/clients/resilience.js +540 -0
  15. package/src/clients/retry.js +22 -167
  16. package/src/clients/standard-tools.js +23 -0
  17. package/src/config/index.js +77 -0
  18. package/src/context/compression.js +42 -9
  19. package/src/context/distill.js +492 -0
  20. package/src/orchestrator/index.js +48 -8
  21. package/src/routing/complexity-analyzer.js +258 -5
  22. package/src/routing/index.js +12 -2
  23. package/src/routing/latency-tracker.js +148 -0
  24. package/src/routing/model-tiers.js +2 -0
  25. package/src/routing/quality-scorer.js +113 -0
  26. package/src/routing/telemetry.js +464 -0
  27. package/src/server.js +13 -12
  28. package/src/tools/code-graph.js +538 -0
  29. package/src/tools/code-mode.js +304 -0
  30. package/src/tools/index.js +4 -0
  31. package/src/tools/lazy-loader.js +18 -0
  32. package/src/tools/mcp-remote.js +7 -0
  33. package/src/tools/smart-selection.js +11 -0
  34. package/src/tools/tinyfish.js +358 -0
  35. package/src/tools/truncate.js +1 -0
  36. package/src/utils/payload.js +206 -0
  37. package/src/utils/perf-timer.js +80 -0
  38. package/.github/FUNDING.yml +0 -15
  39. package/.github/workflows/README.md +0 -215
  40. package/.github/workflows/ci.yml +0 -69
  41. package/.github/workflows/index.yml +0 -62
  42. package/.github/workflows/web-tools-tests.yml +0 -56
  43. package/CITATIONS.bib +0 -6
  44. package/DEPLOYMENT.md +0 -1001
  45. package/LYNKR-TUI-PLAN.md +0 -984
  46. package/PERFORMANCE-REPORT.md +0 -866
  47. package/PLAN-per-client-model-routing.md +0 -252
  48. package/docs/42642f749da6234f41b6b425c3bb07c9.txt +0 -1
  49. package/docs/BingSiteAuth.xml +0 -4
  50. package/docs/docs-style.css +0 -478
  51. package/docs/docs.html +0 -198
  52. package/docs/google5be250e608e6da39.html +0 -1
  53. package/docs/index.html +0 -577
  54. package/docs/index.md +0 -584
  55. package/docs/robots.txt +0 -4
  56. package/docs/sitemap.xml +0 -44
  57. package/docs/style.css +0 -1223
  58. package/docs/toon-integration-spec.md +0 -130
  59. package/documentation/README.md +0 -101
  60. package/documentation/api.md +0 -806
  61. package/documentation/claude-code-cli.md +0 -679
  62. package/documentation/codex-cli.md +0 -397
  63. package/documentation/contributing.md +0 -571
  64. package/documentation/cursor-integration.md +0 -734
  65. package/documentation/docker.md +0 -874
  66. package/documentation/embeddings.md +0 -762
  67. package/documentation/faq.md +0 -713
  68. package/documentation/features.md +0 -403
  69. package/documentation/headroom.md +0 -519
  70. package/documentation/installation.md +0 -758
  71. package/documentation/memory-system.md +0 -476
  72. package/documentation/production.md +0 -636
  73. package/documentation/providers.md +0 -1009
  74. package/documentation/routing.md +0 -476
  75. package/documentation/testing.md +0 -629
  76. package/documentation/token-optimization.md +0 -325
  77. package/documentation/tools.md +0 -697
  78. package/documentation/troubleshooting.md +0 -969
  79. package/final-test.js +0 -33
  80. package/headroom-sidecar/config.py +0 -93
  81. package/headroom-sidecar/requirements.txt +0 -14
  82. package/headroom-sidecar/server.py +0 -451
  83. package/monitor-agents.sh +0 -31
  84. package/scripts/audit-log-reader.js +0 -399
  85. package/scripts/compact-dictionary.js +0 -204
  86. package/scripts/test-deduplication.js +0 -448
  87. package/src/db/database.sqlite +0 -0
  88. package/te +0 -11622
  89. package/test/README.md +0 -212
  90. package/test/azure-openai-config.test.js +0 -213
  91. package/test/azure-openai-error-resilience.test.js +0 -238
  92. package/test/azure-openai-format-conversion.test.js +0 -354
  93. package/test/azure-openai-integration.test.js +0 -287
  94. package/test/azure-openai-routing.test.js +0 -175
  95. package/test/azure-openai-streaming.test.js +0 -171
  96. package/test/bedrock-integration.test.js +0 -457
  97. package/test/comprehensive-test-suite.js +0 -928
  98. package/test/config-validation.test.js +0 -207
  99. package/test/cursor-integration.test.js +0 -484
  100. package/test/format-conversion.test.js +0 -578
  101. package/test/hybrid-routing-integration.test.js +0 -269
  102. package/test/hybrid-routing-performance.test.js +0 -428
  103. package/test/llamacpp-integration.test.js +0 -882
  104. package/test/lmstudio-integration.test.js +0 -347
  105. package/test/memory/extractor.test.js +0 -398
  106. package/test/memory/retriever.test.js +0 -613
  107. package/test/memory/retriever.test.js.bak +0 -585
  108. package/test/memory/search.test.js +0 -537
  109. package/test/memory/search.test.js.bak +0 -389
  110. package/test/memory/store.test.js +0 -344
  111. package/test/memory/store.test.js.bak +0 -312
  112. package/test/memory/surprise.test.js +0 -300
  113. package/test/memory-performance.test.js +0 -472
  114. package/test/openai-integration.test.js +0 -683
  115. package/test/openrouter-error-resilience.test.js +0 -418
  116. package/test/passthrough-mode.test.js +0 -385
  117. package/test/performance-benchmark.js +0 -351
  118. package/test/performance-tests.js +0 -528
  119. package/test/routing.test.js +0 -225
  120. package/test/toon-compression.test.js +0 -131
  121. package/test/web-tools.test.js +0 -329
  122. package/test-agents-simple.js +0 -43
  123. package/test-cli-connection.sh +0 -33
  124. package/test-learning-unit.js +0 -126
  125. package/test-learning.js +0 -112
  126. package/test-parallel-agents.sh +0 -124
  127. package/test-parallel-direct.js +0 -155
  128. package/test-subagents.sh +0 -117
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Graphify Integration — Knowledge Graph for Code Intelligence
3
+ *
4
+ * Communicates with Graphify's CLI to provide blast radius analysis,
5
+ * god node detection, community cohesion, surprise scoring, and
6
+ * structural complexity signals for intelligent routing decisions.
7
+ *
8
+ * Workspace resolution order (per-request):
9
+ * 1. Explicit workspace passed by caller (e.g. from X-Lynkr-Workspace header)
10
+ * 2. Auto-detected from absolute file paths in the conversation messages
11
+ * 3. CODE_GRAPH_WORKSPACE env var
12
+ * 4. process.cwd() (last resort)
13
+ *
14
+ * Graphify: https://github.com/safishamsi/graphify
15
+ *
16
+ * @module tools/code-graph
17
+ */
18
+
19
+ const path = require("path");
20
+ const { execFile } = require("child_process");
21
+ const config = require("../config");
22
+ const logger = require("../logger");
23
+
24
+ // ============================================================================
25
+ // CACHE
26
+ // ============================================================================
27
+
28
+ /** @type {Map<string, { data: any, ts: number }>} */
29
+ const resultCache = new Map();
30
+ const CACHE_TTL_MS = 30_000; // 30 seconds
31
+
32
+ /**
33
+ * Retrieve a cached value or null if expired / missing.
34
+ * @param {string} key
35
+ * @returns {any|null}
36
+ */
37
+ function cacheGet(key) {
38
+ const entry = resultCache.get(key);
39
+ if (!entry) return null;
40
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
41
+ resultCache.delete(key);
42
+ return null;
43
+ }
44
+ return entry.data;
45
+ }
46
+
47
+ /**
48
+ * Store a value in the cache.
49
+ * @param {string} key
50
+ * @param {any} data
51
+ */
52
+ function cacheSet(key, data) {
53
+ resultCache.set(key, { data, ts: Date.now() });
54
+
55
+ // Prevent unbounded growth — evict oldest entries beyond 200
56
+ if (resultCache.size > 200) {
57
+ const oldest = resultCache.keys().next().value;
58
+ resultCache.delete(oldest);
59
+ }
60
+ }
61
+
62
+ // ============================================================================
63
+ // FAILURE SUPPRESSION
64
+ // ============================================================================
65
+
66
+ /** Timestamp of the last logged warning (0 = never) */
67
+ let lastWarningTs = 0;
68
+ const WARNING_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
69
+
70
+ /**
71
+ * Log a warning at most once per cooldown period.
72
+ * @param {string} msg
73
+ * @param {Object} [meta]
74
+ */
75
+ function warnOnce(msg, meta = {}) {
76
+ const now = Date.now();
77
+ if (now - lastWarningTs < WARNING_COOLDOWN_MS) return;
78
+ lastWarningTs = now;
79
+ logger.warn(meta, `[graphify] ${msg}`);
80
+ }
81
+
82
+ // ============================================================================
83
+ // WORKSPACE DETECTION
84
+ // ============================================================================
85
+
86
+ /**
87
+ * Detect the workspace root from a list of absolute file paths by finding
88
+ * their longest common directory prefix.
89
+ *
90
+ * Example:
91
+ * ["/Users/bob/app/src/a.js", "/Users/bob/app/src/b.js", "/Users/bob/app/test/c.js"]
92
+ * → "/Users/bob/app"
93
+ *
94
+ * Returns null if no absolute paths are provided or they share no common root.
95
+ *
96
+ * @param {string[]} filePaths
97
+ * @returns {string|null}
98
+ */
99
+ function detectWorkspaceFromPaths(filePaths) {
100
+ // Only consider absolute paths
101
+ const absolute = filePaths.filter((p) => path.isAbsolute(p));
102
+ if (absolute.length === 0) return null;
103
+
104
+ // Split each path into segments
105
+ const segmented = absolute.map((p) => p.split(path.sep).filter(Boolean));
106
+
107
+ // Find common prefix segments
108
+ const first = segmented[0];
109
+ let commonLength = first.length;
110
+
111
+ for (let i = 1; i < segmented.length; i++) {
112
+ const other = segmented[i];
113
+ let j = 0;
114
+ while (j < commonLength && j < other.length && first[j] === other[j]) {
115
+ j++;
116
+ }
117
+ commonLength = j;
118
+ }
119
+
120
+ if (commonLength === 0) return null;
121
+
122
+ // Reconstruct the common path — must be a directory, not a file
123
+ let common = path.sep + first.slice(0, commonLength).join(path.sep);
124
+
125
+ // If the common path looks like a file (has extension), go up one level
126
+ if (path.extname(common)) {
127
+ common = path.dirname(common);
128
+ }
129
+
130
+ // Don't return root or home-level paths — too broad to be useful
131
+ const depth = common.split(path.sep).filter(Boolean).length;
132
+ if (depth < 2) return null;
133
+
134
+ return common;
135
+ }
136
+
137
+ // ============================================================================
138
+ // CONFIGURATION HELPERS
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Return resolved code-graph configuration from config module.
143
+ * @returns {{ enabled: boolean, command: string, defaultWorkspace: string, timeout: number }}
144
+ */
145
+ function getConfig() {
146
+ const cfg = config.codeGraph || {};
147
+ return {
148
+ enabled: cfg.enabled === true,
149
+ command: cfg.command || "graphify",
150
+ defaultWorkspace: cfg.workspace || process.cwd(),
151
+ timeout: cfg.timeout || 5000,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Resolve the workspace for a given request.
157
+ *
158
+ * Priority:
159
+ * 1. Explicit workspace (from header or caller)
160
+ * 2. Auto-detected from file paths
161
+ * 3. CODE_GRAPH_WORKSPACE env var
162
+ * 4. process.cwd()
163
+ *
164
+ * @param {Object} [options]
165
+ * @param {string} [options.workspace] - Explicit workspace from caller/header
166
+ * @param {string[]} [options.filePaths] - File paths from the conversation
167
+ * @returns {string}
168
+ */
169
+ function resolveWorkspace(options = {}) {
170
+ // 1. Explicit workspace
171
+ if (options.workspace && typeof options.workspace === "string") {
172
+ return options.workspace;
173
+ }
174
+
175
+ // 2. Auto-detect from file paths
176
+ if (Array.isArray(options.filePaths) && options.filePaths.length > 0) {
177
+ const detected = detectWorkspaceFromPaths(options.filePaths);
178
+ if (detected) {
179
+ logger.debug({ workspace: detected }, "[graphify] auto-detected workspace from file paths");
180
+ return detected;
181
+ }
182
+ }
183
+
184
+ // 3/4. Static config or cwd
185
+ return getConfig().defaultWorkspace;
186
+ }
187
+
188
+ // ============================================================================
189
+ // COMMAND EXECUTION
190
+ // ============================================================================
191
+
192
+ /**
193
+ * Execute a Graphify CLI command and parse JSON output.
194
+ *
195
+ * Graphify CLI: `graphify query --workspace <path> <query>`
196
+ * or: `graphify --workspace <path>` (builds graph + reports)
197
+ *
198
+ * @param {string} subcommand — e.g. "query", "benchmark", or null for build
199
+ * @param {string[]} [args] — additional CLI arguments
200
+ * @param {string} [workspace] — resolved workspace path
201
+ * @returns {Promise<Object|null>} Parsed JSON or null on failure
202
+ */
203
+ function execGraph(subcommand, args = [], workspace = null) {
204
+ const cfg = getConfig();
205
+ if (!cfg.enabled) return Promise.resolve(null);
206
+
207
+ const ws = workspace || cfg.defaultWorkspace;
208
+ const parts = cfg.command.split(/\s+/);
209
+ const bin = parts[0];
210
+ const baseArgs = parts.slice(1);
211
+
212
+ const fullArgs = [
213
+ ...baseArgs,
214
+ ...(subcommand ? [subcommand] : []),
215
+ "--workspace",
216
+ ws,
217
+ ...args,
218
+ ];
219
+
220
+ return new Promise((resolve) => {
221
+ execFile(
222
+ bin,
223
+ fullArgs,
224
+ { timeout: cfg.timeout, maxBuffer: 1024 * 1024 },
225
+ (err, stdout, stderr) => {
226
+ if (err) {
227
+ warnOnce(`command failed: ${subcommand || 'build'}`, {
228
+ err: err.message,
229
+ stderr: (stderr || "").slice(0, 200),
230
+ });
231
+ return resolve(null);
232
+ }
233
+ try {
234
+ const data = JSON.parse(stdout);
235
+ return resolve(data);
236
+ } catch (parseErr) {
237
+ // Graphify may output non-JSON for build commands — try reading report
238
+ warnOnce(`failed to parse JSON for: ${subcommand || 'build'}`, {
239
+ err: parseErr.message,
240
+ });
241
+ return resolve(null);
242
+ }
243
+ }
244
+ );
245
+ });
246
+ }
247
+
248
+ // ============================================================================
249
+ // AVAILABILITY CHECK
250
+ // ============================================================================
251
+
252
+ /** Cached availability result per workspace */
253
+ const availabilityCache = new Map(); // workspace → { value, ts }
254
+ const AVAILABILITY_TTL_MS = 60_000; // 1 minute
255
+
256
+ /**
257
+ * Check whether Graphify is configured and responsive.
258
+ * Result is cached per workspace for 60 seconds.
259
+ *
260
+ * @param {Object} [options]
261
+ * @param {string} [options.workspace] - Explicit workspace
262
+ * @param {string[]} [options.filePaths] - File paths for auto-detection
263
+ * @returns {Promise<boolean>}
264
+ */
265
+ async function isAvailable(options = {}) {
266
+ const cfg = getConfig();
267
+ if (!cfg.enabled) return false;
268
+
269
+ const ws = resolveWorkspace(options);
270
+ const now = Date.now();
271
+ const cached = availabilityCache.get(ws);
272
+ if (cached && cached.value !== null && now - cached.ts < AVAILABILITY_TTL_MS) {
273
+ return cached.value;
274
+ }
275
+
276
+ const result = await execGraph("query", ["graph_stats"], ws);
277
+ const available = result !== null;
278
+ availabilityCache.set(ws, { value: available, ts: now });
279
+
280
+ if (available) {
281
+ logger.debug({ workspace: ws }, "[graphify] available");
282
+ }
283
+
284
+ return available;
285
+ }
286
+
287
+ // ============================================================================
288
+ // PUBLIC API
289
+ // ============================================================================
290
+
291
+ /**
292
+ * @typedef {Object} CodeGraphOptions
293
+ * @property {string} [workspace] - Explicit workspace path (e.g. from X-Lynkr-Workspace header)
294
+ * @property {string[]} [filePaths] - File paths from conversation (used for auto-detection)
295
+ */
296
+
297
+ /**
298
+ * Get blast radius for a set of file paths.
299
+ *
300
+ * Uses Graphify's `query get_neighbors` on each file to find affected nodes,
301
+ * then aggregates into blast radius metrics.
302
+ *
303
+ * @param {string[]} filePaths — list of file paths to analyze
304
+ * @param {CodeGraphOptions} [options]
305
+ * @returns {Promise<{ affected_files: number, affected_functions: number, affected_tests: number, dependency_depth: number, risk_score: number }|null>}
306
+ */
307
+ async function getBlastRadius(filePaths, options = {}) {
308
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
309
+
310
+ const ws = resolveWorkspace({ ...options, filePaths });
311
+ const cacheKey = `blast:${ws}:${filePaths.sort().join(",")}`;
312
+ const cached = cacheGet(cacheKey);
313
+ if (cached) return cached;
314
+
315
+ // Query neighbors for each file to estimate blast radius
316
+ const result = await execGraph(
317
+ "query",
318
+ ["get_neighbors", "--files", ...filePaths, "--depth", "2", "--json"],
319
+ ws
320
+ );
321
+ if (!result) return null;
322
+
323
+ // Normalize Graphify output into our standard blast radius format
324
+ const nodes = result.nodes || result.neighbors || [];
325
+ const affectedFiles = new Set();
326
+ const affectedFunctions = [];
327
+ const affectedTests = [];
328
+ let maxDepth = 0;
329
+
330
+ for (const node of nodes) {
331
+ const src = node.source_file || node.source || "";
332
+ if (src) affectedFiles.add(src);
333
+ const label = (node.label || node.id || "").toLowerCase();
334
+ if (label.includes("test") || src.includes("test")) {
335
+ affectedTests.push(node);
336
+ } else {
337
+ affectedFunctions.push(node);
338
+ }
339
+ if (node.depth && node.depth > maxDepth) maxDepth = node.depth;
340
+ }
341
+
342
+ // Risk score: based on affected count and depth
343
+ const riskScore = Math.min(100,
344
+ affectedFiles.size * 3 +
345
+ affectedFunctions.length * 2 +
346
+ maxDepth * 5
347
+ );
348
+
349
+ const normalized = {
350
+ affected_files: affectedFiles.size,
351
+ affected_functions: affectedFunctions.length,
352
+ affected_tests: affectedTests.length,
353
+ dependency_depth: maxDepth,
354
+ risk_score: riskScore,
355
+ };
356
+
357
+ cacheSet(cacheKey, normalized);
358
+ return normalized;
359
+ }
360
+
361
+ /**
362
+ * Get relevant file paths that should be included as context.
363
+ *
364
+ * Uses Graphify's BFS-based query to find related nodes.
365
+ *
366
+ * @param {string[]} filePaths — seed file paths
367
+ * @param {number} [maxFiles=20] — maximum files to return
368
+ * @param {CodeGraphOptions} [options]
369
+ * @returns {Promise<string[]|null>}
370
+ */
371
+ async function getRelevantContext(filePaths, maxFiles = 20, options = {}) {
372
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
373
+
374
+ const ws = resolveWorkspace({ ...options, filePaths });
375
+ const cacheKey = `ctx:${ws}:${filePaths.sort().join(",")}:${maxFiles}`;
376
+ const cached = cacheGet(cacheKey);
377
+ if (cached) return cached;
378
+
379
+ // Use query_graph with BFS to find related files
380
+ const searchTerms = filePaths.map(f => path.basename(f, path.extname(f))).join(" ");
381
+ const result = await execGraph(
382
+ "query",
383
+ ["query_graph", searchTerms, "--max-tokens", String(maxFiles * 100), "--json"],
384
+ ws
385
+ );
386
+ if (!result) return null;
387
+
388
+ // Extract unique source files from result nodes
389
+ const nodes = result.nodes || result.results || [];
390
+ const fileSet = new Set();
391
+ for (const node of nodes) {
392
+ const src = node.source_file || node.source || "";
393
+ if (src) fileSet.add(src);
394
+ }
395
+
396
+ const files = [...fileSet].slice(0, maxFiles);
397
+ if (files.length === 0) return null;
398
+
399
+ cacheSet(cacheKey, files);
400
+ return files;
401
+ }
402
+
403
+ /**
404
+ * Get complexity signals for routing decisions.
405
+ *
406
+ * Queries Graphify for god nodes, community cohesion, and structural signals
407
+ * that indicate how complex a code change is.
408
+ *
409
+ * @param {string[]} filePaths — list of file paths to analyze
410
+ * @param {CodeGraphOptions} [options]
411
+ * @returns {Promise<{ blast_radius: number, dependency_depth: number, test_coverage_pct: number, is_infrastructure: boolean, god_node_touched: boolean, community_count: number, cohesion: number }|null>}
412
+ */
413
+ async function getComplexitySignals(filePaths, options = {}) {
414
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
415
+
416
+ const ws = resolveWorkspace({ ...options, filePaths });
417
+ const cacheKey = `complexity:${ws}:${filePaths.sort().join(",")}`;
418
+ const cached = cacheGet(cacheKey);
419
+ if (cached) return cached;
420
+
421
+ // Run parallel queries: neighbors (blast radius) + god_nodes + graph_stats
422
+ const [neighborsResult, godNodesResult, statsResult] = await Promise.all([
423
+ execGraph("query", ["get_neighbors", "--files", ...filePaths, "--depth", "2", "--json"], ws),
424
+ execGraph("query", ["god_nodes", "--json"], ws),
425
+ execGraph("query", ["graph_stats", "--json"], ws),
426
+ ]);
427
+
428
+ // If all queries failed (tool not available), return null
429
+ if (!neighborsResult && !godNodesResult && !statsResult) return null;
430
+
431
+ // Compute blast radius from neighbors
432
+ let blastRadius = 0;
433
+ let depthMax = 0;
434
+ const affectedFiles = new Set();
435
+ if (neighborsResult) {
436
+ const nodes = neighborsResult.nodes || neighborsResult.neighbors || [];
437
+ for (const node of nodes) {
438
+ if (node.source_file) affectedFiles.add(node.source_file);
439
+ if (node.depth && node.depth > depthMax) depthMax = node.depth;
440
+ }
441
+ blastRadius = affectedFiles.size;
442
+ }
443
+
444
+ // Check if any touched file contains a god node
445
+ let godNodeTouched = false;
446
+ if (godNodesResult) {
447
+ const godNodes = godNodesResult.god_nodes || godNodesResult.nodes || godNodesResult || [];
448
+ const godFiles = new Set(
449
+ (Array.isArray(godNodes) ? godNodes : []).map(n => n.source_file || n.source || "")
450
+ );
451
+ godNodeTouched = filePaths.some(fp => {
452
+ const base = path.basename(fp);
453
+ for (const gf of godFiles) {
454
+ if (gf.includes(base) || base.includes(path.basename(gf))) return true;
455
+ }
456
+ return false;
457
+ });
458
+ }
459
+
460
+ // Extract community/cohesion from stats
461
+ let communityCount = 0;
462
+ let cohesion = 1;
463
+ if (statsResult) {
464
+ communityCount = statsResult.communities || statsResult.community_count || 0;
465
+ cohesion = statsResult.avg_cohesion ?? statsResult.cohesion ?? 1;
466
+ }
467
+
468
+ // Detect infrastructure files
469
+ const infraPatterns = [
470
+ /docker/i, /compose/i, /makefile/i, /webpack/i, /babel/i, /eslint/i,
471
+ /tsconfig/i, /package\.json/i, /\.github/i, /ci/i, /cd/i, /deploy/i,
472
+ /terraform/i, /ansible/i, /k8s/i, /kubernetes/i, /helm/i,
473
+ ];
474
+ const isInfrastructure = filePaths.some(fp =>
475
+ infraPatterns.some(pattern => pattern.test(fp))
476
+ );
477
+
478
+ // Estimate test coverage from graph — ratio of test files to affected files
479
+ const testFiles = [...affectedFiles].filter(f => /test|spec|__test/i.test(f));
480
+ const testCoveragePct = affectedFiles.size > 0
481
+ ? Math.round((testFiles.length / affectedFiles.size) * 100)
482
+ : 100; // Assume covered if we can't tell
483
+
484
+ const normalized = {
485
+ blast_radius: blastRadius,
486
+ dependency_depth: depthMax,
487
+ test_coverage_pct: testCoveragePct,
488
+ is_infrastructure: isInfrastructure,
489
+ god_node_touched: godNodeTouched,
490
+ community_count: communityCount,
491
+ cohesion,
492
+ };
493
+
494
+ cacheSet(cacheKey, normalized);
495
+ return normalized;
496
+ }
497
+
498
+ /**
499
+ * Get overall graph statistics.
500
+ *
501
+ * @param {CodeGraphOptions} [options]
502
+ * @returns {Promise<{ total_files: number, total_functions: number, total_edges: number, languages: string[], communities: number, god_nodes: string[] }|null>}
503
+ */
504
+ async function getGraphStats(options = {}) {
505
+ const ws = resolveWorkspace(options);
506
+ const cacheKey = `stats:${ws}`;
507
+ const cached = cacheGet(cacheKey);
508
+ if (cached) return cached;
509
+
510
+ const result = await execGraph("query", ["graph_stats", "--json"], ws);
511
+ if (!result) return null;
512
+
513
+ const normalized = {
514
+ total_files: result.total_files ?? result.files ?? 0,
515
+ total_functions: result.total_functions ?? result.nodes ?? 0,
516
+ total_edges: result.total_edges ?? result.edges ?? 0,
517
+ languages: Array.isArray(result.languages) ? result.languages : [],
518
+ communities: result.communities ?? result.community_count ?? 0,
519
+ god_nodes: Array.isArray(result.god_nodes) ? result.god_nodes.map(n => n.label || n.id || n) : [],
520
+ };
521
+
522
+ cacheSet(cacheKey, normalized);
523
+ return normalized;
524
+ }
525
+
526
+ // ============================================================================
527
+ // EXPORTS
528
+ // ============================================================================
529
+
530
+ module.exports = {
531
+ isAvailable,
532
+ getBlastRadius,
533
+ getRelevantContext,
534
+ getComplexitySignals,
535
+ getGraphStats,
536
+ resolveWorkspace,
537
+ detectWorkspaceFromPaths,
538
+ };