@monoes/monograph 1.3.0 → 1.4.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 (301) hide show
  1. package/.monomind/registry.json +6 -0
  2. package/__tests__/staleness/git-staleness.test.ts +3 -3
  3. package/dist/src/analysis/benchmark.js.map +1 -1
  4. package/dist/src/analysis/changed-files.d.ts.map +1 -1
  5. package/dist/src/analysis/changed-files.js +11 -2
  6. package/dist/src/analysis/changed-files.js.map +1 -1
  7. package/dist/src/analysis/changed-workspaces.d.ts.map +1 -1
  8. package/dist/src/analysis/changed-workspaces.js +11 -3
  9. package/dist/src/analysis/changed-workspaces.js.map +1 -1
  10. package/dist/src/analysis/churn.d.ts +1 -1
  11. package/dist/src/analysis/churn.d.ts.map +1 -1
  12. package/dist/src/analysis/churn.js +56 -10
  13. package/dist/src/analysis/churn.js.map +1 -1
  14. package/dist/src/analysis/cluster-quality.d.ts +4 -4
  15. package/dist/src/analysis/cluster-quality.d.ts.map +1 -1
  16. package/dist/src/analysis/cluster-quality.js +24 -27
  17. package/dist/src/analysis/cluster-quality.js.map +1 -1
  18. package/dist/src/analysis/combined.d.ts.map +1 -1
  19. package/dist/src/analysis/combined.js +2 -0
  20. package/dist/src/analysis/combined.js.map +1 -1
  21. package/dist/src/analysis/feature-flags.d.ts.map +1 -1
  22. package/dist/src/analysis/feature-flags.js +11 -5
  23. package/dist/src/analysis/feature-flags.js.map +1 -1
  24. package/dist/src/analysis/progress.d.ts.map +1 -1
  25. package/dist/src/analysis/project-state.d.ts.map +1 -1
  26. package/dist/src/analysis/shape-extractor.js.map +1 -1
  27. package/dist/src/analysis/suppression-context.d.ts.map +1 -1
  28. package/dist/src/analysis/surprise-scorer.js.map +1 -1
  29. package/dist/src/analysis/targets.d.ts.map +1 -1
  30. package/dist/src/analysis/targets.js +4 -5
  31. package/dist/src/analysis/targets.js.map +1 -1
  32. package/dist/src/analysis/worker-pool.d.ts.map +1 -1
  33. package/dist/src/analysis/worker-pool.js.map +1 -1
  34. package/dist/src/analyze/unused-deps.js.map +1 -1
  35. package/dist/src/analyze/unused-exports.js.map +1 -1
  36. package/dist/src/analyze/unused-files.js.map +1 -1
  37. package/dist/src/cache/augment-cache.js.map +1 -1
  38. package/dist/src/cache/extraction-cache.d.ts +21 -0
  39. package/dist/src/cache/extraction-cache.d.ts.map +1 -1
  40. package/dist/src/cache/extraction-cache.js +62 -6
  41. package/dist/src/cache/extraction-cache.js.map +1 -1
  42. package/dist/src/check/index.js.map +1 -1
  43. package/dist/src/cli/check-update.js.map +1 -1
  44. package/dist/src/cli/doctor.d.ts.map +1 -1
  45. package/dist/src/cli/doctor.js +6 -10
  46. package/dist/src/cli/doctor.js.map +1 -1
  47. package/dist/src/config/boundary-config.js.map +1 -1
  48. package/dist/src/config/config-parsing.d.ts +1 -1
  49. package/dist/src/config/config-parsing.d.ts.map +1 -1
  50. package/dist/src/config/migration-types.d.ts +2 -2
  51. package/dist/src/config/migration-types.d.ts.map +1 -1
  52. package/dist/src/config/migration-types.js.map +1 -1
  53. package/dist/src/config/used-class-members.js.map +1 -1
  54. package/dist/src/config/workspace.js.map +1 -1
  55. package/dist/src/coverage/upload-source-maps.js.map +1 -1
  56. package/dist/src/duplicates/detect/filtering.d.ts.map +1 -1
  57. package/dist/src/duplicates/shingle-filter.js.map +1 -1
  58. package/dist/src/error/fallow-error.d.ts.map +1 -1
  59. package/dist/src/error/fallow-error.js.map +1 -1
  60. package/dist/src/error.d.ts.map +1 -1
  61. package/dist/src/error.js.map +1 -1
  62. package/dist/src/export/analysis-json.js +1 -1
  63. package/dist/src/export/analysis-json.js.map +1 -1
  64. package/dist/src/export/badge.js.map +1 -1
  65. package/dist/src/export/dot.js.map +1 -1
  66. package/dist/src/export/sarif.d.ts.map +1 -1
  67. package/dist/src/export/sarif.js +13 -2
  68. package/dist/src/export/sarif.js.map +1 -1
  69. package/dist/src/fix/orchestrator.js.map +1 -1
  70. package/dist/src/graph/baseline.d.ts.map +1 -1
  71. package/dist/src/graph/baseline.js +29 -20
  72. package/dist/src/graph/baseline.js.map +1 -1
  73. package/dist/src/graph/cycle-detection.js.map +1 -1
  74. package/dist/src/graph/dead-code.d.ts +7 -2
  75. package/dist/src/graph/dead-code.d.ts.map +1 -1
  76. package/dist/src/graph/dead-code.js +19 -16
  77. package/dist/src/graph/dead-code.js.map +1 -1
  78. package/dist/src/graph/dfs.js.map +1 -1
  79. package/dist/src/graph/edge-filter.js.map +1 -1
  80. package/dist/src/graph/health-score.d.ts.map +1 -1
  81. package/dist/src/graph/health-score.js +11 -14
  82. package/dist/src/graph/health-score.js.map +1 -1
  83. package/dist/src/graph/hotspots.d.ts.map +1 -1
  84. package/dist/src/graph/hotspots.js +8 -4
  85. package/dist/src/graph/hotspots.js.map +1 -1
  86. package/dist/src/graph/import-chain.js.map +1 -1
  87. package/dist/src/graph/merge.js.map +1 -1
  88. package/dist/src/graph/node-search.js.map +1 -1
  89. package/dist/src/graph/node-types.d.ts.map +1 -1
  90. package/dist/src/graph/pagerank.d.ts.map +1 -1
  91. package/dist/src/graph/pagerank.js +10 -1
  92. package/dist/src/graph/pagerank.js.map +1 -1
  93. package/dist/src/graph/regex-search.js.map +1 -1
  94. package/dist/src/graph/regression.d.ts.map +1 -1
  95. package/dist/src/graph/resolve/discover-types.js.map +1 -1
  96. package/dist/src/graph/resolve/types.d.ts +3 -3
  97. package/dist/src/graph/resolve/types.d.ts.map +1 -1
  98. package/dist/src/graph/statistics.d.ts +3 -2
  99. package/dist/src/graph/statistics.d.ts.map +1 -1
  100. package/dist/src/graph/statistics.js +18 -7
  101. package/dist/src/graph/statistics.js.map +1 -1
  102. package/dist/src/graph/stats.d.ts.map +1 -1
  103. package/dist/src/graph/stats.js +2 -1
  104. package/dist/src/graph/stats.js.map +1 -1
  105. package/dist/src/groups/group-search.d.ts.map +1 -1
  106. package/dist/src/groups/group-search.js +9 -18
  107. package/dist/src/groups/group-search.js.map +1 -1
  108. package/dist/src/health/complexity-findings.js.map +1 -1
  109. package/dist/src/health/grouping.js.map +1 -1
  110. package/dist/src/health/health-report-types.js.map +1 -1
  111. package/dist/src/health/health-report.js.map +1 -1
  112. package/dist/src/health/targets.js.map +1 -1
  113. package/dist/src/index.d.ts +3 -0
  114. package/dist/src/index.d.ts.map +1 -1
  115. package/dist/src/index.js +3 -0
  116. package/dist/src/index.js.map +1 -1
  117. package/dist/src/ingest/transcribe.js.map +1 -1
  118. package/dist/src/ingest/url-ingest.js.map +1 -1
  119. package/dist/src/license/manager.d.ts.map +1 -1
  120. package/dist/src/license/manager.js.map +1 -1
  121. package/dist/src/lsp/diagnostics-push.js.map +1 -1
  122. package/dist/src/mcp-tools/impact.d.ts.map +1 -1
  123. package/dist/src/mcp-tools/impact.js +7 -1
  124. package/dist/src/mcp-tools/impact.js.map +1 -1
  125. package/dist/src/mcp-tools/list-repos.d.ts +1 -1
  126. package/dist/src/mcp-tools/list-repos.d.ts.map +1 -1
  127. package/dist/src/mcp-tools/query.d.ts +1 -1
  128. package/dist/src/mcp-tools/query.d.ts.map +1 -1
  129. package/dist/src/parsers/c.js.map +1 -1
  130. package/dist/src/parsers/cpp.js.map +1 -1
  131. package/dist/src/parsers/csharp.js.map +1 -1
  132. package/dist/src/parsers/dart.js.map +1 -1
  133. package/dist/src/parsers/go.js.map +1 -1
  134. package/dist/src/parsers/java.js.map +1 -1
  135. package/dist/src/parsers/kotlin.js.map +1 -1
  136. package/dist/src/parsers/loader.js.map +1 -1
  137. package/dist/src/parsers/php.js.map +1 -1
  138. package/dist/src/parsers/python.js.map +1 -1
  139. package/dist/src/parsers/ruby.js.map +1 -1
  140. package/dist/src/parsers/rust.js.map +1 -1
  141. package/dist/src/parsers/swift.js.map +1 -1
  142. package/dist/src/parsers/typescript.js.map +1 -1
  143. package/dist/src/parsers/vue.js.map +1 -1
  144. package/dist/src/pipeline/effort.js.map +1 -1
  145. package/dist/src/pipeline/orchestrator.d.ts.map +1 -1
  146. package/dist/src/pipeline/orchestrator.js +85 -24
  147. package/dist/src/pipeline/orchestrator.js.map +1 -1
  148. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts +11 -0
  149. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts.map +1 -0
  150. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js +96 -0
  151. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js.map +1 -0
  152. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts +3 -0
  153. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts.map +1 -0
  154. package/dist/src/pipeline/phases/bridge-adapters/registry.js +13 -0
  155. package/dist/src/pipeline/phases/bridge-adapters/registry.js.map +1 -0
  156. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts +10 -0
  157. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts.map +1 -0
  158. package/dist/src/pipeline/phases/bridge-adapters/tauri.js +104 -0
  159. package/dist/src/pipeline/phases/bridge-adapters/tauri.js.map +1 -0
  160. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts +30 -0
  161. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts.map +1 -0
  162. package/dist/src/pipeline/phases/bridge-adapters/types.js +2 -0
  163. package/dist/src/pipeline/phases/bridge-adapters/types.js.map +1 -0
  164. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts +10 -0
  165. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts.map +1 -0
  166. package/dist/src/pipeline/phases/bridge-adapters/wails.js +27 -0
  167. package/dist/src/pipeline/phases/bridge-adapters/wails.js.map +1 -0
  168. package/dist/src/pipeline/phases/bridge-resolver.d.ts +23 -0
  169. package/dist/src/pipeline/phases/bridge-resolver.d.ts.map +1 -0
  170. package/dist/src/pipeline/phases/bridge-resolver.js +70 -0
  171. package/dist/src/pipeline/phases/bridge-resolver.js.map +1 -0
  172. package/dist/src/pipeline/phases/cross-file.d.ts.map +1 -1
  173. package/dist/src/pipeline/phases/cross-file.js +70 -27
  174. package/dist/src/pipeline/phases/cross-file.js.map +1 -1
  175. package/dist/src/pipeline/phases/leiden.js.map +1 -1
  176. package/dist/src/pipeline/phases/parse.d.ts.map +1 -1
  177. package/dist/src/pipeline/phases/parse.js +58 -17
  178. package/dist/src/pipeline/phases/parse.js.map +1 -1
  179. package/dist/src/pipeline/phases/scan.d.ts.map +1 -1
  180. package/dist/src/pipeline/phases/scan.js +12 -5
  181. package/dist/src/pipeline/phases/scan.js.map +1 -1
  182. package/dist/src/pipeline/phases/scope-resolution.d.ts +13 -0
  183. package/dist/src/pipeline/phases/scope-resolution.d.ts.map +1 -1
  184. package/dist/src/pipeline/phases/scope-resolution.js +18 -4
  185. package/dist/src/pipeline/phases/scope-resolution.js.map +1 -1
  186. package/dist/src/pipeline/progress.d.ts.map +1 -1
  187. package/dist/src/pipeline/runner.d.ts.map +1 -1
  188. package/dist/src/pipeline/runner.js.map +1 -1
  189. package/dist/src/pipeline/types.d.ts +2 -0
  190. package/dist/src/pipeline/types.d.ts.map +1 -1
  191. package/dist/src/pipeline/types.js +1 -0
  192. package/dist/src/pipeline/types.js.map +1 -1
  193. package/dist/src/regression/baseline.js.map +1 -1
  194. package/dist/src/regression/outcome.js.map +1 -1
  195. package/dist/src/report/badge-svg.js.map +1 -1
  196. package/dist/src/report/duplication-grouping.js.map +1 -1
  197. package/dist/src/report/human-check.js.map +1 -1
  198. package/dist/src/report/human-dupes.js.map +1 -1
  199. package/dist/src/report/human-health.js.map +1 -1
  200. package/dist/src/report/json-builders.js.map +1 -1
  201. package/dist/src/report/number-format.js.map +1 -1
  202. package/dist/src/reporting/graph-report.d.ts.map +1 -1
  203. package/dist/src/reporting/graph-report.js +8 -9
  204. package/dist/src/reporting/graph-report.js.map +1 -1
  205. package/dist/src/scope-resolution/evidence-weights.d.ts.map +1 -1
  206. package/dist/src/search/hybrid-query.js.map +1 -1
  207. package/dist/src/security/safe-fetch.js.map +1 -1
  208. package/dist/src/staleness/git-staleness.d.ts.map +1 -1
  209. package/dist/src/staleness/git-staleness.js +5 -2
  210. package/dist/src/staleness/git-staleness.js.map +1 -1
  211. package/dist/src/storage/db.d.ts +13 -1
  212. package/dist/src/storage/db.d.ts.map +1 -1
  213. package/dist/src/storage/db.js +23 -2
  214. package/dist/src/storage/db.js.map +1 -1
  215. package/dist/src/storage/edge-store.d.ts +6 -0
  216. package/dist/src/storage/edge-store.d.ts.map +1 -1
  217. package/dist/src/storage/edge-store.js +8 -1
  218. package/dist/src/storage/edge-store.js.map +1 -1
  219. package/dist/src/storage/fts-store.d.ts.map +1 -1
  220. package/dist/src/storage/fts-store.js +31 -9
  221. package/dist/src/storage/fts-store.js.map +1 -1
  222. package/dist/src/storage/node-store.js.map +1 -1
  223. package/dist/src/storage/schema.d.ts +5 -0
  224. package/dist/src/storage/schema.d.ts.map +1 -1
  225. package/dist/src/storage/schema.js +25 -0
  226. package/dist/src/storage/schema.js.map +1 -1
  227. package/dist/src/storage/unlinked-refs.js.map +1 -1
  228. package/dist/src/types.d.ts +2 -2
  229. package/dist/src/types.d.ts.map +1 -1
  230. package/dist/src/types.js.map +1 -1
  231. package/dist/src/watch/watcher.d.ts.map +1 -1
  232. package/dist/src/watch/watcher.js +12 -2
  233. package/dist/src/watch/watcher.js.map +1 -1
  234. package/dist/src/web/analyze-api.d.ts.map +1 -1
  235. package/dist/src/web/analyze-api.js +3 -1
  236. package/dist/src/web/analyze-api.js.map +1 -1
  237. package/dist/src/web/api.d.ts +3 -0
  238. package/dist/src/web/api.d.ts.map +1 -1
  239. package/dist/src/web/api.js +59 -20
  240. package/dist/src/web/api.js.map +1 -1
  241. package/dist/src/web/async-jobs.d.ts.map +1 -1
  242. package/dist/src/web/async-jobs.js +15 -0
  243. package/dist/src/web/async-jobs.js.map +1 -1
  244. package/dist/src/web/react-ui.js +2 -2
  245. package/dist/src/wiki/providers.js.map +1 -1
  246. package/dist/src/wiki/wiki-generator.d.ts.map +1 -1
  247. package/dist/src/wiki/wiki-generator.js +9 -6
  248. package/dist/src/wiki/wiki-generator.js.map +1 -1
  249. package/dist/tsconfig.tsbuildinfo +1 -1
  250. package/package.json +5 -2
  251. package/src/__tests__/graph/dead-code.test.ts +27 -7
  252. package/src/__tests__/pipeline/phases/bridge-adapters/electron-ipc.test.ts +87 -0
  253. package/src/__tests__/pipeline/phases/bridge-adapters/tauri.test.ts +116 -0
  254. package/src/__tests__/pipeline/phases/bridge-adapters/wails.test.ts +81 -0
  255. package/src/__tests__/pipeline/phases/bridge-resolver.test.ts +110 -0
  256. package/src/analysis/changed-files.ts +14 -2
  257. package/src/analysis/changed-workspaces.ts +13 -4
  258. package/src/analysis/churn.ts +57 -10
  259. package/src/analysis/cluster-quality.ts +25 -26
  260. package/src/analysis/combined.ts +2 -0
  261. package/src/analysis/feature-flags.ts +10 -5
  262. package/src/analysis/targets.ts +4 -5
  263. package/src/cache/extraction-cache.ts +59 -6
  264. package/src/cli/doctor.ts +6 -10
  265. package/src/export/analysis-json.ts +1 -1
  266. package/src/export/sarif.ts +11 -2
  267. package/src/graph/baseline.ts +29 -20
  268. package/src/graph/dead-code.ts +19 -16
  269. package/src/graph/health-score.ts +13 -16
  270. package/src/graph/hotspots.ts +8 -4
  271. package/src/graph/pagerank.ts +10 -1
  272. package/src/graph/statistics.ts +19 -7
  273. package/src/graph/stats.ts +2 -1
  274. package/src/groups/group-search.ts +9 -27
  275. package/src/index.ts +3 -0
  276. package/src/mcp-tools/impact.ts +7 -1
  277. package/src/pipeline/orchestrator.ts +87 -26
  278. package/src/pipeline/phases/bridge-adapters/electron-ipc.ts +91 -0
  279. package/src/pipeline/phases/bridge-adapters/registry.ts +14 -0
  280. package/src/pipeline/phases/bridge-adapters/tauri.ts +103 -0
  281. package/src/pipeline/phases/bridge-adapters/types.ts +31 -0
  282. package/src/pipeline/phases/bridge-adapters/wails.ts +33 -0
  283. package/src/pipeline/phases/bridge-resolver.ts +77 -0
  284. package/src/pipeline/phases/cross-file.ts +75 -27
  285. package/src/pipeline/phases/parse.ts +60 -17
  286. package/src/pipeline/phases/scan.ts +11 -5
  287. package/src/pipeline/phases/scope-resolution.ts +19 -4
  288. package/src/pipeline/types.ts +3 -0
  289. package/src/reporting/graph-report.ts +8 -9
  290. package/src/staleness/git-staleness.ts +5 -2
  291. package/src/storage/db.ts +35 -1
  292. package/src/storage/edge-store.ts +8 -1
  293. package/src/storage/fts-store.ts +31 -9
  294. package/src/storage/schema.ts +30 -0
  295. package/src/watch/watcher.ts +11 -2
  296. package/src/web/analyze-api.ts +3 -1
  297. package/src/web/api.ts +62 -22
  298. package/src/web/async-jobs.ts +15 -0
  299. package/src/web/react-ui.ts +2 -2
  300. package/src/wiki/wiki-generator.ts +9 -6
  301. package/tsconfig.json +2 -1
