codebase-onboarder 0.1.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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/bin/onboarder.js +14 -0
  4. package/bin/postinstall.js +6 -0
  5. package/cli/commands.js +436 -0
  6. package/cli/main.js +129 -0
  7. package/cli/prompt.js +101 -0
  8. package/cli/ui.js +55 -0
  9. package/cli/wizard.js +325 -0
  10. package/package.json +48 -0
  11. package/public/app.js +1339 -0
  12. package/public/index.html +455 -0
  13. package/public/js/about.js +284 -0
  14. package/public/js/aiDraft.js +163 -0
  15. package/public/js/analysisPanel.js +270 -0
  16. package/public/js/analysisReport.js +154 -0
  17. package/public/js/api.js +264 -0
  18. package/public/js/atlas.js +98 -0
  19. package/public/js/blameView.js +41 -0
  20. package/public/js/codeTab.js +256 -0
  21. package/public/js/codeViewer.js +184 -0
  22. package/public/js/components/FileChip.js +102 -0
  23. package/public/js/components/MetricSparkline.js +89 -0
  24. package/public/js/components/RiskBadge.js +105 -0
  25. package/public/js/deepAnalysisView.js +496 -0
  26. package/public/js/diagramPane.js +154 -0
  27. package/public/js/diffView.js +352 -0
  28. package/public/js/docsView.js +566 -0
  29. package/public/js/fileSourceBrowser.js +47 -0
  30. package/public/js/flameGraph.js +75 -0
  31. package/public/js/forceGraph.js +592 -0
  32. package/public/js/heatmap.js +185 -0
  33. package/public/js/highlight.js +122 -0
  34. package/public/js/html.js +50 -0
  35. package/public/js/insightsView.js +236 -0
  36. package/public/js/inspector.js +535 -0
  37. package/public/js/llm.js +345 -0
  38. package/public/js/mapView.js +159 -0
  39. package/public/js/markdown.js +119 -0
  40. package/public/js/mindmap.js +289 -0
  41. package/public/js/repoFiles.js +44 -0
  42. package/public/js/sbomView.js +142 -0
  43. package/public/js/scanCache.js +76 -0
  44. package/public/js/search.js +874 -0
  45. package/public/js/serverSettings.js +169 -0
  46. package/public/js/state.js +192 -0
  47. package/public/js/tour.js +17 -0
  48. package/public/js/transitions.js +15 -0
  49. package/public/js/tree.js +200 -0
  50. package/public/js/workflowsView.js +95 -0
  51. package/public/styles.css +2767 -0
  52. package/public/vendor/mermaid.min.js +3587 -0
  53. package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  54. package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  55. package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  56. package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  57. package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  58. package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  59. package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  60. package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  61. package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  62. package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  63. package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  64. package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  65. package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  66. package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  67. package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  68. package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  69. package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  70. package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  71. package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  72. package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  73. package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  74. package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  75. package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  76. package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  77. package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  78. package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  79. package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  80. package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  81. package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  82. package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  83. package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  84. package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  85. package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  86. package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  87. package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  88. package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  89. package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  90. package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  91. package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  92. package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  93. package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  94. package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  95. package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  96. package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  97. package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  98. package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  99. package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  100. package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  101. package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  102. package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  103. package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  104. package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  105. package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  106. package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  107. package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  108. package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  109. package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  110. package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  111. package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  112. package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  113. package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  114. package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  115. package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  116. package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  117. package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  118. package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  119. package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  120. package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  121. package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  122. package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  123. package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  124. package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  125. package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  126. package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  127. package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  128. package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  129. package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  130. package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  131. package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  132. package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  133. package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  134. package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  135. package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  136. package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  137. package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  138. package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  139. package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  140. package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  141. package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  142. package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  143. package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  144. package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  145. package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  146. package/public/vendor/monaco/vs/loader.js +11 -0
  147. package/public/vendor/monaco/worker-boot.js +9 -0
  148. package/server/.fuse_hidden0000000800000001 +36 -0
  149. package/server/apiDiff.js +25 -0
  150. package/server/apiDocs.js +67 -0
  151. package/server/apiFile.js +36 -0
  152. package/server/apiGitBlame.js +63 -0
  153. package/server/apiMcp.js +110 -0
  154. package/server/apiScan.js +119 -0
  155. package/server/apiSearch.js +253 -0
  156. package/server/apiSettings.js +118 -0
  157. package/server/apiTools.js +90 -0
  158. package/server/config.js +227 -0
  159. package/server/fileSourceNode.js +44 -0
  160. package/server/gitClone.js +95 -0
  161. package/server/gitDiff.js +184 -0
  162. package/server/gitHistory.js +110 -0
  163. package/server/htmlText.js +44 -0
  164. package/server/http.js +55 -0
  165. package/server/httpGuards.js +87 -0
  166. package/server/index.js +156 -0
  167. package/server/llmProxy.js +162 -0
  168. package/server/logger.js +29 -0
  169. package/server/mcp/analysis.js +209 -0
  170. package/server/mcp/http.js +213 -0
  171. package/server/mcp/runner.js +278 -0
  172. package/server/mcp/server.js +241 -0
  173. package/server/mcp/standalone.js +42 -0
  174. package/server/mcp/tools.js +683 -0
  175. package/server/paths.js +39 -0
  176. package/server/router.js +218 -0
  177. package/server/searchIndex.js +118 -0
  178. package/server/sessions.js +163 -0
  179. package/server/static.js +59 -0
  180. package/server/tools/install.js +246 -0
  181. package/server/tools/parse.js +170 -0
  182. package/server/tools/platform.js +91 -0
  183. package/server/tools/registry.js +212 -0
  184. package/server/tools/scan.js +136 -0
  185. package/server/tools.js +212 -0
  186. package/server/tunnel.js +92 -0
  187. package/shared/analyzer/docs.js +135 -0
  188. package/shared/analyzer/explainLocal.js +163 -0
  189. package/shared/analyzer/graph.js +461 -0
  190. package/shared/analyzer/health.js +215 -0
  191. package/shared/analyzer/history.js +146 -0
  192. package/shared/analyzer/languages/csharp.js +39 -0
  193. package/shared/analyzer/languages/generic.js +131 -0
  194. package/shared/analyzer/languages/go.js +70 -0
  195. package/shared/analyzer/languages/index.js +43 -0
  196. package/shared/analyzer/languages/java.js +39 -0
  197. package/shared/analyzer/languages/javascript.js +240 -0
  198. package/shared/analyzer/languages/python.js +120 -0
  199. package/shared/analyzer/languages/rust.js +42 -0
  200. package/shared/analyzer/languages/typescript.js +138 -0
  201. package/shared/analyzer/licenses.js +151 -0
  202. package/shared/analyzer/metrics.js +120 -0
  203. package/shared/analyzer/pathUtil.js +69 -0
  204. package/shared/analyzer/patterns.js +272 -0
  205. package/shared/analyzer/scan.js +473 -0
  206. package/shared/analyzer/security.js +187 -0
  207. package/shared/analyzer/services.js +157 -0
  208. package/shared/analyzer/stack.js +173 -0
  209. package/shared/analyzer/tour.js +69 -0
  210. package/shared/analyzer/util.js +123 -0
  211. package/shared/analyzer/workflows.js +143 -0
  212. package/shared/diagram/aiFacts.js +145 -0
  213. package/shared/diagram/aiMermaid.js +59 -0
  214. package/shared/diagram/atlas.js +93 -0
  215. package/shared/diagram/mermaid.js +459 -0
  216. package/shared/search/query.js +518 -0
