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,473 @@
1
+ // The scanner: walks a FileSource, dispatches per-language analyzers,
2
+ // resolves imports into edges, and hands back one big plain-data result.
3
+ //
4
+ // FileSource interface (both adapters implement it):
5
+ // list(dir) -> [{ name, path, type: 'file'|'dir' }]
6
+ // read(path) -> string
7
+ // Paths are repo-relative, POSIX style ('' is the root).
8
+
9
+ import { languageFor } from './languages/index.js';
10
+ import { dirOf, baseName, extOf } from './pathUtil.js';
11
+ import { codeStats, complexityOf } from './metrics.js';
12
+ import { analyzeSecurityFile } from './security.js';
13
+ import { analyzeLicenses } from './licenses.js';
14
+ import { analyzeWorkflows } from './workflows.js';
15
+
16
+ const SKIP_DIRS = new Set([
17
+ 'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next',
18
+ '.nuxt', 'target', 'vendor', '__pycache__', '.venv', 'venv', 'env',
19
+ '.idea', '.vscode', 'coverage', '.cache', '.turbo', 'tmp', 'temp',
20
+ 'DerivedData', 'Pods', '.gradle', 'bin', 'obj',
21
+ ]);
22
+
23
+ const SKIP_FILES = new Set([
24
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'composer.lock',
25
+ 'Gemfile.lock', 'Cargo.lock', 'poetry.lock', '.DS_Store',
26
+ ]);
27
+
28
+ const SKIP_EXTS = new Set([
29
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.icns', '.svg',
30
+ '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp4', '.mov', '.mp3',
31
+ '.wav', '.zip', '.gz', '.tar', '.rar', '.7z', '.pdf', '.jar', '.war',
32
+ '.class', '.o', '.a', '.so', '.dylib', '.dll', '.exe', '.pyc', '.wasm',
33
+ '.min.js', '.min.css', '.map', '.lock', '.bin', '.db', '.sqlite',
34
+ ]);
35
+
36
+ export const DEFAULT_LIMITS = { maxFiles: 4000, maxFileSize: 200 * 1024 };
37
+
38
+ function shouldSkipFile(name) {
39
+ if (SKIP_FILES.has(name)) return true;
40
+ if (SKIP_EXTS.has(extOf(name))) return true;
41
+ // extOf() only sees the final suffix, so name the common bundle shapes here.
42
+ return /\.min\.(js|css)$/i.test(name);
43
+ }
44
+
45
+ export async function scanRepo(source, options = {}) {
46
+ const limits = { ...DEFAULT_LIMITS, ...options };
47
+ const onProgress = options.onProgress || (() => {});
48
+ const startedAt = Date.now();
49
+
50
+ const ignore = await buildIgnoreMatcher(source);
51
+
52
+ // Every way a file or directory can drop out of the scan, counted apart.
53
+ // One integer for all of them hid the difference between "we ignored a
54
+ // lockfile" and "we could not read your source" — and only one of those is
55
+ // something the reader needs to know about.
56
+ const skips = {
57
+ ignored: 0, // matched a .gitignore rule
58
+ vendorDir: 0, // node_modules, dist, .git, any dotfolder
59
+ notSource: 0, // images, lockfiles, minified bundles
60
+ listFailed: 0, // the directory would not list
61
+ notCode: 0, // no analyzer for this extension (README.md, .yml, …)
62
+ readFailed: 0, // the file would not read
63
+ tooLarge: 0, // over maxFileSize
64
+ analyzeFailed: 0, // the language analyzer threw
65
+ };
66
+
67
+ // ---- Walk --------------------------------------------------------------
68
+ const allFiles = [];
69
+ let truncated = null;
70
+ const queue = [''];
71
+ walk: while (queue.length) {
72
+ const dir = queue.shift();
73
+ let entries;
74
+ try {
75
+ entries = await source.list(dir);
76
+ } catch {
77
+ skips.listFailed++;
78
+ continue;
79
+ }
80
+ for (const entry of entries) {
81
+ if (allFiles.length >= limits.maxFiles) {
82
+ // Stop the entire walk, not just this directory. A plain `break` here
83
+ // left every already-queued directory to be listed anyway, so the cap
84
+ // was both slower and less predictable than it looked.
85
+ truncated = { atFiles: allFiles.length, dirsQueued: queue.length };
86
+ break walk;
87
+ }
88
+ if (ignore(entry.path)) {
89
+ skips.ignored++;
90
+ continue;
91
+ }
92
+ if (entry.type === 'dir') {
93
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
94
+ skips.vendorDir++;
95
+ continue;
96
+ }
97
+ queue.push(entry.path);
98
+ } else {
99
+ if (shouldSkipFile(entry.name)) {
100
+ skips.notSource++;
101
+ continue;
102
+ }
103
+ allFiles.push(entry.path);
104
+ }
105
+ }
106
+ onProgress({ phase: 'walk', found: allFiles.length });
107
+ }
108
+ allFiles.sort();
109
+
110
+ const fileSet = new Set(allFiles);
111
+ const dirSet = new Set(['']);
112
+ for (const p of allFiles) {
113
+ let d = dirOf(p);
114
+ while (true) {
115
+ if (dirSet.has(d)) break;
116
+ dirSet.add(d);
117
+ d = dirOf(d);
118
+ }
119
+ }
120
+ const has = (p) => fileSet.has(p);
121
+ const hasDir = (d) => dirSet.has(d);
122
+ const nameIndex = new Map();
123
+ for (const p of allFiles) {
124
+ const n = baseName(p);
125
+ if (!nameIndex.has(n)) nameIndex.set(n, p);
126
+ }
127
+ const findByName = (n) => nameIndex.get(n) || null;
128
+
129
+ const context = {
130
+ hasDir,
131
+ findByName,
132
+ modulePath: await readModulePath(source),
133
+ tsPaths: await readTsConfigPaths(source),
134
+ };
135
+
136
+ // ---- Parse ---------------------------------------------------------------
137
+ // Reads run a window ahead of the analysis; the analysis stays strictly in
138
+ // path order. Waiting for one `read` to land before starting the next was the
139
+ // largest wall-clock cost of a scan and the one with the least to show for
140
+ // it — every adapter behind `FileSource` is I/O, and neither `node:fs` nor the
141
+ // File System Access API cares how many requests are outstanding.
142
+ //
143
+ // Order matters more than it looks: `files`, `edges` and every skip count
144
+ // have to come out identical no matter how the reads interleave, or two scans
145
+ // of one repo would disagree. So the window only prefetches — the loop below
146
+ // consumes it one file at a time, in the order `allFiles` was sorted into.
147
+ //
148
+ // The language is resolved here and kept. The resolve phase needs the same
149
+ // answer per file and used to ask `languageFor` for it a second time.
150
+ const files = [];
151
+ const langOf = new Map();
152
+ let parsed = 0;
153
+ const codeFiles = [];
154
+ for (const path of allFiles) {
155
+ const lang = languageFor(path);
156
+ if (!lang) {
157
+ skips.notCode++;
158
+ continue;
159
+ }
160
+ langOf.set(path, lang);
161
+ codeFiles.push(path);
162
+ }
163
+
164
+ for await (const { path, text, failed } of readAhead(source, codeFiles)) {
165
+ if (failed) {
166
+ skips.readFailed++;
167
+ continue;
168
+ }
169
+ if (text.length > limits.maxFileSize) {
170
+ skips.tooLarge++;
171
+ continue;
172
+ }
173
+ const lang = langOf.get(path);
174
+ let analysis;
175
+ try {
176
+ analysis = lang.analyze(text, path);
177
+ } catch {
178
+ skips.analyzeFailed++;
179
+ continue;
180
+ }
181
+ const cs = codeStats(text, lang.id);
182
+ const findings = analyzeSecurityFile(text, lang.id);
183
+ const docRatio = cs.code + cs.comment > 0 ? Math.round((cs.comment / (cs.code + cs.comment)) * 100) : 0;
184
+ files.push({
185
+ path,
186
+ dir: dirOf(path),
187
+ name: baseName(path),
188
+ ext: extOf(path),
189
+ lang: lang.id,
190
+ size: text.length,
191
+ loc: cs.code,
192
+ comment: cs.comment,
193
+ blank: cs.blank,
194
+ lines: cs.lines,
195
+ docRatio,
196
+ complexity: complexityOf(text, lang.id),
197
+ findings: findings.length ? findings : undefined,
198
+ ...analysis,
199
+ });
200
+ parsed++;
201
+ if (parsed % 25 === 0) onProgress({ phase: 'parse', done: parsed });
202
+ }
203
+ onProgress({ phase: 'parse', done: parsed });
204
+ // ---- Resolve imports into edges -------------------------------------------
205
+ const edges = [];
206
+ const edgeKeys = new Map(); // key -> edge
207
+ const externals = new Map(); // package name -> Set of files
208
+
209
+ // What share of the imports we found did we manage to place? A regex-based
210
+ // analyzer owes its reader this number more than any other: it is the
211
+ // engine's own estimate of how much of the graph is missing.
212
+ const tally = { total: 0, internal: 0, external: 0, unresolved: 0 };
213
+ const unresolvedSpecs = new Map(); // spec -> { count, from }
214
+
215
+ // Go resolves an import to a *directory*, and every .go file in it is a
216
+ // target. Indexed once here rather than scanned per import — a Go repo with
217
+ // 1,000 files and 8,000 imports was doing eight million comparisons for it.
218
+ const goFilesByDir = new Map();
219
+ for (const f of files) {
220
+ if (f.lang !== 'go') continue;
221
+ const bucket = goFilesByDir.get(f.dir);
222
+ if (bucket) bucket.push(f.path);
223
+ else goFilesByDir.set(f.dir, [f.path]);
224
+ }
225
+
226
+ for (const file of files) {
227
+ const lang = langOf.get(file.path);
228
+ for (const imp of file.imports) {
229
+ tally.total++;
230
+ const res = lang.resolveImport(imp.spec, file.path, has, context, imp);
231
+ if (res.path) {
232
+ tally.internal++;
233
+ addEdge(edges, edgeKeys, file.path, res.path, 'imports', imp.symbols);
234
+ } else if (res.packageDir) {
235
+ tally.internal++;
236
+ for (const target of goFilesByDir.get(res.packageDir) || []) {
237
+ addEdge(edges, edgeKeys, file.path, target, 'imports', imp.symbols);
238
+ }
239
+ } else if (res.external) {
240
+ tally.external++;
241
+ if (!externals.has(res.external)) externals.set(res.external, new Set());
242
+ externals.get(res.external).add(file.path);
243
+ } else {
244
+ // Every resolver ends with `return { unresolved: spec }`. This branch
245
+ // is where that used to disappear for want of an `else` — the import
246
+ // was counted nowhere and the graph simply came up short in silence.
247
+ tally.unresolved++;
248
+ const spec = res.unresolved || imp.spec;
249
+ const seen = unresolvedSpecs.get(spec);
250
+ if (seen) seen.count++;
251
+ else unresolvedSpecs.set(spec, { spec, count: 1, from: file.path });
252
+ }
253
+ }
254
+ }
255
+
256
+ // Resolved-or-correctly-external over everything we saw. An external package
257
+ // counts as a success: the resolver knew what it was looking at.
258
+ const confidence = tally.total
259
+ ? Math.round(((tally.internal + tally.external) / tally.total) * 100)
260
+ : 100;
261
+ const worstUnresolved = [...unresolvedSpecs.values()]
262
+ .sort((a, b) => b.count - a.count || a.spec.localeCompare(b.spec))
263
+ .slice(0, 12);
264
+
265
+ const externalList = [...externals.entries()]
266
+ .map(([name, usedBySet]) => ({ name, usedBy: [...usedBySet].sort() }))
267
+ .sort((a, b) => b.usedBy.length - a.usedBy.length || a.name.localeCompare(b.name));
268
+
269
+ // Folder rollups with full size/LOC/documentation breakdown
270
+ const filesPerDir = new Map();
271
+ const folderData = new Map();
272
+ for (const p of allFiles) {
273
+ const d = dirOf(p);
274
+ filesPerDir.set(d, (filesPerDir.get(d) || 0) + 1);
275
+ }
276
+ for (const path of dirSet) {
277
+ if (!path) continue;
278
+ folderData.set(path, {
279
+ path,
280
+ name: baseName(path),
281
+ depth: path.split('/').length,
282
+ fileCount: filesPerDir.get(path) || 0,
283
+ loc: 0,
284
+ comment: 0,
285
+ blank: 0,
286
+ size: 0,
287
+ langs: {},
288
+ hasReadme: false,
289
+ });
290
+ }
291
+ for (const p of allFiles) {
292
+ const d = dirOf(p);
293
+ const bn = baseName(p).toLowerCase();
294
+ if (bn.startsWith('readme.') || bn === 'readme') {
295
+ const entry = folderData.get(d);
296
+ if (entry) entry.hasReadme = true;
297
+ }
298
+ }
299
+ for (const f of files) {
300
+ const entry = folderData.get(f.dir);
301
+ if (entry) {
302
+ entry.loc += f.loc || 0;
303
+ entry.comment += f.comment || 0;
304
+ entry.blank += f.blank || 0;
305
+ entry.size += f.size || 0;
306
+ entry.langs[f.lang] = (entry.langs[f.lang] || 0) + (f.loc || 0);
307
+ }
308
+ }
309
+ const folders = [...folderData.values()]
310
+ .map((f) => ({
311
+ ...f,
312
+ docRatio: f.loc + f.comment > 0 ? Math.round((f.comment / (f.loc + f.comment)) * 100) : 0,
313
+ }))
314
+ .sort((a, b) => a.path.localeCompare(b.path));
315
+
316
+ const skippedTotal = Object.values(skips).reduce((a, b) => a + b, 0);
317
+
318
+ const stats = {
319
+ filesTotal: allFiles.length,
320
+ filesParsed: parsed,
321
+ skipped: skippedTotal,
322
+ skips,
323
+ truncated,
324
+ edgeCount: edges.length,
325
+ imports: {
326
+ total: tally.total,
327
+ internal: tally.internal,
328
+ external: tally.external,
329
+ unresolved: tally.unresolved,
330
+ confidence,
331
+ worst: worstUnresolved,
332
+ },
333
+ tookMs: Date.now() - startedAt,
334
+ languages: countBy(files, (f) => f.lang),
335
+ languageLoc: files.reduce((acc, f) => {
336
+ acc[f.lang] = (acc[f.lang] || 0) + (f.loc || 0);
337
+ return acc;
338
+ }, {}),
339
+ };
340
+
341
+ const [workflows, licenseReport] = await Promise.all([
342
+ analyzeWorkflows(source).catch(() => []),
343
+ analyzeLicenses(source, context.manifest || {}).catch(() => null),
344
+ ]);
345
+
346
+ return {
347
+ root: source.root || '(unknown)',
348
+ name: source.name || baseName(source.root || '') || 'repo',
349
+ scannedAt: new Date().toISOString(),
350
+ stats,
351
+ allFiles,
352
+ files,
353
+ edges,
354
+ externals: externalList,
355
+ folders,
356
+ workflows: workflows || [],
357
+ sbom: licenseReport?.sbom || [],
358
+ license: licenseReport?.projectLicense || { id: 'UNKNOWN', name: 'Unknown' },
359
+ licenseReport: licenseReport || undefined,
360
+ };
361
+ }
362
+
363
+ function addEdge(edges, edgeKeys, from, to, kind, symbols = []) {
364
+ if (from === to) return;
365
+ const key = from + '->' + to;
366
+ const existing = edgeKeys.get(key);
367
+ if (existing) {
368
+ if (symbols && symbols.length) {
369
+ existing.symbols = Array.from(new Set([...(existing.symbols || []), ...symbols]));
370
+ }
371
+ return;
372
+ }
373
+ const edge = { from, to, kind, symbols: symbols ? symbols.slice() : [] };
374
+ edgeKeys.set(key, edge);
375
+ edges.push(edge);
376
+ }
377
+
378
+ // How many reads may be in flight at once. Small on purpose: a file is read
379
+ // whole before its size can be checked (a `FileSource` has no `stat`), so this
380
+ // is also a multiplier on peak memory. Eight is enough to keep a disk or the
381
+ // browser's file layer busy without holding a heap of large files at once.
382
+ const READ_AHEAD = 8;
383
+
384
+ // Yields `{ path, text, failed }` for each path, in the order given, while
385
+ // keeping up to READ_AHEAD reads in flight. Failures are yielded rather than
386
+ // thrown so the caller can count them and keep going — one unreadable file
387
+ // should not end a scan.
388
+ async function* readAhead(source, paths, width = READ_AHEAD) {
389
+ const read = (path) =>
390
+ Promise.resolve()
391
+ .then(() => source.read(path))
392
+ .then((text) => ({ path, text, failed: false }), () => ({ path, text: '', failed: true }));
393
+
394
+ const inFlight = [];
395
+ let next = 0;
396
+ while (next < paths.length && inFlight.length < width) inFlight.push(read(paths[next++]));
397
+
398
+ while (inFlight.length) {
399
+ // shift() before the await: the slot is refilled immediately, so the next
400
+ // read starts while this file is being analyzed rather than after it.
401
+ const settled = inFlight.shift();
402
+ if (next < paths.length) inFlight.push(read(paths[next++]));
403
+ yield await settled;
404
+ }
405
+ }
406
+
407
+ function countBy(items, fn) {
408
+ const out = {};
409
+ for (const item of items) {
410
+ const k = fn(item);
411
+ out[k] = (out[k] || 0) + 1;
412
+ }
413
+ return out;
414
+ }
415
+
416
+ async function readModulePath(source) {
417
+ try {
418
+ const text = await source.read('go.mod');
419
+ const m = text.match(/^module\s+(\S+)/m);
420
+ return m ? m[1] : '';
421
+ } catch {
422
+ return '';
423
+ }
424
+ }
425
+
426
+ // Simplified .gitignore: plain names, dir/, *.ext, /anchored. No negation —
427
+ // documented limitation.
428
+ async function buildIgnoreMatcher(source) {
429
+ let text = '';
430
+ try {
431
+ text = await source.read('.gitignore');
432
+ } catch {
433
+ return () => false;
434
+ }
435
+ const rules = [];
436
+ for (let line of text.split('\n')) {
437
+ line = line.trim();
438
+ if (!line || line.startsWith('#') || line.startsWith('!')) continue;
439
+ const anchored = line.startsWith('/');
440
+ const pattern = line.replace(/^\//, '').replace(/\/$/, '');
441
+ if (!pattern) continue;
442
+ const re = new RegExp(
443
+ (anchored ? '^' : '(^|/)') +
444
+ pattern.split(/(\*\*|\*)/).map((part) =>
445
+ part === '**' ? '.*' : part === '*' ? '[^/]*' : escapeRe(part)
446
+ ).join('') +
447
+ '($|/)'
448
+ );
449
+ rules.push({ re });
450
+ }
451
+ return (path) => rules.some((r) => r.re.test(path));
452
+ }
453
+
454
+ function escapeRe(s) {
455
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
456
+ }
457
+
458
+ async function readTsConfigPaths(source) {
459
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
460
+ try {
461
+ const text = await source.read(name);
462
+ const clean = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
463
+ const json = JSON.parse(clean);
464
+ const compilerOptions = json.compilerOptions || {};
465
+ const baseUrl = compilerOptions.baseUrl ? compilerOptions.baseUrl.replace(/^\.?\//, '').replace(/\/$/, '') : '';
466
+ const paths = compilerOptions.paths || {};
467
+ if (Object.keys(paths).length || baseUrl) {
468
+ return { baseUrl, paths };
469
+ }
470
+ } catch {}
471
+ }
472
+ return null;
473
+ }
@@ -0,0 +1,187 @@
1
+ // A built-in vulnerability & code-quality rule engine — a Semgrep-lite that
2
+ // runs entirely locally, no external binary. Rules are regex/line based,
3
+ // language-scoped, each with a severity and a category. Per-file findings are
4
+ // attached during the scan; summarizeSecurity rolls them into a repo grade.
5
+
6
+ import { lineCounter } from './util.js';
7
+
8
+ export const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
9
+ export const sevRank = (s) => SEVERITIES.indexOf(s);
10
+
11
+ // Each rule: id, severity, category, message, rx. `byLine` rules test each
12
+ // line (and report the line number); others test the whole source once.
13
+ const RULES = [
14
+ { id: 'hardcoded-secret', severity: 'critical', category: 'secret',
15
+ message: 'Hardcoded credential — move it to env/config.',
16
+ rx: /\b(api[_-]?key|apikey|secret|client[_-]?secret|password|passwd|access[_-]?token|auth[_-]?token|private[_-]?key)\b\s*[:=]\s*['"`][^'"`\s]{8,}['"`]/i, byLine: true },
17
+ { id: 'aws-key', severity: 'critical', category: 'secret',
18
+ message: 'Looks like an AWS access key id.',
19
+ rx: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/, byLine: true },
20
+ { id: 'private-key-block', severity: 'critical', category: 'secret',
21
+ message: 'A private key is committed to the source.',
22
+ rx: /-----BEGIN (RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----/ },
23
+ { id: 'eval', severity: 'high', category: 'injection', langs: ['javascript'],
24
+ message: 'eval() executes strings as code — classic injection sink.',
25
+ rx: /\beval\s*\(/, byLine: true },
26
+ { id: 'new-function', severity: 'high', category: 'injection', langs: ['javascript'],
27
+ message: 'new Function() is eval in a trench coat.',
28
+ rx: /\bnew Function\s*\(/, byLine: true },
29
+ { id: 'child-exec', severity: 'high', category: 'injection', langs: ['javascript'],
30
+ message: 'Shelling out — if any argument is user input, this is command injection.',
31
+ rx: /\b(child_process\.(exec|spawn)|execSync|spawnSync)\s*\(/, byLine: true },
32
+ { id: 'os-system', severity: 'high', category: 'injection', langs: ['python'],
33
+ message: 'os.system / shell=True runs a shell — sanitize any input reaching it.',
34
+ rx: /\bos\.system\s*\(|shell\s*=\s*True/, byLine: true },
35
+ { id: 'sql-concat', severity: 'high', category: 'injection',
36
+ message: 'SQL built by string concatenation/formatting — use parameterized queries.',
37
+ rx: /\b(execute|query|exec|raw)\s*\(\s*[^)]*(\+|\$\{|\.format\(|f["'])/, byLine: true },
38
+ { id: 'innerhtml', severity: 'medium', category: 'xss', langs: ['javascript'],
39
+ message: 'Writing raw HTML — if the value is user-controlled, this is XSS.',
40
+ rx: /\b(innerHTML|outerHTML)\s*=|dangerouslySetInnerHTML|document\.write\s*\(/, byLine: true,
41
+ ignore: /\b(innerHTML|outerHTML)\s*=\s*(['"`])\2\s*;?\s*$/ },
42
+ { id: 'weak-hash', severity: 'medium', category: 'crypto',
43
+ message: 'MD5/SHA-1 are broken — use SHA-256+, and bcrypt/argon2 for passwords.',
44
+ rx: /\b(md5|sha1)\s*\(|createHash\(\s*['"](md5|sha1)['"]/, byLine: true },
45
+ { id: 'math-random', severity: 'low', category: 'crypto', langs: ['javascript'],
46
+ message: 'Math.random() is not cryptographically secure.',
47
+ rx: /\bMath\.random\s*\(/, byLine: true },
48
+ { id: 'insecure-deser', severity: 'high', category: 'deserialization',
49
+ message: 'Insecure deserialization — untrusted data here is remote code execution.',
50
+ rx: /\b(pickle\.loads?|yaml\.load\s*\((?![^)]*SafeLoader)|unserialize\s*\(|ObjectInputStream)/, byLine: true },
51
+ { id: 'open-redirect', severity: 'medium', category: 'redirect',
52
+ message: 'Redirect target comes from the request — open-redirect risk.',
53
+ rx: /\b(res\.redirect|redirect)\s*\(\s*(req\.|request\.)/, byLine: true },
54
+ { id: 'localhost-url', severity: 'low', category: 'config',
55
+ message: 'Hardcoded localhost/127.0.0.1 — will break outside this machine.',
56
+ rx: /['"`](https?:\/\/(localhost|127\.0\.0\.1)|localhost:\d|127\.0\.0\.1)/, byLine: true },
57
+ { id: 'debugger', severity: 'low', category: 'debug', langs: ['javascript'],
58
+ message: 'A debugger statement is left in.',
59
+ rx: /\bdebugger\s*;?/, byLine: true },
60
+ { id: 'console-log', severity: 'info', category: 'debug', langs: ['javascript'],
61
+ message: 'console.log — fine locally, noise in production.',
62
+ rx: /\bconsole\.(log|debug|info)\s*\(/, byLine: true },
63
+ { id: 'print-stmt', severity: 'info', category: 'debug', langs: ['python'],
64
+ message: 'print() — use the logger in production code.',
65
+ rx: /^\s*print\s*\(/, byLine: true },
66
+ { id: 'todo', severity: 'info', category: 'quality',
67
+ message: 'TODO/FIXME left in the source.',
68
+ rx: /\b(TODO|FIXME|XXX|HACK)\b/, byLine: true },
69
+ { id: 'loose-eq', severity: 'low', category: 'quality', langs: ['javascript'],
70
+ message: 'Loose equality (==) coerces types — prefer ===.',
71
+ rx: /[^=!<>]==[^=]/, byLine: true },
72
+ { id: 'var-keyword', severity: 'low', category: 'quality', langs: ['javascript'],
73
+ message: 'var is function-scoped and hoisted — prefer let/const.',
74
+ rx: /\bvar\s+[A-Za-z_$]/, byLine: true },
75
+ { id: 'empty-catch', severity: 'medium', category: 'quality',
76
+ message: 'Swallowed exception — errors vanish silently here.',
77
+ rx: /(catch\s*\([^)]*\)\s*\{\s*\}|except\s*\w*\s*:\s*pass\b)/, byLine: true },
78
+ // JSON Web Tokens start with a base64-encoded header that is always
79
+ // `eyJ…` ("{"). A line that contains one and treats it as a literal
80
+ // string — rather than reading it from a vault — is the same shape of
81
+ // mistake as a hardcoded API key. Three base64 segments separated by
82
+ // dots is the JWT signature; we only require the first segment to keep
83
+ // the rule cheap.
84
+ { id: 'jwt-secret', severity: 'critical', category: 'secret',
85
+ message: 'Looks like a hardcoded JWT — treat it like a credential, not a string.',
86
+ rx: /['"`](eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})['"`]/, byLine: true },
87
+ // The `os-system` rule already catches `os.system` and `shell=True` on
88
+ // subprocess calls, but `subprocess.Popen(..., shell=True)` and
89
+ // `subprocess.run(..., shell=True)` are the modern Python way to do
90
+ // the same thing, and a regex on `shell=True` alone matches both
91
+ // without the false-positive rate. A separate rule for Popen keeps the
92
+ // message specific and the langs filter honest.
93
+ { id: 'subprocess-shell', severity: 'high', category: 'injection', langs: ['python'],
94
+ message: 'subprocess.* with shell=True runs a shell — sanitize any input reaching it.',
95
+ rx: /\bsubprocess\.(Popen|run|call|check_output|check_call)\s*\([^)]*shell\s*=\s*True/, byLine: true },
96
+ // Go's `http.ListenAndServe` defaults to binding all interfaces when
97
+ // given a `":port"` address (the colon makes it a wildcard), and a
98
+ // literal `0.0.0.0` does the same. That is fine inside a container with
99
+ // a tight network policy and a bug everywhere else: the dev box, the
100
+ // staging cluster, the CI runner, the laptop on coffee-shop Wi-Fi. The
101
+ // rule does not distinguish "0.0.0.0" from "127.0.0.1" because the
102
+ // safer pattern is always `127.0.0.1:<port>`, so the finding is a
103
+ // nudge rather than an alarm.
104
+ { id: 'go-public-listen', severity: 'medium', category: 'config', langs: ['go'],
105
+ message: 'Go listener binds all interfaces (":port" or "0.0.0.0") — bind 127.0.0.1 explicitly.',
106
+ rx: /(ListenAndServe|Listen|TLS\s*\.\s*Listen|ListenAndServeTLS)\s*\(\s*["'](:[0-9]+|0\.0\.0\.0(?::[0-9]+)?|::)["']/, byLine: true },
107
+ ];
108
+
109
+ // Findings for one file, run at scan time while the source is in hand.
110
+ export function analyzeSecurityFile(source, lang) {
111
+ const findings = [];
112
+ const text = String(source);
113
+ const lines = text.split('\n');
114
+ // Whole-file rules report a byte offset and need it as a line number. Built
115
+ // once and lazily: most files trip none of these rules and never build it.
116
+ const lineAt = lineCounter(text);
117
+ for (const rule of RULES) {
118
+ if (rule.langs && !rule.langs.includes(lang)) continue;
119
+ if (rule.byLine) {
120
+ for (let i = 0; i < lines.length; i++) {
121
+ if (rule.ignore && rule.ignore.test(lines[i])) continue;
122
+ const m = lines[i].match(rule.rx);
123
+ if (m) {
124
+ findings.push({ rule: rule.id, severity: rule.severity, category: rule.category, message: rule.message, line: i + 1, excerpt: lines[i].trim().slice(0, 90) });
125
+ }
126
+ }
127
+ } else {
128
+ const m = text.match(rule.rx);
129
+ if (m) {
130
+ findings.push({ rule: rule.id, severity: rule.severity, category: rule.category, message: rule.message, line: lineAt(m.index), excerpt: (m[0] || '').slice(0, 90) });
131
+ }
132
+ }
133
+ }
134
+ return findings;
135
+ }
136
+
137
+ // Repo-level roll-up: counts by severity and category, the files carrying
138
+ // findings (worst-first), and a 0–100 score + grade.
139
+ export function summarizeSecurity(scan) {
140
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
141
+ const byCat = {};
142
+ const files = [];
143
+ for (const f of scan.files) {
144
+ const finds = f.findings || [];
145
+ if (!finds.length) continue;
146
+ let worst = 'info';
147
+ for (const x of finds) {
148
+ counts[x.severity]++;
149
+ byCat[x.category] = (byCat[x.category] || 0) + 1;
150
+ if (sevRank(x.severity) > sevRank(worst)) worst = x.severity;
151
+ }
152
+ files.push({ path: f.path, name: f.name, findings: finds, worst, count: finds.length });
153
+ }
154
+ files.sort((a, b) => sevRank(b.worst) - sevRank(a.worst) || b.count - a.count);
155
+
156
+ let score = 100;
157
+ score -= counts.critical * 15;
158
+ score -= counts.high * 6;
159
+ score -= counts.medium * 2;
160
+ score -= Math.min(10, counts.low);
161
+ score = Math.max(0, Math.round(score));
162
+ const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 45 ? 'D' : 'F';
163
+
164
+ return { counts, byCat, files, score, grade, total: files.reduce((s, f) => s + f.count, 0) };
165
+ }
166
+
167
+ // Fold an external engine's findings into the scan, in place, so the security
168
+ // view — which reads `scan.files[].findings` and re-summarizes — sees one list.
169
+ // Dedup is on (path, line, rule): two engines flagging the same line for the
170
+ // same reason are one problem. This is the shared, pure half of the merge; the
171
+ // fetching and spawning live on the server.
172
+ export function mergeFindingsIntoScan(scan, external) {
173
+ if (!scan || !Array.isArray(external) || !external.length) return 0;
174
+ const byPath = new Map(scan.files.map((f) => [f.path, f]));
175
+ let added = 0;
176
+ for (const finding of external) {
177
+ const file = byPath.get(finding.path);
178
+ if (!file) continue; // a path the scanner never accepted is not ours to show
179
+ const list = file.findings || (file.findings = []);
180
+ const dupe = list.some((x) => x.line === finding.line && x.rule === finding.rule);
181
+ if (dupe) continue;
182
+ list.push(finding);
183
+ added++;
184
+ }
185
+ return added;
186
+ }
187
+