@@ -98,46 +98,45 @@ export function silhouetteScore(
98
98
  }
99
99
 
100
100
  /**
101
- * Compute Newman–Girvan modularity Q.
101
+ * Compute Newman–Girvan modularity Q using the community-level formula:
102
+ * Q = Σ_c [e_c / m - (a_c / 2m)²]
103
+ * where e_c = intra-community edges, a_c = sum of degrees in community c, m = total edges.
102
104
  *
103
- * Q = (1/2m) * Σ_{ij} [A_ij - k_i*k_j/(2m)] * δ(c_i, c_j)
104
- *
105
- * where m = total edge count, k_i = degree of node i, A_ij = adjacency.
105
+ * O(E + N) instead of O().
106
106
  * Returns a value in (-0.5, 1].
107
107
  */
108
108
  export function modularityScore(
109
109
  memberships: Map<string, number>,
110
110
  edges: Edge[],
111
111
  ): number {
112
+ if (edges.length === 0 || memberships.size === 0) return 0;
113
+
112
114
  const adj = buildAdjacency(edges);
115
+ const m = edges.length; // directed edges count (each undirected pair counted once in input)
113
116
 
114
- // Degree map (undirected) use adj to count undirected edges correctly
115
- const degree = new Map<string, number>();
116
- for (const [nodeId, neighbors] of adj) {
117
- degree.set(nodeId, neighbors.size);
117
+ // Sum of degrees per community and intra-community edge count
118
+ const communityDegreeSum = new Map<number, number>();
119
+ const intraCommunityEdges = new Map<number, number>();
120
+
121
+ for (const [nodeId, cid] of memberships) {
122
+ const deg = adj.get(nodeId)?.size ?? 0;
123
+ communityDegreeSum.set(cid, (communityDegreeSum.get(cid) ?? 0) + deg);
118
124
  }
119
125
 
120
- // m_undirected: each undirected edge is counted once in adj (both directions added)
121
- // so sum of degrees = 2 * m_undirected
122
- const totalDegree = [...degree.values()].reduce((s, d) => s + d, 0);
123
- const m = totalDegree / 2;
124
- if (m === 0) return 0;
126
+ for (const { sourceId, targetId } of edges) {
127
+ const cs = memberships.get(sourceId);
128
+ const ct = memberships.get(targetId);
129
+ if (cs !== undefined && ct !== undefined && cs === ct) {
130
+ intraCommunityEdges.set(cs, (intraCommunityEdges.get(cs) ?? 0) + 1);
131
+ }
132
+ }
125
133
 
126
134
  const twoM = 2 * m;
127
135
  let Q = 0;
128
-
129
- const nodes = [...memberships.keys()];
130
- for (let i = 0; i < nodes.length; i++) {
131
- const u = nodes[i]!;
132
- for (let j = i + 1; j < nodes.length; j++) {
133
- const v = nodes[j]!;
134
- if (memberships.get(u) !== memberships.get(v)) continue;
135
- const aij = adj.get(u)?.has(v) ? 1 : 0;
136
- const ki = degree.get(u) ?? 0;
137
- const kj = degree.get(v) ?? 0;
138
- Q += aij - (ki * kj) / twoM;
139
- }
136
+ for (const [cid, degSum] of communityDegreeSum) {
137
+ const ec = intraCommunityEdges.get(cid) ?? 0;
138
+ Q += ec / m - (degSum / twoM) ** 2;
140
139
  }
141
140
 
142
- return Q / m; // normalise by m (equivalent to 1/(2m) summed both directions)
141
+ return Q;
143
142
  }
@@ -29,6 +29,8 @@ export function resolveAnalyses(opts: CombinedOptions): Set<AnalysisKind> {
29
29
  return result;
30
30
  }
31
31
 
32
+ // monolean: stub — resolves which analyses to run but doesn't execute them.
33
+ // Wire up when dead-code/duplication detectors are called from a unified entry point.
32
34
  export async function runCombined(
33
35
  db: MonographDb,
34
36
  opts: CombinedOptions
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync, statSync } from 'fs';
1
+ import { readFileSync, readdirSync, lstatSync } from 'fs';
2
2
  import { join, extname } from 'path';
3
3
 
4
4
  export type FlagKind = 'EnvironmentVariable' | 'SdkCall' | 'ConfigObject';
@@ -170,13 +170,18 @@ export function crossReferenceWithDeadCode(
170
170
  }
171
171
 
172
172
  function walkDir(dir: string, fn: (filePath: string) => void): void {
173
- let entries: string[];
174
- try { entries = readdirSync(dir); } catch { return; }
175
- for (const entry of entries) {
173
+ let dirents: import('fs').Dirent[];
174
+ try { dirents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
175
+ for (const dirent of dirents) {
176
+ const entry = dirent.name;
176
177
  if (entry === 'node_modules' || entry === '.git' || entry === 'dist' || entry === 'build') continue;
178
+ // Never follow symlinks — avoids infinite recursion on a symlink pointing to
179
+ // an ancestor directory, and avoids indexing a symlinked external tree as if
180
+ // it were part of the repo. Matches scan.ts's walk().
181
+ if (dirent.isSymbolicLink()) continue;
177
182
  const full = join(dir, entry);
178
183
  let st;
179
- try { st = statSync(full); } catch { continue; }
184
+ try { st = lstatSync(full); } catch { continue; }
180
185
  if (st.isDirectory()) walkDir(full, fn);
181
186
  else if (st.isFile()) fn(full);
182
187
  }
@@ -44,13 +44,12 @@ export function computeRefactoringTargets(db: MonographDb): RefactoringTargetsRe
44
44
  n.id,
45
45
  n.file_path,
46
46
  n.properties,
47
- COUNT(DISTINCT e_in.id) as in_degree,
48
- COUNT(DISTINCT e_out.id) as out_degree
47
+ COALESCE(d_in.cnt, 0) as in_degree,
48
+ COALESCE(d_out.cnt, 0) as out_degree
49
49
  FROM nodes n
50
- LEFT JOIN edges e_in ON e_in.target_id = n.id
51
- LEFT JOIN edges e_out ON e_out.source_id = n.id
50
+ LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges GROUP BY target_id) d_in ON d_in.target_id = n.id
51
+ LEFT JOIN (SELECT source_id, COUNT(*) AS cnt FROM edges GROUP BY source_id) d_out ON d_out.source_id = n.id
52
52
  WHERE n.label = 'File' AND n.file_path IS NOT NULL
53
- GROUP BY n.id
54
53
  `).all() as FileNodeRow[];
55
54
 
56
55
  if (rows.length === 0) {
@@ -1,14 +1,25 @@
1
1
  import { createHash } from 'crypto';
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
2
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, mkdirSync, existsSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import type { MonographNode, MonographEdge } from '../types.js';
5
5
 
6
+ /**
7
+ * Bump this whenever the parser/extractor output format changes in a way that
8
+ * would make previously-cached nodes/edges stale or wrong (e.g. a parser bugfix,
9
+ * a change to what symbols get extracted, a change to node/edge shape). Any
10
+ * cached entry written under an older version is treated as a miss on read, so
11
+ * bumping this effectively invalidates the entire on-disk extraction cache.
12
+ */
13
+ export const EXTRACTION_CACHE_VERSION = 1;
14
+
6
15
  export interface CacheEntry {
7
16
  fileHash: string;
8
17
  mtimeMs?: number;
9
18
  size?: number;
10
19
  nodes: MonographNode[];
11
20
  edges: MonographEdge[];
21
+ /** Extraction format version this entry was written under — see EXTRACTION_CACHE_VERSION. */
22
+ cacheVersion?: number;
12
23
  }
13
24
 
14
25
  export class ExtractionCache {
@@ -32,6 +43,46 @@ export class ExtractionCache {
32
43
  return join(this.dir, `${key}.json`);
33
44
  }
34
45
 
46
+ /** tmp+rename write — cache corruption is low-stakes but a torn write from a
47
+ * killed process still shouldn't poison the next read. */
48
+ private writeAtomic(p: string, data: string): void {
49
+ const tmp = `${p}.${process.pid}.tmp`;
50
+ try {
51
+ writeFileSync(tmp, data);
52
+ renameSync(tmp, p);
53
+ } catch {
54
+ try { unlinkSync(tmp); } catch { /* ignore */ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Delete cache entries whose mtime exceeds maxAgeMs (default 30 days).
60
+ * Covers files that were deleted/renamed (their cache entry is now orphaned
61
+ * and would otherwise sit on disk forever) as well as entries simply not
62
+ * touched in a long time. Single readdir + stat pass over the cache dir.
63
+ * Returns the number of entries removed.
64
+ */
65
+ prune(maxAgeMs = 30 * 24 * 60 * 60 * 1000): number {
66
+ let removed = 0;
67
+ let entries: string[];
68
+ try {
69
+ entries = readdirSync(this.dir).filter((f) => f.endsWith('.json') && !f.startsWith('._'));
70
+ } catch {
71
+ return 0;
72
+ }
73
+ const cutoff = Date.now() - maxAgeMs;
74
+ for (const f of entries) {
75
+ const p = join(this.dir, f);
76
+ try {
77
+ if (statSync(p).mtimeMs < cutoff) {
78
+ unlinkSync(p);
79
+ removed++;
80
+ }
81
+ } catch { /* ignore */ }
82
+ }
83
+ return removed;
84
+ }
85
+
35
86
  /**
36
87
  * Fast-path: check mtime+size before falling back to content hash.
37
88
  * Returns cached entry if file hasn't changed, null on miss.
@@ -41,6 +92,7 @@ export class ExtractionCache {
41
92
  if (!existsSync(p)) return null;
42
93
  try {
43
94
  const entry: CacheEntry = JSON.parse(readFileSync(p, 'utf-8'));
95
+ if (entry.cacheVersion !== EXTRACTION_CACHE_VERSION) return null;
44
96
  const st = statSync(filePath);
45
97
  if (entry.mtimeMs === st.mtimeMs && entry.size === st.size) return entry;
46
98
  // mtime/size differ or missing — recheck content hash
@@ -49,7 +101,7 @@ export class ExtractionCache {
49
101
  // Hash matches — update entry with current mtime+size for next run
50
102
  entry.mtimeMs = st.mtimeMs;
51
103
  entry.size = st.size;
52
- try { writeFileSync(p, JSON.stringify(entry)); } catch { /* non-fatal */ }
104
+ try { this.writeAtomic(p, JSON.stringify(entry)); } catch { /* non-fatal */ }
53
105
  return entry;
54
106
  } catch { return null; }
55
107
  }
@@ -59,6 +111,7 @@ export class ExtractionCache {
59
111
  if (!existsSync(p)) return null;
60
112
  try {
61
113
  const entry: CacheEntry = JSON.parse(readFileSync(p, 'utf-8'));
114
+ if (entry.cacheVersion !== EXTRACTION_CACHE_VERSION) return null;
62
115
  return entry.fileHash === fileHash ? entry : null;
63
116
  } catch { return null; }
64
117
  }
@@ -67,21 +120,21 @@ export class ExtractionCache {
67
120
  let mtimeMs: number | undefined;
68
121
  let size: number | undefined;
69
122
  try { const st = statSync(filePath); mtimeMs = st.mtimeMs; size = st.size; } catch { /* ignore */ }
70
- const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges };
71
- writeFileSync(this.entryPath(filePath), JSON.stringify(entry));
123
+ const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges, cacheVersion: EXTRACTION_CACHE_VERSION };
124
+ this.writeAtomic(this.entryPath(filePath), JSON.stringify(entry));
72
125
  }
73
126
 
74
127
  setDeferred(filePath: string, fileHash: string, nodes: MonographNode[], edges: MonographEdge[]): void {
75
128
  let mtimeMs: number | undefined;
76
129
  let size: number | undefined;
77
130
  try { const st = statSync(filePath); mtimeMs = st.mtimeMs; size = st.size; } catch { /* ignore */ }
78
- const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges };
131
+ const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges, cacheVersion: EXTRACTION_CACHE_VERSION };
79
132
  this.pending.push({ path: this.entryPath(filePath), data: JSON.stringify(entry) });
80
133
  }
81
134
 
82
135
  flush(): void {
83
136
  for (const { path, data } of this.pending) {
84
- try { writeFileSync(path, data); } catch { /* non-fatal */ }
137
+ try { this.writeAtomic(path, data); } catch { /* non-fatal */ }
85
138
  }
86
139
  this.pending = [];
87
140
  }
package/src/cli/doctor.ts CHANGED
@@ -51,12 +51,9 @@ function checkDbExists(dbPath: string): DoctorCheck {
51
51
  };
52
52
  }
53
53
 
54
- function checkDbReadable(dbPath: string): DoctorCheck {
54
+ async function checkDbReadable(dbPath: string): Promise<DoctorCheck> {
55
55
  try {
56
- // Dynamic require keeps this check lazy so missing better-sqlite3 doesn't
57
- // crash the entire doctor invocation.
58
- // eslint-disable-next-line @typescript-eslint/no-var-requires
59
- const Database = require('better-sqlite3') as typeof import('better-sqlite3');
56
+ const Database = (await import('better-sqlite3')).default;
60
57
  const db = new Database(dbPath, { readonly: true });
61
58
  db.prepare('SELECT 1').get();
62
59
  db.close();
@@ -67,10 +64,9 @@ function checkDbReadable(dbPath: string): DoctorCheck {
67
64
  }
68
65
  }
69
66
 
70
- function checkNodeCount(dbPath: string): DoctorCheck {
67
+ async function checkNodeCount(dbPath: string): Promise<DoctorCheck> {
71
68
  try {
72
- // eslint-disable-next-line @typescript-eslint/no-var-requires
73
- const Database = require('better-sqlite3') as typeof import('better-sqlite3');
69
+ const Database = (await import('better-sqlite3')).default;
74
70
  const db = new Database(dbPath, { readonly: true });
75
71
  const row = db.prepare('SELECT COUNT(*) AS n FROM nodes').get() as { n: number } | undefined;
76
72
  db.close();
@@ -159,8 +155,8 @@ export async function runDoctor(repoPath: string): Promise<DoctorResult> {
159
155
 
160
156
  // 3. DB readable (only if DB exists)
161
157
  if (dbExistsCheck.status !== 'error') {
162
- checks.push(checkDbReadable(dbPath));
163
- checks.push(checkNodeCount(dbPath));
158
+ checks.push(await checkDbReadable(dbPath));
159
+ checks.push(await checkNodeCount(dbPath));
164
160
  } else {
165
161
  checks.push({
166
162
  name: 'SQLite DB readable',
@@ -82,6 +82,6 @@ export function buildDuplicationResultsEnvelope(
82
82
 
83
83
  export function stripRootPrefix(obj: unknown, rootPrefix: string): unknown {
84
84
  const json = JSON.stringify(obj);
85
- const escaped = rootPrefix.replace(/[/\\]/g, s => `\\${s}`);
85
+ const escaped = rootPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
86
  return JSON.parse(json.replace(new RegExp(escaped, 'g'), ''));
87
87
  }
@@ -1,6 +1,15 @@
1
1
  import type Database from 'better-sqlite3';
2
2
  import { createHash } from 'crypto';
3
- import { relative } from 'path';
3
+ import { readFileSync } from 'fs';
4
+ import { relative, dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const PKG_VERSION = (() => {
9
+ try {
10
+ return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')).version ?? '0.0.0';
11
+ } catch { return '0.0.0'; }
12
+ })();
4
13
 
5
14
  export interface SarifRule {
6
15
  id: string;
@@ -231,6 +240,6 @@ export function exportHealthSarif(findings: SarifHealthFinding[], root?: string)
231
240
  return {
232
241
  $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
233
242
  version: '2.1.0',
234
- runs: [{ tool: { driver: { name: 'monograph-health', version: '1.0.0', rules } }, results }],
243
+ runs: [{ tool: { driver: { name: 'monograph-health', version: PKG_VERSION, rules } }, results }],
235
244
  };
236
245
  }
@@ -126,15 +126,19 @@ export function extractFindingsFromDb(
126
126
 
127
127
  // God nodes (degree > 50)
128
128
  const gods = db.prepare(`
129
- SELECT n.id, n.name, n.file_path, n.start_line,
130
- COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as degree
129
+ SELECT n.id, n.name, n.file_path, n.start_line, d.deg as degree
131
130
  FROM nodes n
132
- LEFT JOIN edges e1 ON e1.source_id = n.id
133
- LEFT JOIN edges e2 ON e2.target_id = n.id
134
- GROUP BY n.id
135
- HAVING degree > 50
136
- ORDER BY degree DESC
137
- LIMIT 100
131
+ JOIN (
132
+ SELECT node_id, SUM(cnt) AS deg FROM (
133
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
134
+ UNION ALL
135
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
136
+ ) GROUP BY node_id
137
+ HAVING deg > 50
138
+ ORDER BY deg DESC
139
+ LIMIT 100
140
+ ) d ON d.node_id = n.id
141
+ ORDER BY d.deg DESC
138
142
  `).all() as { id: string; name: string; file_path: string | null; start_line: number | null; degree: number }[];
139
143
 
140
144
  for (const n of gods) {
@@ -173,20 +177,25 @@ export function extractFindingsFromDb(
173
177
  });
174
178
  }
175
179
 
176
- // Bridge nodes: nodes whose edges span more than one community_id
177
- // Use JOIN+GROUP BY instead of correlated subqueries to avoid N*2 subquery executions
180
+ // Bridge nodes: nodes whose edges span 2+ distinct foreign communities.
181
+ // Pre-aggregate cross-community counts per direction to avoid 4-way cross product.
178
182
  const bridges = db.prepare(`
179
- SELECT n.id, n.name, n.file_path, n.start_line,
180
- COUNT(DISTINCT CASE WHEN e_out.source_id = n.id AND n_tgt.community_id != n.community_id THEN n_tgt.community_id END) +
181
- COUNT(DISTINCT CASE WHEN e_in.target_id = n.id AND n_src.community_id != n.community_id THEN n_src.community_id END) AS cross_communities
183
+ SELECT n.id, n.name, n.file_path, n.start_line
182
184
  FROM nodes n
183
- LEFT JOIN edges e_out ON e_out.source_id = n.id
184
- LEFT JOIN nodes n_tgt ON n_tgt.id = e_out.target_id AND n_tgt.community_id IS NOT NULL
185
- LEFT JOIN edges e_in ON e_in.target_id = n.id
186
- LEFT JOIN nodes n_src ON n_src.id = e_in.source_id AND n_src.community_id IS NOT NULL
187
- WHERE n.community_id IS NOT NULL
188
- GROUP BY n.id
189
- HAVING cross_communities >= 2
185
+ JOIN (
186
+ SELECT node_id, COUNT(DISTINCT foreign_community) AS cross_cnt FROM (
187
+ SELECT e.source_id AS node_id, n2.community_id AS foreign_community
188
+ FROM edges e
189
+ JOIN nodes n1 ON n1.id = e.source_id AND n1.community_id IS NOT NULL
190
+ JOIN nodes n2 ON n2.id = e.target_id AND n2.community_id IS NOT NULL AND n2.community_id != n1.community_id
191
+ UNION ALL
192
+ SELECT e.target_id AS node_id, n1.community_id AS foreign_community
193
+ FROM edges e
194
+ JOIN nodes n1 ON n1.id = e.source_id AND n1.community_id IS NOT NULL
195
+ JOIN nodes n2 ON n2.id = e.target_id AND n2.community_id IS NOT NULL AND n1.community_id != n2.community_id
196
+ ) GROUP BY node_id
197
+ HAVING cross_cnt >= 2
198
+ ) b ON b.node_id = n.id
190
199
  LIMIT 100
191
200
  `).all() as { id: string; name: string; file_path: string | null; start_line: number | null }[];
192
201
 
@@ -11,8 +11,13 @@ import type { MonographDb } from '../storage/db.js';
11
11
  * - Only exported — private locals are internal by design
12
12
  * - Skips entry-point, test, dist, and node_modules paths
13
13
  * - Requires zero inbound CALLS, IMPORTS, REFERENCES, and RE_EXPORTS edges
14
- * - No same-name node in another file (catches import bindings)
15
- * - Not re-exported through any index.ts barrel file
14
+ * - Not re-exported through any index.ts barrel file, via an actual RE_EXPORTS
15
+ * edge from that barrel's File node (not merely sharing a name with something
16
+ * an index.ts happens to also define)
17
+ *
18
+ * Known gap: files consumed only via a dynamic `import()` call won't have a static
19
+ * edge in the graph, so they can still surface as false positives here — this
20
+ * detector only tracks statically-resolved edges.
16
21
  */
17
22
  export function detectDeadCode(db: MonographDb): string[] {
18
23
  return detectDeadCodeNodes(db).map(n => n.id);
@@ -58,21 +63,19 @@ export function detectDeadCodeNodes(db: MonographDb): DeadCodeNode[] {
58
63
  WHERE e.target_id = n.id
59
64
  AND e.relation IN ('CALLS', 'IMPORTS', 'REFERENCES', 'RE_EXPORTS')
60
65
  )
61
- -- no same-name node in another file (import bindings, .cjs callers, etc.)
62
- AND NOT EXISTS (
63
- SELECT 1 FROM nodes o
64
- WHERE o.name = n.name AND o.file_path != n.file_path
65
- AND o.file_path IS NOT NULL
66
- AND o.file_path NOT LIKE '%/dist/%'
67
- AND o.file_path NOT LIKE '%node_modules%'
68
- )
69
- -- not re-exported through a barrel (index.ts/index.js)
66
+ -- not re-exported through a barrel (index.ts/index.js) checked via an
67
+ -- ACTUAL RE_EXPORTS edge from the barrel's File node to this candidate's
68
+ -- File node, not a same-NAME coincidence with an unrelated symbol that
69
+ -- happens to also live in some index.ts elsewhere in the repo.
70
70
  AND NOT EXISTS (
71
- SELECT 1 FROM nodes b
72
- WHERE b.name = n.name
73
- AND b.file_path LIKE '%/index.%'
74
- AND b.file_path NOT LIKE '%/dist/%'
75
- AND b.file_path NOT LIKE '%node_modules%'
71
+ SELECT 1 FROM edges e
72
+ JOIN nodes fn ON fn.id = e.target_id AND fn.label = 'File'
73
+ JOIN nodes bn ON bn.id = e.source_id AND bn.label = 'File'
74
+ WHERE e.relation = 'RE_EXPORTS'
75
+ AND fn.file_path = n.file_path
76
+ AND bn.file_path LIKE '%/index.%'
77
+ AND bn.file_path NOT LIKE '%/dist/%'
78
+ AND bn.file_path NOT LIKE '%node_modules%'
76
79
  )
77
80
  ORDER BY n.file_path, n.start_line`,
78
81
  )
@@ -33,7 +33,7 @@ export function computeHealthScore(db: Database.Database): HealthScoreResult {
33
33
 
34
34
  // ── Unreachable file nodes ────────────────────────────────────────────────
35
35
  const unreachableRow = db.prepare(
36
- "SELECT COUNT(*) as c FROM nodes WHERE label = 'File' AND properties LIKE '%\"unreachable\"%'"
36
+ "SELECT COUNT(*) as c FROM nodes WHERE label = 'File' AND json_extract(properties, '$.unreachable') = 1"
37
37
  ).get() as { c: number };
38
38
  const unreachableFiles = unreachableRow.c;
39
39
 
@@ -42,23 +42,20 @@ export function computeHealthScore(db: Database.Database): HealthScoreResult {
42
42
  const totalNodes = totalNodesRow.c;
43
43
 
44
44
  // ── p95 degree (god nodes) ────────────────────────────────────────────────
45
- const degreesRows = db.prepare(`
46
- SELECT source_id as node_id, COUNT(*) as deg FROM edges GROUP BY source_id
47
- UNION ALL
48
- SELECT target_id as node_id, COUNT(*) as deg FROM edges GROUP BY target_id
49
- `).all() as { node_id: string; deg: number }[];
50
-
51
- // Sum degrees per node
52
- const degMap = new Map<string, number>();
53
- for (const row of degreesRows) {
54
- degMap.set(row.node_id, (degMap.get(row.node_id) ?? 0) + row.deg);
55
- }
56
- const degrees = [...degMap.values()].sort((a, b) => a - b);
57
- const p95Index = Math.floor(degrees.length * 0.95);
58
- const p95Degree = degrees[p95Index] ?? 0;
45
+ // Compute per-node total degree (in + out) entirely in SQL
46
+ const degreeRows = db.prepare(`
47
+ SELECT node_id, SUM(cnt) AS degree FROM (
48
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
49
+ UNION ALL
50
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
51
+ ) GROUP BY node_id ORDER BY degree ASC
52
+ `).all() as { node_id: string; degree: number }[];
53
+
54
+ const p95Index = Math.floor(degreeRows.length * 0.95);
55
+ const p95Degree = degreeRows[p95Index]?.degree ?? 0;
59
56
 
60
57
  const godNodeCount = p95Degree > 0
61
- ? [...degMap.values()].filter(d => d > p95Degree).length
58
+ ? degreeRows.filter(r => r.degree > p95Degree).length
62
59
  : 0;
63
60
 
64
61
  // ── Circular edges (bidirectional import pairs) ───────────────────────────
@@ -123,12 +123,16 @@ export function computeHotspots(
123
123
  // ── Step 3: Get file nodes + centrality from DB ───────────────────────────
124
124
  const fileNodes = db.prepare(`
125
125
  SELECT n.id, n.name, n.file_path, n.label, n.community_id,
126
- COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as degree
126
+ COALESCE(d.deg, 0) as degree
127
127
  FROM nodes n
128
- LEFT JOIN edges e1 ON e1.source_id = n.id
129
- LEFT JOIN edges e2 ON e2.target_id = n.id
128
+ LEFT JOIN (
129
+ SELECT node_id, SUM(cnt) AS deg FROM (
130
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
131
+ UNION ALL
132
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
133
+ ) GROUP BY node_id
134
+ ) d ON d.node_id = n.id
130
135
  WHERE n.label = 'File' AND n.file_path IS NOT NULL
131
- GROUP BY n.id
132
136
  `).all() as { id: string; name: string; file_path: string; label: string; community_id: number | null; degree: number }[];
133
137
 
134
138
  // ── Step 4: Join churn + centrality ──────────────────────────────────────
@@ -24,6 +24,10 @@ function getStmts(db: MonographDb): StmtCache {
24
24
  const key = (db as unknown as { name: string }).name ?? '__default__';
25
25
  let cache = _stmtCache.get(key);
26
26
  if (!cache) {
27
+ if (_stmtCache.size >= MAX_STMT_CACHE) {
28
+ const oldest = _stmtCache.keys().next().value!;
29
+ _stmtCache.delete(oldest);
30
+ }
27
31
  cache = {
28
32
  selectNodes: db.prepare('SELECT id FROM nodes'),
29
33
  selectEdges: db.prepare('SELECT source_id, target_id FROM edges'),
@@ -38,6 +42,8 @@ function getStmts(db: MonographDb): StmtCache {
38
42
  // changed. Keyed by (dbName, nodeCount, edgeCount) with a 5-second TTL.
39
43
  // ---------------------------------------------------------------------------
40
44
  const PAGERANK_CACHE_TTL_MS = 5_000;
45
+ const MAX_RESULT_CACHE = 50;
46
+ const MAX_STMT_CACHE = 20;
41
47
 
42
48
  interface PageRankCacheEntry {
43
49
  result: Map<string, number>;
@@ -149,7 +155,10 @@ export function pageRank(db: MonographDb, options: PageRankOptions = {}): Map<st
149
155
  result.set(nodes[i], scores[i]);
150
156
  }
151
157
 
152
- // Store in result cache
158
+ if (_resultCache.size >= MAX_RESULT_CACHE) {
159
+ const oldest = _resultCache.keys().next().value!;
160
+ _resultCache.delete(oldest);
161
+ }
153
162
  _resultCache.set(cacheKey, { result, expiresAt: now + PAGERANK_CACHE_TTL_MS });
154
163
 
155
164
  return result;
@@ -77,21 +77,24 @@ export function clusteringCoefficient(db: MonographDb): number {
77
77
  let totalCoeff = 0;
78
78
  let countWithNeighbors = 0;
79
79
 
80
+ // Cap per-node neighbor scan to avoid O(k²) explosion on hub nodes
81
+ const MAX_K = 200;
80
82
  for (const [node, nbrs] of neighbors) {
81
83
  const k = nbrs.size;
82
84
  if (k < 2) continue;
83
85
 
84
86
  let triangles = 0;
85
- const nbList = [...nbrs];
86
- for (let i = 0; i < nbList.length; i++) {
87
- for (let j = i + 1; j < nbList.length; j++) {
87
+ const nbList = k <= MAX_K ? [...nbrs] : [...nbrs].slice(0, MAX_K);
88
+ const effectiveK = nbList.length;
89
+ for (let i = 0; i < effectiveK; i++) {
90
+ for (let j = i + 1; j < effectiveK; j++) {
88
91
  if (neighbors.get(nbList[i])?.has(nbList[j])) {
89
92
  triangles++;
90
93
  }
91
94
  }
92
95
  }
93
96
 
94
- const possible = (k * (k - 1)) / 2;
97
+ const possible = (effectiveK * (effectiveK - 1)) / 2;
95
98
  totalCoeff += triangles / possible;
96
99
  countWithNeighbors++;
97
100
  }
@@ -112,19 +115,28 @@ export interface PathStats {
112
115
  }
113
116
 
114
117
  /**
115
- * Compute both average path length and graph diameter in a single BFS pass.
116
- * Unreachable pairs are excluded from averagePathLength.
118
+ * Compute both average path length and graph diameter via BFS.
119
+ * For graphs > 500 nodes, uses deterministic sampling (every Nth node)
120
+ * to keep runtime practical — O(sample * E) instead of O(N * E).
117
121
  */
118
122
  export function pathStats(db: MonographDb): PathStats {
119
123
  const graph = getGraph(db);
120
124
  const nodes = graph.nodes();
121
125
  if (nodes.length < 2) return { averagePathLength: 0, diameter: 0 };
122
126
 
127
+ // Sample sources for large graphs — stride ensures even coverage
128
+ const MAX_SOURCES = 500;
129
+ const stride = nodes.length <= MAX_SOURCES ? 1 : Math.ceil(nodes.length / MAX_SOURCES);
130
+ const sources: string[] = [];
131
+ for (let i = 0; i < nodes.length && sources.length < MAX_SOURCES; i += stride) {
132
+ sources.push(nodes[i]);
133
+ }
134
+
123
135
  let totalLength = 0;
124
136
  let reachablePairs = 0;
125
137
  let maxDist = 0;
126
138
 
127
- for (const source of nodes) {
139
+ for (const source of sources) {
128
140
  bfsFromNode(graph, source, (node, _attr, depth) => {
129
141
  if (node !== source && depth > 0) {
130
142
  totalLength += depth;
@@ -143,10 +143,11 @@ export function computeGraphStats(db: Database.Database): GraphStatsSummary {
143
143
  /** Format a GraphStatsSummary as structured text for LLM consumption. */
144
144
  export function formatGraphStats(s: GraphStatsSummary): string {
145
145
  const cp = s.couplingProfile;
146
+ const totalEntities = cp.fanInProfile.low + cp.fanInProfile.medium + cp.fanInProfile.high + cp.fanInProfile.critical;
146
147
  const lines: string[] = [
147
148
  `Graph Stats`,
148
149
  ` Nodes: ${s.nodeCount} Edges: ${s.edgeCount} Communities: ${s.communityCount} Files: ${s.fileCount}`,
149
- `Coupling Profile (${cp.totalFiles} files)`,
150
+ `Coupling Profile (${totalEntities} entities, ${cp.totalFiles} files)`,
150
151
  ` Fan-in p50/p75/p90/p95: ${cp.p50FanIn}/${cp.p75FanIn}/${cp.p90FanIn}/${cp.p95FanIn}`,
151
152
  ` High-coupling (>p95): ${cp.couplingHighPct}%`,
152
153
  `Fan-in Risk: low=${cp.fanInProfile.low}(${cp.fanInProfile.lowPct}%) med=${cp.fanInProfile.medium}(${cp.fanInProfile.mediumPct}%) high=${cp.fanInProfile.high}(${cp.fanInProfile.highPct}%) crit=${cp.fanInProfile.critical}(${cp.fanInProfile.criticalPct}%)`,