@@ -0,0 +1,461 @@
1
+ // Graph facts: entry points, hubs, orphans, cycles, fan-in/fan-out.
2
+ // Pure functions over the scan result — no I/O here.
3
+
4
+ import { topFolderOf } from './pathUtil.js';
5
+
6
+ const ENTRY_NAMES = new Set([
7
+ 'index', 'main', 'app', 'server', 'cli', 'manage', 'mod', '__main__',
8
+ 'run', 'start', 'cmd',
9
+ ]);
10
+
11
+ const TEST_RE = /(^|[._-])(test|spec|tests|__tests__)([._-]|\/|$)/i;
12
+
13
+ export function computeFacts(scan, manifest = {}) {
14
+ const { files, edges } = scan;
15
+ const fanIn = new Map();
16
+ const fanOut = new Map();
17
+ const importers = new Map();
18
+ const importsOf = new Map();
19
+
20
+ for (const f of files) {
21
+ fanIn.set(f.path, 0);
22
+ fanOut.set(f.path, 0);
23
+ importers.set(f.path, []);
24
+ importsOf.set(f.path, []);
25
+ }
26
+ for (const e of edges) {
27
+ if (!fanIn.has(e.to) || !fanOut.has(e.from)) continue;
28
+ fanIn.set(e.to, fanIn.get(e.to) + 1);
29
+ fanOut.set(e.from, fanOut.get(e.from) + 1);
30
+ importers.get(e.to).push(e.from);
31
+ importsOf.get(e.from).push(e.to);
32
+ }
33
+
34
+ const entrySet = new Set();
35
+ for (const f of files) {
36
+ const stem = (f.name || f.path || '').replace(/\.[^.]+$/, '');
37
+ if (ENTRY_NAMES.has(stem) || f.hasMain) entrySet.add(f.path);
38
+ }
39
+ for (const p of manifest.entryPoints || []) entrySet.add(p);
40
+
41
+ // Tarjan is the most expensive thing in this function, and one run answers
42
+ // both questions asked of it: which files sit in a cycle, and what the
43
+ // largest cycles are. It used to run twice on identical input.
44
+ const sccs = stronglyConnected(files.map((f) => f.path), edges);
45
+ const multi = sccs.filter((scc) => scc.length > 1);
46
+
47
+ const inCycle = new Set();
48
+ for (const scc of multi) for (const p of scc) inCycle.add(p);
49
+
50
+ const entries = files
51
+ .filter((f) => entrySet.has(f.path))
52
+ .map((f) => f.path)
53
+ .sort();
54
+
55
+ const hubs = files
56
+ .map((f) => ({ path: f.path, fanIn: fanIn.get(f.path) || 0, fanOut: fanOut.get(f.path) || 0 }))
57
+ .filter((h) => h.fanIn >= 3)
58
+ .sort((a, b) => b.fanIn - a.fanIn);
59
+
60
+ const orphans = files
61
+ .filter((f) => (fanIn.get(f.path) || 0) === 0 && !entrySet.has(f.path) && !TEST_RE.test(f.path))
62
+ .map((f) => f.path)
63
+ .sort();
64
+
65
+ const cycles = multi
66
+ .slice()
67
+ .sort((a, b) => b.length - a.length)
68
+ .slice(0, 10);
69
+
70
+ // Collect all imported symbols per target file
71
+ const importedSymbolsByTarget = new Map();
72
+ const wildcards = new Set();
73
+ for (const e of edges) {
74
+ if (!e.to) continue;
75
+ if (!importedSymbolsByTarget.has(e.to)) importedSymbolsByTarget.set(e.to, new Set());
76
+ const set = importedSymbolsByTarget.get(e.to);
77
+ if (!e.symbols || !e.symbols.length || e.symbols.includes('*')) {
78
+ wildcards.add(e.to);
79
+ } else {
80
+ for (const s of e.symbols) set.add(s);
81
+ }
82
+ }
83
+
84
+ const deadExports = [];
85
+ for (const f of files) {
86
+ if (!f.exports || !f.exports.length) continue;
87
+ if (entrySet.has(f.path) || TEST_RE.test(f.path) || wildcards.has(f.path)) continue;
88
+ const importedSet = importedSymbolsByTarget.get(f.path);
89
+ if (!importedSet || importedSet.size === 0) {
90
+ for (const exp of f.exports) {
91
+ deadExports.push({ file: f.path, name: exp.name, kind: exp.kind });
92
+ }
93
+ } else {
94
+ for (const exp of f.exports) {
95
+ if (!importedSet.has(exp.name) && exp.name !== 'default') {
96
+ deadExports.push({ file: f.path, name: exp.name, kind: exp.kind });
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ // Test coverage & untested hubs
103
+ const testFiles = files.filter((f) => TEST_RE.test(f.path));
104
+ const testedSet = new Set();
105
+ const testQueue = [];
106
+ for (const tf of testFiles) {
107
+ for (const target of importsOf.get(tf.path) || []) {
108
+ if (!TEST_RE.test(target) && !testedSet.has(target)) {
109
+ testedSet.add(target);
110
+ testQueue.push(target);
111
+ }
112
+ }
113
+ }
114
+ let hop = 0;
115
+ while (testQueue.length && hop < 500) {
116
+ hop++;
117
+ const curr = testQueue.shift();
118
+ for (const next of importsOf.get(curr) || []) {
119
+ if (!TEST_RE.test(next) && !testedSet.has(next)) {
120
+ testedSet.add(next);
121
+ testQueue.push(next);
122
+ }
123
+ }
124
+ }
125
+ const nonTestFiles = files.filter((f) => !TEST_RE.test(f.path));
126
+ const untestedHubs = hubs.filter((h) => !TEST_RE.test(h.path) && !testedSet.has(h.path));
127
+ const testCoverage = {
128
+ testedCount: testedSet.size,
129
+ totalNonTest: nonTestFiles.length,
130
+ ratio: nonTestFiles.length ? Math.round((testedSet.size / nonTestFiles.length) * 100) : 100,
131
+ };
132
+
133
+ // Dependency drift
134
+ const declaredDeps = new Set([
135
+ ...Object.keys(manifest.deps?.npm || {}),
136
+ ...Object.keys(manifest.deps?.dev || {}),
137
+ ...(manifest.deps?.pip || []),
138
+ ...(manifest.deps?.go || []),
139
+ ...(manifest.deps?.cargo || []),
140
+ ]);
141
+ const importedExternals = new Set((scan.externals || []).map((x) => x.name));
142
+ const unusedDeclared = [...declaredDeps].filter((d) => !importedExternals.has(d)).sort();
143
+ const BUILTIN_MODULES = new Set([
144
+ 'fs', 'path', 'http', 'https', 'url', 'crypto', 'os', 'stream', 'util',
145
+ 'events', 'child_process', 'buffer', 'assert', 'net', 'zlib', 'tls', 'dns',
146
+ 'perf_hooks', 'worker_threads',
147
+ ]);
148
+ const undeclaredImported = [...importedExternals]
149
+ .filter((i) => !declaredDeps.has(i) && !BUILTIN_MODULES.has(i))
150
+ .sort();
151
+ const depsDrift = { unusedDeclared, undeclaredImported };
152
+
153
+ const folderEdges = new Map();
154
+ for (const e of edges) {
155
+ const a = topFolderOf(e.from);
156
+ const b = topFolderOf(e.to);
157
+ if (a === b) continue;
158
+ const key = a + '->' + b;
159
+ folderEdges.set(key, (folderEdges.get(key) || 0) + 1);
160
+ }
161
+
162
+ const paths = files.map(f => f.path);
163
+ const communitiesMap = louvainCommunities(paths, edges);
164
+ const commGroups = new Map();
165
+ for (const [n, c] of communitiesMap.entries()) {
166
+ if (!commGroups.has(c)) commGroups.set(c, []);
167
+ commGroups.get(c).push(n);
168
+ }
169
+ const communities = [...commGroups.entries()]
170
+ .map(([id, members]) => ({ id, members, size: members.length }))
171
+ .sort((a, b) => b.size - a.size);
172
+
173
+ const betweennessResult = brandesBetweenness(paths, edges);
174
+ const betweenness = Object.fromEntries(betweennessResult.map);
175
+
176
+ return {
177
+ communities,
178
+ betweenness,
179
+ // Whether `betweenness` above is a real measurement or all zeros
180
+ // because the graph was over the Brandes cap. The UI uses this the
181
+ // same way it uses `health.blastExact`: it shows an honest
182
+ // "not computed for repos of this size" instead of pretending the
183
+ // zeros are a finding.
184
+ betweennessExact: betweennessResult.exact,
185
+ fanIn: Object.fromEntries(fanIn),
186
+ fanOut: Object.fromEntries(fanOut),
187
+ importers: Object.fromEntries(importers),
188
+ importsOf: Object.fromEntries(importsOf),
189
+ entries,
190
+ hubs,
191
+ orphans,
192
+ cycles,
193
+ inCycle: [...inCycle],
194
+ deadExports,
195
+ testCoverage,
196
+ untestedHubs,
197
+ depsDrift,
198
+ folderEdges: [...folderEdges.entries()].map(([key, count]) => {
199
+ const [from, to] = key.split('->');
200
+ return { from, to, count };
201
+ }),
202
+ entrySet: entries,
203
+ };
204
+ }
205
+
206
+ // Tarjan's strongly connected components, iterative so deep graphs don't
207
+ // blow the stack.
208
+ function stronglyConnected(nodes, edges) {
209
+ const adj = new Map(nodes.map((n) => [n, []]));
210
+ for (const e of edges) {
211
+ if (adj.has(e.from)) adj.get(e.from).push(e.to);
212
+ }
213
+ const index = new Map();
214
+ const low = new Map();
215
+ const onStack = new Set();
216
+ const stack = [];
217
+ const result = [];
218
+ let counter = 0;
219
+
220
+ for (const start of nodes) {
221
+ if (index.has(start)) continue;
222
+ const work = [[start, 0]];
223
+ while (work.length) {
224
+ const [v, pi] = work[work.length - 1];
225
+ if (pi === 0) {
226
+ index.set(v, counter);
227
+ low.set(v, counter);
228
+ counter++;
229
+ stack.push(v);
230
+ onStack.add(v);
231
+ }
232
+ const children = adj.get(v) || [];
233
+ if (pi < children.length) {
234
+ work[work.length - 1][1] = pi + 1;
235
+ const w = children[pi];
236
+ if (!index.has(w)) {
237
+ work.push([w, 0]);
238
+ } else if (onStack.has(w)) {
239
+ low.set(v, Math.min(low.get(v), index.get(w)));
240
+ }
241
+ } else {
242
+ work.pop();
243
+ if (work.length) {
244
+ const parent = work[work.length - 1][0];
245
+ low.set(parent, Math.min(low.get(parent), low.get(v)));
246
+ }
247
+ if (low.get(v) === index.get(v)) {
248
+ const scc = [];
249
+ let w;
250
+ do {
251
+ w = stack.pop();
252
+ onStack.delete(w);
253
+ scc.push(w);
254
+ } while (w !== v);
255
+ result.push(scc);
256
+ }
257
+ }
258
+ }
259
+ }
260
+ return result;
261
+ }
262
+
263
+ // Lookup shapes for the render loops.
264
+ //
265
+ // `facts` is plain data on purpose — it is serialized to JSON and handed to the
266
+ // browser, so `entries`, `inCycle` and `hubs` have to travel as arrays. But the
267
+ // UI asks "is this file in a cycle?" once per file per redraw, and an array
268
+ // answers that in O(n): drawing a 2,000-file map turned into four million string
269
+ // comparisons. This builds the Sets and Maps once per facts object and
270
+ // remembers them in a WeakMap, so the index costs nothing on the second call
271
+ // and is collected the moment the scan it describes is dropped.
272
+ //
273
+ // It assumes nobody mutates `facts` after computing it. Nothing does — the
274
+ // engine returns it and every consumer reads.
275
+ const INDEXES = new WeakMap();
276
+
277
+ export function factIndex(facts) {
278
+ let index = INDEXES.get(facts);
279
+ if (index) return index;
280
+ index = {
281
+ entries: new Set(facts.entries || []),
282
+ inCycle: new Set(facts.inCycle || []),
283
+ hubs: new Map((facts.hubs || []).map((h) => [h.path, h])),
284
+ };
285
+ INDEXES.set(facts, index);
286
+ return index;
287
+ }
288
+
289
+ export function roleOf(path, facts) {
290
+ if (TEST_RE.test(path)) return 'test';
291
+ const index = factIndex(facts);
292
+ if (index.entries.has(path)) return 'entry';
293
+ const hub = index.hubs.get(path);
294
+ if (hub && hub.fanIn >= 5) return 'hub';
295
+ const fin = facts.fanIn[path] || 0;
296
+ const fout = facts.fanOut[path] || 0;
297
+ if (fout === 0 && fin > 0) return 'leaf';
298
+ if (/config|settings|\.json$|\.ya?ml$|\.toml$/.test(path)) return 'config';
299
+ return 'module';
300
+ }
301
+
302
+ // Is this file part of an import cycle? Same answer as
303
+ // `facts.inCycle.includes(path)`, without the scan.
304
+ export function inCycle(path, facts) {
305
+ return factIndex(facts).inCycle.has(path);
306
+ }
307
+
308
+ // The same trick for the scan itself. `scan.files.find((f) => f.path === p)` and
309
+ // `scan.files.filter((f) => f.dir === d)` were written a dozen times across the
310
+ // engine and the UI, both inside per-file loops — the docs generator alone ran
311
+ // the first one once per file per folder. One pass builds both lookups.
312
+ //
313
+ // `byDir` hands back the same array to every caller, in `scan.files` order.
314
+ // Treat it as read-only: copy before sorting.
315
+ const SCAN_INDEXES = new WeakMap();
316
+ const NO_FILES = Object.freeze([]);
317
+
318
+ export function scanIndex(scan) {
319
+ let index = SCAN_INDEXES.get(scan);
320
+ if (index) return index;
321
+ const byPath = new Map();
322
+ const byDir = new Map();
323
+ for (const f of scan.files || []) {
324
+ byPath.set(f.path, f);
325
+ const bucket = byDir.get(f.dir);
326
+ if (bucket) bucket.push(f);
327
+ else byDir.set(f.dir, [f]);
328
+ }
329
+ index = {
330
+ byPath,
331
+ byDir,
332
+ fileAt: (path) => byPath.get(path) || null,
333
+ filesIn: (dir) => byDir.get(dir) || NO_FILES,
334
+ };
335
+ SCAN_INDEXES.set(scan, index);
336
+ return index;
337
+ }
338
+
339
+ function louvainCommunities(nodes, edges) {
340
+ let communities = new Map(nodes.map(n => [n, n]));
341
+ let tot = new Map(nodes.map(n => [n, 0]));
342
+ let inC = new Map(nodes.map(n => [n, 0]));
343
+ let k = new Map(nodes.map(n => [n, 0]));
344
+ let m2 = 0;
345
+
346
+ const adj = new Map(nodes.map(n => [n, []]));
347
+ for (const e of edges) {
348
+ if (!adj.has(e.from)) adj.set(e.from, []);
349
+ adj.get(e.from).push(e.to);
350
+ k.set(e.from, (k.get(e.from) || 0) + 1);
351
+ k.set(e.to, (k.get(e.to) || 0) + 1);
352
+ m2 += 2;
353
+ }
354
+ if (m2 === 0) return communities;
355
+
356
+ for (const n of nodes) tot.set(n, k.get(n));
357
+
358
+ // Eight passes is enough for any practical graph to settle. Three was
359
+ // the early limit and worked for small repos, but on denser graphs the
360
+ // modularity gain was still climbing at iteration three. The early-exit
361
+ // (`improved === false`) keeps the cost bounded on graphs that converge
362
+ // quickly, so this is not a free pass on every scan — it is a ceiling.
363
+ let iters = 0;
364
+ let improved = true;
365
+ while (iters < 8 && improved) {
366
+ improved = false;
367
+ for (const u of nodes) {
368
+ const c_u = communities.get(u);
369
+ const k_u = k.get(u);
370
+
371
+ const counts = new Map();
372
+ for (const v of (adj.get(u) || [])) {
373
+ const c_v = communities.get(v);
374
+ counts.set(c_v, (counts.get(c_v) || 0) + 1);
375
+ }
376
+
377
+ let best_c = c_u;
378
+ let max_gain = 0;
379
+
380
+ for (const [c_v, weight] of counts.entries()) {
381
+ if (c_v === c_u) continue;
382
+ const tot_c = tot.get(c_v);
383
+ const gain = weight - (k_u * tot_c) / m2;
384
+ if (gain > max_gain) {
385
+ max_gain = gain;
386
+ best_c = c_v;
387
+ }
388
+ }
389
+
390
+ if (best_c !== c_u) {
391
+ tot.set(c_u, tot.get(c_u) - k_u);
392
+ tot.set(best_c, tot.get(best_c) + k_u);
393
+ communities.set(u, best_c);
394
+ improved = true;
395
+ }
396
+ }
397
+ iters++;
398
+ }
399
+ return communities;
400
+ }
401
+
402
+ // Brandes's algorithm. The old return was just a Map of zeros when the
403
+ // graph had more than 1,000 nodes, which is honest about the cost (O(V·E)
404
+ // in the unweighted case) but dishonest about what the consumer is looking
405
+ // at: a row of zeros reads like a real measurement. The wrapper now returns
406
+ // `{ map, exact }` so the consumer can show "betweenness was not computed
407
+ // for repos over 1,000 files" the way the health panel shows
408
+ // `blastExact: false` next to a fan-in fallback.
409
+ function brandesBetweenness(nodes, edges) {
410
+ const cb = new Map(nodes.map(n => [n, 0]));
411
+ if (nodes.length > 1000) return { map: cb, exact: false };
412
+
413
+ const adj = new Map(nodes.map(n => [n, []]));
414
+ for (const e of edges) {
415
+ if (adj.has(e.from)) adj.get(e.from).push(e.to);
416
+ }
417
+
418
+ for (const s of nodes) {
419
+ const S = [];
420
+ const P = new Map(nodes.map(n => [n, []]));
421
+ const sigma = new Map(nodes.map(n => [n, 0]));
422
+ sigma.set(s, 1);
423
+ const d = new Map(nodes.map(n => [n, -1]));
424
+ d.set(s, 0);
425
+ const Q = [s];
426
+
427
+ while (Q.length > 0) {
428
+ const v = Q.shift();
429
+ S.push(v);
430
+ for (const w of (adj.get(v) || [])) {
431
+ if (d.get(w) < 0) {
432
+ d.set(w, d.get(v) + 1);
433
+ Q.push(w);
434
+ }
435
+ if (d.get(w) === d.get(v) + 1) {
436
+ sigma.set(w, sigma.get(w) + sigma.get(v));
437
+ P.get(w).push(v);
438
+ }
439
+ }
440
+ }
441
+
442
+ const delta = new Map(nodes.map(n => [n, 0]));
443
+ while (S.length > 0) {
444
+ const w = S.pop();
445
+ for (const v of P.get(w)) {
446
+ delta.set(v, delta.get(v) + (sigma.get(v) / sigma.get(w)) * (1 + delta.get(w)));
447
+ }
448
+ if (w !== s) {
449
+ cb.set(w, cb.get(w) + delta.get(w));
450
+ }
451
+ }
452
+ }
453
+
454
+ let max = 0;
455
+ for (const val of cb.values()) if (val > max) max = val;
456
+ if (max > 0) {
457
+ for (const [k, v] of cb.entries()) cb.set(k, v / max);
458
+ }
459
+
460
+ return { map: cb, exact: true };
461
+ }
@@ -0,0 +1,215 @@
1
+ // The advanced analysis engine: graph centrality (PageRank), blast radius
2
+ // (transitive dependents), instability (Martin's Ce/(Ca+Ce)), a per-file risk
3
+ // score, and a repo health grade. Pure — runs in Node and the browser.
4
+
5
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
6
+
7
+ export function analyzeHealth(scan, facts) {
8
+ const files = scan.files;
9
+ const paths = files.map((f) => f.path);
10
+ const n = paths.length;
11
+ // A folder with no code we recognize. The shape has to match the real return
12
+ // or the Health panel throws on `health.breakdown.length`.
13
+ if (!n) {
14
+ return {
15
+ score: 100, grade: 'A', perFile: [], breakdown: [], blastExact: true,
16
+ totals: { crit: 0, high: 0, avgCx: 0, orphanRatio: 0, cycles: 0 },
17
+ };
18
+ }
19
+
20
+ const pr = pageRank(paths, scan.edges);
21
+ let prMax = 1e-9;
22
+ for (const p of paths) {
23
+ if (pr[p] > prMax) prMax = pr[p];
24
+ }
25
+
26
+ // Blast radius = number of distinct files that transitively depend on me.
27
+ // Above 2,500 files the condensation gets expensive, so we fall back to
28
+ // plain fan-in — a different measurement wearing the same name. `blastExact`
29
+ // travels with the result so the UI can stop calling it a blast radius.
30
+ const blast = n <= 2500 ? blastRadius(paths, scan.edges) : null;
31
+ const inCycle = new Set(facts.inCycle);
32
+
33
+ const perFile = files.map((f) => {
34
+ const p = f.path;
35
+ const ca = facts.fanIn[p] || 0;
36
+ const ce = facts.fanOut[p] || 0;
37
+ const instability = ca + ce === 0 ? 0 : ce / (ca + ce);
38
+ const cx = f.complexity || 1;
39
+ const loc = f.loc || f.size || 0;
40
+ const centrality = (pr[p] || 0) / prMax;
41
+ const risk = clamp(
42
+ Math.round(
43
+ 34 * centrality + // how load-bearing this file is
44
+ 26 * Math.min(cx / 28, 1) + // how hard it is to change safely
45
+ 20 * (inCycle.has(p) ? 1 : 0) + // cycles are the sharpest edge
46
+ 12 * Math.min(loc / 900, 1) + // bulk alone slows reading
47
+ 8 * instability // high efferent → change ripples outward
48
+ ),
49
+ 0,
50
+ 100
51
+ );
52
+ return {
53
+ path: p, name: f.name, risk,
54
+ centrality: Math.round(centrality * 100) / 100,
55
+ blast: blast ? blast[p] : ca,
56
+ instability: Math.round(instability * 100) / 100,
57
+ complexity: cx, loc, fanIn: ca, fanOut: ce, inCycle: inCycle.has(p),
58
+ };
59
+ });
60
+ perFile.sort((a, b) => b.risk - a.risk);
61
+
62
+ const crit = perFile.filter((f) => f.risk >= 70).length;
63
+ const high = perFile.filter((f) => f.risk >= 50 && f.risk < 70).length;
64
+ const avgCx = perFile.reduce((s, f) => s + f.complexity, 0) / n;
65
+ const orphanRatio = (facts.orphans.length || 0) / n;
66
+
67
+ // The score, itemized — so the grade can explain itself.
68
+ const breakdown = [];
69
+ let score = 100;
70
+ const take = (points, label) => {
71
+ const p = Math.round(points);
72
+ if (p >= 1) { score -= p; breakdown.push({ label, points: -p }); }
73
+ };
74
+ const cycleCount = facts.cycles?.length || 0;
75
+ take(cycleCount * 4, `${cycleCount} circular ${cycleCount === 1 ? 'dependency' : 'dependencies'}`);
76
+ take(crit * 4 + high * 2, `${crit} critical + ${high} high-risk file${crit + high === 1 ? '' : 's'}`);
77
+ take(Math.max(0, avgCx - 6) * 2, `average complexity ${Math.round(avgCx * 10) / 10} (over 6)`);
78
+ take(orphanRatio * 25, `${Math.round(orphanRatio * 100)}% of files unconnected`);
79
+ score = Math.max(0, Math.round(score));
80
+ const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
81
+ let lowMI = 0;
82
+ let totalEffort = 0;
83
+ for (const f of files) {
84
+ if (f.maintainabilityIndex < 65) lowMI++;
85
+ if (f.effort) totalEffort += f.effort;
86
+ }
87
+ const techDebtRatio = n ? lowMI / n : 0;
88
+ const effortHours = totalEffort / 3600;
89
+ let debtCategory = 'low';
90
+ if (techDebtRatio > 0.5) debtCategory = 'critical';
91
+ else if (techDebtRatio > 0.25) debtCategory = 'high';
92
+ else if (techDebtRatio > 0.10) debtCategory = 'moderate';
93
+
94
+
95
+ return { score, grade, perFile, breakdown, blastExact: Boolean(blast), totals: { crit, high, avgCx: Math.round(avgCx * 10) / 10, orphanRatio, cycles: facts.cycles.length || 0 }, techDebtRatio, effortHours, debtCategory };
96
+ }
97
+
98
+ // Importance over the directed import graph: edge a→b (a imports b) means
99
+ // rank flows to b — the files everything leans on rise to the top.
100
+ function pageRank(paths, edges) {
101
+ const n = paths.length;
102
+ const idx = Object.fromEntries(paths.map((p, i) => [p, i]));
103
+ const adj = Array.from({ length: n }, () => []);
104
+ const out = new Array(n).fill(0);
105
+ for (const e of edges) {
106
+ const a = idx[e.from];
107
+ const b = idx[e.to];
108
+ if (a == null || b == null) continue;
109
+ adj[a].push(b);
110
+ out[a]++;
111
+ }
112
+ let rank = new Array(n).fill(1 / n);
113
+ const d = 0.85;
114
+ for (let it = 0; it < 24; it++) {
115
+ const next = new Array(n).fill((1 - d) / n);
116
+ for (let a = 0; a < n; a++) {
117
+ const share = (rank[a] * d) / (out[a] || 1);
118
+ for (const b of adj[a]) next[b] += share;
119
+ }
120
+ rank = next;
121
+ }
122
+ const map = {};
123
+ paths.forEach((p, i) => (map[p] = rank[i]));
124
+ return map;
125
+ }
126
+
127
+ // Distinct transitive dependents per node, computed exactly via strongly
128
+ // connected components so cycles share one reachable set.
129
+ function blastRadius(paths, edges) {
130
+ const n = paths.length;
131
+ const idx = Object.fromEntries(paths.map((p, i) => [p, i]));
132
+ // Reverse the graph: walk from a file to everything that imports it.
133
+ const radj = Array.from({ length: n }, () => []);
134
+ for (const e of edges) {
135
+ const a = idx[e.from];
136
+ const b = idx[e.to];
137
+ if (a == null || b == null) continue;
138
+ radj[b].push(a);
139
+ }
140
+ const { comp, compCount } = tarjan(n, radj);
141
+ const compSize = new Array(compCount).fill(0);
142
+ const compAdj = Array.from({ length: compCount }, () => new Set());
143
+ for (let v = 0; v < n; v++) {
144
+ compSize[comp[v]]++;
145
+ for (const w of radj[v]) if (comp[w] !== comp[v]) compAdj[comp[v]].add(comp[w]);
146
+ }
147
+ const memo = new Map();
148
+ const reach = (c) => {
149
+ if (memo.has(c)) return memo.get(c);
150
+ const set = new Set([c]);
151
+ for (const s of compAdj[c]) for (const x of reach(s)) set.add(x);
152
+ memo.set(c, set);
153
+ return set;
154
+ };
155
+ const blast = {};
156
+ paths.forEach((p, i) => {
157
+ let count = 0;
158
+ for (const c of reach(comp[i])) count += compSize[c];
159
+ blast[p] = count - compSize[comp[i]]; // exclude self
160
+ });
161
+ return blast;
162
+ }
163
+
164
+ // Iterative Tarjan (no recursion limit on long chains).
165
+ function tarjan(n, adj) {
166
+ const comp = new Array(n).fill(-1);
167
+ const index = new Array(n).fill(-1);
168
+ const low = new Array(n).fill(0);
169
+ const onStack = new Array(n).fill(false);
170
+ const stack = [];
171
+ let idx = 0;
172
+ let compCount = 0;
173
+ for (let s = 0; s < n; s++) {
174
+ if (index[s] !== -1) continue;
175
+ const work = [[s, 0]];
176
+ while (work.length) {
177
+ const top = work[work.length - 1];
178
+ const v = top[0];
179
+ let i = top[1];
180
+ if (i === 0) {
181
+ index[v] = low[v] = idx++;
182
+ stack.push(v);
183
+ onStack[v] = true;
184
+ }
185
+ let pushed = false;
186
+ for (; i < adj[v].length; i++) {
187
+ const w = adj[v][i];
188
+ if (index[w] === -1) {
189
+ top[1] = i + 1;
190
+ work.push([w, 0]);
191
+ pushed = true;
192
+ break;
193
+ }
194
+ if (onStack[w]) low[v] = Math.min(low[v], index[w]);
195
+ }
196
+ if (pushed) continue;
197
+ if (low[v] === index[v]) {
198
+ let w;
199
+ do {
200
+ w = stack.pop();
201
+ onStack[w] = false;
202
+ comp[w] = compCount;
203
+ } while (w !== v);
204
+ compCount++;
205
+ }
206
+ work.pop();
207
+ if (work.length) {
208
+ const parent = work[work.length - 1][0];
209
+ low[parent] = Math.min(low[parent], low[v]);
210
+ }
211
+ }
212
+ }
213
+ return { comp, compCount };
214
+ }
215
+