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,683 @@
1
+ // The tools. One entry per question an agent might ask about a repository, with
2
+ // JSON Schema descriptions written for the model reading them rather than for a
3
+ // person filling in a form.
4
+ //
5
+ // The division of labour follows what the app already knows how to answer:
6
+ // * `onboarder_scan` is the door — everything else takes the path it returns.
7
+ // * The read-only tools (`onboarder_overview`, `_architecture`, `_health`, …)
8
+ // are projections of the one cached analysis, so they are cheap and mutually
9
+ // consistent.
10
+ // * `onboarder_read_file`, `_list_files` and `_search` are the three ways to get
11
+ // at actual source, each with a size or result cap.
12
+ // * `onboarder_deep_analysis` runs the optional external analyzers and is the
13
+ // only tool that spawns a process.
14
+ //
15
+ // Every tool declares `path` in its schema, and every handler resolves it through
16
+ // `analyzeRepository`, so a client that forgets the path gets a sentence that says
17
+ // so rather than a validation error with no context.
18
+
19
+ import {
20
+ analyzeRepository, cachedRoots, fileIn, readFileText, filesUnder, parsedFilesUnder,
21
+ ToolError, scanIndex, baseName, explainFile, explainFolder,
22
+ docFileRow, fileFactsLine, fileStaticDoc, folderStaticDoc, searchDocuments,
23
+ } from './analysis.js';
24
+ import { buildTourStops } from '../../shared/analyzer/tour.js';
25
+ import { runExternalAnalysis, toolsStatus } from '../tools/scan.js';
26
+ import { TOOL_DEFS } from '../tools/registry.js';
27
+
28
+ const PATH_PROPERTY = {
29
+ type: 'string',
30
+ description:
31
+ 'Absolute path to the repository on this machine (for example /Users/you/code/my-app). '
32
+ + 'The path returned by onboarder_scan is the one to reuse. Omit it to use the last repository scanned.',
33
+ };
34
+
35
+ const LIMIT_PROPERTY = {
36
+ type: 'integer',
37
+ description: 'Maximum number of results. Defaults to 20, capped at 200.',
38
+ minimum: 1,
39
+ maximum: 200,
40
+ };
41
+
42
+ // A client that never scans anything still gets a useful answer from the path it
43
+ // happens to send, and a client that scanned something and then omits the path
44
+ // gets the one it scanned. `lastRoot` is that memory.
45
+ let lastRoot = null;
46
+
47
+ async function resolve({ path: p }) {
48
+ const root = String(p || '').trim() || lastRoot;
49
+ if (!root) {
50
+ throw new ToolError('No repository yet — call onboarder_scan with a path first.');
51
+ }
52
+ const analysis = await analyzeRepository(root);
53
+ lastRoot = analysis.root;
54
+ return analysis;
55
+ }
56
+
57
+ // Scans are expensive and the cache holds a few; a tool that takes a limit should
58
+ // not be able to be talked into returning the whole repository.
59
+ function limitOf(value, fallback = 20, max = 200) {
60
+ const n = Number(value);
61
+ if (!Number.isFinite(n)) return fallback;
62
+ return Math.max(1, Math.min(max, Math.floor(n)));
63
+ }
64
+
65
+ // ---- the payloads -----------------------------------------------------------
66
+ //
67
+ // Each of these is a view's data, flattened into plain JSON. The point is that an
68
+ // agent can act on the answer without knowing this codebase: a hub is a path and a
69
+ // count, not an index into an array the tool kept to itself.
70
+
71
+ function overviewPayload(a) {
72
+ const { scan, facts, manifest, health, security } = a;
73
+ return {
74
+ root: a.root,
75
+ name: scan.name,
76
+ summary: a.overview,
77
+ stats: {
78
+ files: scan.stats.filesParsed,
79
+ filesSeen: scan.stats.filesTotal,
80
+ languages: scan.stats.languages,
81
+ connections: scan.stats.edgeCount,
82
+ importsResolved: scan.stats.imports?.confidence ?? null,
83
+ },
84
+ entryPoints: facts.entries.slice(0, 10),
85
+ hubs: facts.hubs.slice(0, 10).map((h) => ({ path: h.path, dependents: h.fanIn, dependencies: h.fanOut })),
86
+ cycles: facts.cycles.slice(0, 5),
87
+ orphanCount: facts.orphans.length,
88
+ deadExportCount: (facts.deadExports || []).length,
89
+ services: (manifest.services || []).map((s) => ({ name: s.name, kind: s.kind, port: s.port ?? null })),
90
+ // A repo's dependencies are a different fact from its services — `express` is
91
+ // a library, not a thing this app runs — so they are named apart. They are
92
+ // here, rather than only in `onboarder_dependencies`, because "what does this
93
+ // project use" is a first-scan question and an extra round trip for it would
94
+ // be a tool the agent has to learn exists.
95
+ packageName: manifest.packageName || null,
96
+ dependencies: Object.entries(manifest.deps?.npm || {})
97
+ .map(([name, version]) => ({ name, version })),
98
+ devDependencies: Object.keys(manifest.deps?.dev || {}),
99
+ health: { score: health.score, grade: health.grade },
100
+ security: { score: security.score, grade: security.grade, findings: security.total },
101
+ caveats: a.caveats,
102
+ };
103
+ }
104
+
105
+ function architecturePayload(a) {
106
+ const { layersInfo, findings, coupling } = a.patterns;
107
+ // `couplingMatrix` returns a folder list and a `Map` keyed `'from->to'`, not a
108
+ // list of edges. Flattening it here means the agent gets pairs; leaving it raw
109
+ // means it has to know this module's encoding — and a Map does not survive
110
+ // `JSON.stringify`, so it would arrive as `{}` rather than as an error.
111
+ const pairs = coupling.counts instanceof Map
112
+ ? [...coupling.counts.entries()]
113
+ : Object.entries(coupling.counts || {});
114
+ const edges = pairs
115
+ .map(([key, count]) => {
116
+ const [from, to] = key.split('->');
117
+ return { from, to, count };
118
+ })
119
+ .sort((x, y) => y.count - x.count);
120
+
121
+ return {
122
+ // A layer here is a list of paths, not an object: depth is the array's index.
123
+ layers: layersInfo.layers.map((files, depth) => ({
124
+ depth,
125
+ fileCount: files.length,
126
+ files: files.slice(0, 20),
127
+ })),
128
+ seeds: layersInfo.seeds,
129
+ unreachable: (layersInfo.unreachable || []).slice(0, 20),
130
+ patterns: findings.map((p) => ({
131
+ id: p.id,
132
+ tone: p.tone,
133
+ severity: p.severity,
134
+ title: p.title,
135
+ detail: p.detail,
136
+ paths: (p.paths || []).slice(0, 10),
137
+ })),
138
+ coupling: { folders: coupling.folders, edges: edges.slice(0, 30), max: coupling.max },
139
+ services: (a.manifest.services || []).map((s) => ({ name: s.name, kind: s.kind, port: s.port ?? null })),
140
+ };
141
+ }
142
+
143
+ function healthPayload(a, limit) {
144
+ return {
145
+ root: a.root,
146
+ score: a.health.score,
147
+ grade: a.health.grade,
148
+ totals: a.health.totals,
149
+ breakdown: a.health.breakdown,
150
+ // `blast` is a transitive-dependency count, exact only on smaller repos;
151
+ // `blastExact` says which, because a number that quietly changes meaning is
152
+ // worse than no number.
153
+ blastExact: a.health.blastExact,
154
+ riskiestFiles: a.health.perFile.slice(0, limit).map((f) => ({
155
+ path: f.path,
156
+ risk: f.risk,
157
+ blast: f.blast,
158
+ complexity: f.complexity,
159
+ loc: f.loc,
160
+ fanIn: f.fanIn,
161
+ fanOut: f.fanOut,
162
+ inCycle: f.inCycle,
163
+ })),
164
+ };
165
+ }
166
+
167
+ function securityPayload(a, limit) {
168
+ const s = a.security;
169
+ // `summarizeSecurity` rolls up per file, keeping each file's own findings. The
170
+ // flat list an agent wants is the concatenation, ordered worst-first by file.
171
+ const findings = [];
172
+ for (const file of s.files || []) {
173
+ for (const f of file.findings || []) {
174
+ findings.push({
175
+ severity: f.severity,
176
+ rule: f.rule,
177
+ category: f.category,
178
+ path: file.path,
179
+ line: f.line ?? null,
180
+ message: f.message,
181
+ excerpt: f.excerpt,
182
+ });
183
+ }
184
+ }
185
+ return {
186
+ root: a.root,
187
+ score: s.score,
188
+ grade: s.grade,
189
+ total: s.total,
190
+ bySeverity: s.counts,
191
+ byCategory: s.byCat,
192
+ filesWithFindings: (s.files || []).length,
193
+ findings: findings.slice(0, limit),
194
+ };
195
+ }
196
+
197
+ function historyPayload(a, limit) {
198
+ const h = a.history;
199
+ if (!h.available) return { available: false, reason: h.reason };
200
+ return {
201
+ available: true,
202
+ commitCount: h.commitCount,
203
+ totalCommits: h.totalCommits,
204
+ truncated: h.truncated,
205
+ firstCommitAt: h.firstCommitAt,
206
+ lastCommitAt: h.lastCommitAt,
207
+ contributors: (h.authors || []).slice(0, limit).map((c) => ({ name: c.name, commits: c.commits })),
208
+ hotspots: (h.perFile || []).slice(0, limit).map((r) => ({
209
+ path: r.path, churn: r.churn, authors: r.authors, complexity: r.complexity,
210
+ hotspot: r.hotspot, firstSeen: r.firstSeen, lastTouched: r.lastTouched,
211
+ })),
212
+ soloFiles: h.soloFiles,
213
+ // Files that keep changing together without importing each other — the
214
+ // coupling the import graph cannot see.
215
+ coChanged: (h.coChanged || []).slice(0, 15),
216
+ pathsGone: h.pathsGone,
217
+ };
218
+ }
219
+
220
+ function dependenciesPayload(a) {
221
+ return {
222
+ languages: a.stack.languages,
223
+ packageManagers: a.stack.pm,
224
+ dependencies: a.stack.items.map((d) => ({
225
+ name: d.name, version: d.version, category: d.category, dev: d.dev, docs: d.docs,
226
+ })),
227
+ projectLicense: a.scan.licenseReport?.projectLicense || a.scan.license,
228
+ licenseCounts: a.scan.licenseReport?.counts || null,
229
+ workflows: (a.scan.workflows || []).map((w) => ({
230
+ name: w.name, file: w.file || w.path, triggers: w.triggers, jobs: (w.jobs || []).map((j) => j.name),
231
+ })),
232
+ };
233
+ }
234
+
235
+ // The folder boundary crossing for one folder, derived from the real edge list.
236
+ //
237
+ // `facts.folderEdges` is a rollup over top-level folders only — the coupling
238
+ // matrix the architecture view draws. That makes it the wrong source for
239
+ // `onboarder_explain_folder` on a nested folder: asking about `server/mcp` and
240
+ // getting nothing back looks exactly like "this folder imports nothing", which is
241
+ // the opposite of true.
242
+ //
243
+ // So this walks the edges and asks the question the tool actually asked: for each
244
+ // import that crosses the folder's boundary, who is on the other side? The other
245
+ // side is named at the same depth as the folder, so a report about `server/mcp`
246
+ // talks about its siblings rather than collapsing to `server`.
247
+ function folderCrossings(a, folder) {
248
+ const prefix = folder ? folder + '/' : '';
249
+ const depth = folder ? folder.split('/').length : 1;
250
+ const topFolder = (p) => p.split('/')[0];
251
+ const other = (p) => (prefix && p.startsWith(prefix)
252
+ ? p.slice(prefix.length).split('/').slice(0, depth).join('/')
253
+ : p.split('/').slice(0, depth).join('/'));
254
+
255
+ const out = new Map();
256
+ const into = new Map();
257
+ for (const e of a.scan.edges || []) {
258
+ const fromIn = !prefix || e.from.startsWith(prefix);
259
+ const toIn = !prefix || e.to.startsWith(prefix);
260
+ // Every path is "in" the root, so with no prefix both flags are always true
261
+ // and nothing counts as a crossing. The root's own coupling is the top-level
262
+ // rollup, which is exactly the same question asked one level up.
263
+ if (!prefix) {
264
+ const from = topFolder(e.from);
265
+ const to = topFolder(e.to);
266
+ if (from !== to) out.set(to, (out.get(to) || 0) + 1);
267
+ continue;
268
+ }
269
+ if (fromIn === toIn) continue; // an edge entirely inside the folder is not a crossing
270
+ if (fromIn) {
271
+ const key = other(e.to);
272
+ out.set(key, (out.get(key) || 0) + 1);
273
+ } else {
274
+ const key = other(e.from);
275
+ into.set(key, (into.get(key) || 0) + 1);
276
+ }
277
+ }
278
+ const toList = (m) => [...m.entries()]
279
+ .map(([name, count]) => ({ folder: name, count }))
280
+ .sort((x, y) => y.count - x.count);
281
+ return { imports: toList(out), importedBy: toList(into) };
282
+ }
283
+
284
+ // The tour is the app's "read this first" list. The ordering lives in
285
+ // `shared/analyzer/tour.js` so the browser and this server cannot disagree about
286
+ // which files a newcomer should read.
287
+ function tourPayload(a) {
288
+ return {
289
+ root: a.root,
290
+ summary: a.overview,
291
+ stops: buildTourStops(a.scan, a.facts).map((s, i) => ({
292
+ order: i + 1,
293
+ path: s.path,
294
+ name: baseName(s.path),
295
+ why: s.why,
296
+ dependents: a.facts.fanIn[s.path] || 0,
297
+ dependencies: a.facts.fanOut[s.path] || 0,
298
+ })),
299
+ };
300
+ }
301
+
302
+ // ---- the tool table ---------------------------------------------------------
303
+
304
+ export const TOOLS = [
305
+ {
306
+ name: 'onboarder_scan',
307
+ description:
308
+ 'Scan a repository and return everything Onboarder knows about it: structure, entry points, '
309
+ + 'hubs, cycles, dependencies, services, health, security, history and a written overview. '
310
+ + 'Start here. The returned `root` is what every other tool takes as its `path`.',
311
+ inputSchema: {
312
+ type: 'object',
313
+ properties: {
314
+ path: PATH_PROPERTY,
315
+ force: { type: 'boolean', description: 'Rescan even if this repository is already cached. Defaults to false.' },
316
+ },
317
+ required: [],
318
+ },
319
+ async run(args) {
320
+ const a = await analyzeRepository(args.path, { force: !!args.force });
321
+ lastRoot = a.root;
322
+ return overviewPayload(a);
323
+ },
324
+ },
325
+
326
+ {
327
+ name: 'onboarder_rescan',
328
+ description:
329
+ 'Discard the cached analysis for a repository and scan it again. Use after changing files on disk, '
330
+ + 'since every other tool answers from the cached scan.',
331
+ inputSchema: { type: 'object', properties: { path: PATH_PROPERTY }, required: [] },
332
+ async run(args) {
333
+ const a = await analyzeRepository(args.path, { force: true });
334
+ lastRoot = a.root;
335
+ return overviewPayload(a);
336
+ },
337
+ },
338
+
339
+ {
340
+ name: 'onboarder_overview',
341
+ description:
342
+ 'The repository in one call: written summary, languages, entry points, the files everything depends '
343
+ + 'on, dependency cycles, services, and what the scan is unsure about. Cheap, because it reads the '
344
+ + 'cached analysis rather than scanning again.',
345
+ inputSchema: { type: 'object', properties: { path: PATH_PROPERTY }, required: [] },
346
+ async run(args) { return overviewPayload(await resolve(args)); },
347
+ },
348
+
349
+ {
350
+ name: 'onboarder_architecture',
351
+ description:
352
+ 'How the code is arranged: the dependency layers and which files sit in each, files nothing '
353
+ + 'reaches, the architectural patterns detected (hub-and-spoke, cycles, god objects, layer '
354
+ + 'violations), which folders import each other, and the services detected.',
355
+ inputSchema: { type: 'object', properties: { path: PATH_PROPERTY }, required: [] },
356
+ async run(args) { return architecturePayload(await resolve(args)); },
357
+ },
358
+
359
+ {
360
+ name: 'onboarder_tour',
361
+ description:
362
+ 'A guided reading order for a new contributor: the handful of files worth reading first, each with '
363
+ + 'the reason it matters and how many files depend on it. The fastest way to understand an '
364
+ + 'unfamiliar repository.',
365
+ inputSchema: { type: 'object', properties: { path: PATH_PROPERTY }, required: [] },
366
+ async run(args) { return tourPayload(await resolve(args)); },
367
+ },
368
+
369
+ {
370
+ name: 'onboarder_health',
371
+ description:
372
+ 'Maintainability report: an overall score and letter grade, what the score is made of, and the '
373
+ + 'riskiest files with their size, complexity, dependency counts and blast radius.',
374
+ inputSchema: {
375
+ type: 'object',
376
+ properties: { path: PATH_PROPERTY, limit: LIMIT_PROPERTY },
377
+ required: [],
378
+ },
379
+ async run(args) { return healthPayload(await resolve(args), limitOf(args.limit, 20)); },
380
+ },
381
+
382
+ {
383
+ name: 'onboarder_security',
384
+ description:
385
+ 'Static security findings from the built-in rules: hardcoded secrets, unsafe calls, injection '
386
+ + 'patterns and the rest, ordered by severity. These are the built-in rules only — use '
387
+ + 'onboarder_deep_analysis for the external scanners.',
388
+ inputSchema: {
389
+ type: 'object',
390
+ properties: { path: PATH_PROPERTY, limit: LIMIT_PROPERTY },
391
+ required: [],
392
+ },
393
+ async run(args) { return securityPayload(await resolve(args), limitOf(args.limit, 50, 500)); },
394
+ },
395
+
396
+ {
397
+ name: 'onboarder_history',
398
+ description:
399
+ 'What git knows: commit count, contributors, and the files with the highest churn weighted against '
400
+ + 'complexity — the change-risk hotspots, plus files only one person has ever touched and files '
401
+ + 'that keep changing together without importing each other.',
402
+ inputSchema: {
403
+ type: 'object',
404
+ properties: { path: PATH_PROPERTY, limit: LIMIT_PROPERTY },
405
+ required: [],
406
+ },
407
+ async run(args) { return historyPayload(await resolve(args), limitOf(args.limit, 25)); },
408
+ },
409
+
410
+ {
411
+ name: 'onboarder_dependencies',
412
+ description:
413
+ 'The dependency inventory: languages, package managers, every declared dependency with version, '
414
+ + 'category and documentation link, the project license with its compliance counts, and the CI '
415
+ + 'workflows with their triggers and jobs.',
416
+ inputSchema: { type: 'object', properties: { path: PATH_PROPERTY }, required: [] },
417
+ async run(args) { return dependenciesPayload(await resolve(args)); },
418
+ },
419
+ {
420
+ name: 'onboarder_explain_file',
421
+ description:
422
+ 'Explain one file: its role in the codebase, what it imports, what imports it, its exports, its '
423
+ + 'metrics, and a written summary. Set includeSource to get the file text as well.',
424
+ inputSchema: {
425
+ type: 'object',
426
+ properties: {
427
+ path: PATH_PROPERTY,
428
+ file: { type: 'string', description: 'Repository-relative path, for example server/router.js.' },
429
+ includeSource: { type: 'boolean', description: 'Also return the full file text. Defaults to false.' },
430
+ },
431
+ required: ['file'],
432
+ },
433
+ async run(args) {
434
+ const a = await resolve(args);
435
+ const { rel, file } = fileIn(a, args.file);
436
+ return {
437
+ path: rel,
438
+ role: fileFactsLine(rel, a.scan, a.facts),
439
+ documentation: fileStaticDoc(rel, a.scan, a.facts),
440
+ row: docFileRow(rel, a.scan, a.facts),
441
+ imports: a.facts.importsOf[rel] || [],
442
+ importedBy: a.facts.importers[rel] || [],
443
+ metrics: {
444
+ loc: file.loc,
445
+ complexity: file.complexity,
446
+ exports: (file.exports || []).length,
447
+ functions: (file.functions || []).map((fn) => fn.name),
448
+ },
449
+ summary: explainFile(rel, file, a.facts),
450
+ // Source last, and only when asked for: it is by far the largest part of
451
+ // the answer, and a model that wanted the structure does not need it.
452
+ ...(args.includeSource ? { source: (await readFileText(a, rel)).text } : {}),
453
+ };
454
+ },
455
+ },
456
+
457
+ {
458
+ name: 'onboarder_explain_folder',
459
+
460
+ description:
461
+ 'Explain one folder: what lives in it, which folders it imports, its entry points and hubs, and a '
462
+ + 'written summary of what the folder is for. Pass an empty string for the repository root.',
463
+ inputSchema: {
464
+ type: 'object',
465
+ properties: {
466
+ path: PATH_PROPERTY,
467
+ folder: { type: 'string', description: 'Repository-relative folder, for example server/mcp. Use "" for the root.' },
468
+ },
469
+ required: ['folder'],
470
+ },
471
+ async run(args) {
472
+ const a = await resolve(args);
473
+ const folder = String(args.folder ?? '').trim();
474
+ const paths = filesUnder(a, folder);
475
+ if (!paths.length && folder) throw new ToolError(`No files under "${folder}".`);
476
+
477
+ const parsed = parsedFilesUnder(a, folder);
478
+ if (!parsed.length && folder) {
479
+ throw new ToolError(`Nothing under "${folder}" was parsed as source — use onboarder_list_files instead.`);
480
+ }
481
+ // Subfolders are the directories directly inside `folder`, not every
482
+ // directory beneath it: a tree two levels down is noise in a summary.
483
+ const depth = folder ? folder.split('/').length : 0;
484
+ const subfolders = [...new Set(paths
485
+ .map((p) => p.split('/').slice(0, -1).join('/'))
486
+ .filter((d) => d && d.split('/').length === depth + 1))];
487
+
488
+ return {
489
+ folder: folder || '.',
490
+ fileCount: paths.length,
491
+ parsedFileCount: parsed.length,
492
+ subfolders: subfolders.slice(0, 40),
493
+ files: parsed.map((f) => docFileRow(f.path, a.scan, a.facts)),
494
+ // Which folders this one imports, and which import it. Counts are import
495
+ // edges, so `server/mcp → shared/analyzer: 6` means six files in here
496
+ // reach in there — not six files in there.
497
+ ...folderCrossings(a, folder),
498
+ documentation: folderStaticDoc(folder, a.scan, a.facts, subfolders.length),
499
+ summary: explainFolder(folder, a.scan, a.facts),
500
+ };
501
+ },
502
+ },
503
+
504
+ {
505
+ name: 'onboarder_read_file',
506
+ description:
507
+ 'The text of one file inside the repository. Paths outside the repository are refused, and files over '
508
+ + '200 KB are refused rather than truncated — use onboarder_search to find the part you need.',
509
+ inputSchema: {
510
+ type: 'object',
511
+ properties: {
512
+ path: PATH_PROPERTY,
513
+ file: { type: 'string', description: 'Repository-relative path.' },
514
+ },
515
+ required: ['file'],
516
+ },
517
+ async run(args) {
518
+ const a = await resolve(args);
519
+ return readFileText(a, args.file);
520
+ },
521
+ },
522
+
523
+ {
524
+ name: 'onboarder_list_files',
525
+ description:
526
+ 'List the files in the repository, or in one folder. Filter by a substring of the path, by extension, '
527
+ + 'and by whether the file is a test.',
528
+ inputSchema: {
529
+ type: 'object',
530
+ properties: {
531
+ path: PATH_PROPERTY,
532
+ folder: { type: 'string', description: 'Repository-relative folder. Use "" for the whole repository.' },
533
+ contains: { type: 'string', description: 'Keep only paths containing this text.' },
534
+ extension: { type: 'string', description: 'Keep only this extension, with or without the dot, e.g. "js".' },
535
+ tests: { type: 'boolean', description: 'true keeps only test files, false excludes them.' },
536
+ limit: LIMIT_PROPERTY,
537
+ },
538
+ required: [],
539
+ },
540
+ async run(args) {
541
+ const a = await resolve(args);
542
+ const limit = limitOf(args.limit, 200);
543
+ const folder = String(args.folder ?? '').trim();
544
+ const needle = String(args.contains || '').toLowerCase();
545
+ const ext = String(args.extension || '').trim().replace(/^\./, '').toLowerCase();
546
+ const isTest = (p) => /(^|\/)(tests?|__tests__)\//i.test(p) || /\.(test|spec)\./i.test(p);
547
+
548
+ // A folder listing spans subfolders and includes assets: "what is in here"
549
+ // is a question about the repository, not about what the parser understood.
550
+ let files = filesUnder(a, folder);
551
+ if (needle) files = files.filter((p) => p.toLowerCase().includes(needle));
552
+ if (ext) files = files.filter((p) => p.toLowerCase().endsWith('.' + ext));
553
+ if (args.tests === true) files = files.filter(isTest);
554
+ if (args.tests === false) files = files.filter((p) => !isTest(p));
555
+
556
+ return {
557
+ root: a.root,
558
+ folder: folder || '.',
559
+ total: files.length,
560
+ truncated: files.length > limit,
561
+ // `allFiles` rows are sometimes just a path string rather than a parsed
562
+ // file object, so both are mapped to their path and nothing else.
563
+ files: files.slice(0, limit).map((f) => (typeof f === 'string' ? f : f.path)),
564
+ };
565
+ },
566
+ },
567
+
568
+ {
569
+ name: 'onboarder_search',
570
+ description:
571
+ "Search the repository the way the app's search palette does: bare words rank by relevance, and the "
572
+ + 'full query language works too — path:, ext:, is:test, is:source, "exact phrases", -exclude, '
573
+ + '/regex/, and case: for case sensitivity.',
574
+ inputSchema: {
575
+ type: 'object',
576
+ properties: {
577
+ path: PATH_PROPERTY,
578
+ query: { type: 'string', description: 'The search query.' },
579
+ limit: LIMIT_PROPERTY,
580
+ caseSensitive: { type: 'boolean', description: 'Match case exactly. Defaults to false.' },
581
+ },
582
+ required: ['query'],
583
+ },
584
+ async run(args) {
585
+ const a = await resolve(args);
586
+ const out = searchDocuments(a.searchIndex, String(args.query || ''), {
587
+ limit: limitOf(args.limit, 20),
588
+ caseSensitive: !!args.caseSensitive,
589
+ });
590
+ // A regex that would not compile comes back as `error` with zero results.
591
+ // Passing that through as-is would tell the agent the code is not there,
592
+ // which is the one conclusion it must not draw from a typo in its own
593
+ // query — so it is raised as a tool error the model can see and correct.
594
+ if (out.error) throw new ToolError(out.error);
595
+ return {
596
+ query: out.query,
597
+ total: out.total,
598
+ indexed: out.indexed,
599
+ results: out.results.map((r) => ({ path: r.path, line: r.line, snippet: r.snippet })),
600
+ };
601
+ },
602
+ },
603
+
604
+ {
605
+ name: 'onboarder_deep_analysis',
606
+ description:
607
+ 'Run the optional external analyzers (semgrep, gitleaks, knip, and the rest) over the repository and '
608
+ + 'return their findings normalized. Slower than the built-in tools, and it only covers the analyzers '
609
+ + 'that are actually installed. Call onboarder_analyzer_status first to see which those are.',
610
+ inputSchema: {
611
+ type: 'object',
612
+ properties: {
613
+ path: PATH_PROPERTY,
614
+ tools: {
615
+ type: 'array',
616
+ items: { type: 'string', enum: TOOL_DEFS.map((d) => d.id) },
617
+ description: 'Which analyzers to run. Omit to run every installed one.',
618
+ },
619
+ kinds: {
620
+ type: 'array',
621
+ items: { type: 'string', enum: ['security', 'dead-code'] },
622
+ description: 'Narrow to a purpose.',
623
+ },
624
+ },
625
+ required: [],
626
+ },
627
+ async run(args) {
628
+ const a = await resolve(args);
629
+ return runExternalAnalysis(a.root, { tools: args.tools, kinds: args.kinds });
630
+ },
631
+ },
632
+
633
+ {
634
+ name: 'onboarder_analyzer_status',
635
+ description:
636
+ 'Which external analyzers are installed on this machine, what each one is for, and how to install the '
637
+ + 'ones that are missing. Takes no arguments.',
638
+ inputSchema: { type: 'object', properties: {}, required: [] },
639
+ async run() {
640
+ // `toolsStatus` is keyed by id — the shape the engines panel and the HTTP
641
+ // route both expect. It becomes a list here so an agent does not have to
642
+ // know that, but the same fields survive, so this and `/api/tools` cannot
643
+ // disagree about whether semgrep is installed.
644
+ return { analyzers: Object.values(toolsStatus()) };
645
+ },
646
+ },
647
+ ];
648
+
649
+ const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
650
+
651
+ // The MCP wire shape, which is not the same as the internal one: the handler
652
+ // stays off the table, and the schema is named `inputSchema` exactly as the spec
653
+ // requires.
654
+ export function toolDefinitions() {
655
+ return TOOLS.map((t) => ({
656
+ name: t.name,
657
+ description: t.description,
658
+ inputSchema: t.inputSchema,
659
+ }));
660
+ }
661
+
662
+ export function findTool(name) {
663
+ return BY_NAME.get(name) || null;
664
+ }
665
+
666
+ export async function callTool(name, args) {
667
+ const tool = findTool(name);
668
+ if (!tool) throw new ToolError(`No tool called "${name}".`);
669
+ return await tool.run(args || {});
670
+ }
671
+
672
+ // What the top-bar button polls. The runner fills in `running`, `pid` and the
673
+ // rest; this is the part that is the same either way.
674
+ export function mcpStatus() {
675
+ return {
676
+ transport: 'stdio',
677
+ toolCount: TOOLS.length,
678
+ tools: TOOLS.map((t) => t.name),
679
+ cachedRepositories: cachedRoots(),
680
+ lastRoot,
681
+ analyzers: toolsStatus(),
682
+ };
683
+ }