Skip to content
File

Blob: .opencode/tools/bazel-deps.ts

typescript530 lines
1import { tool } from '@opencode-ai/plugin';
2import path from 'path';
3 
4// Known aliases: short name → Bazel repo name
5// Built from build/deps/deps.jsonc, v8.MODULE.bazel, MODULE.bazel, etc.
6const ALIASES: Record<string, string> = {
7 boringssl: 'ssl',
8 openssl: 'ssl',
9 v8: 'workerd-v8',
10 icu: 'com_googlesource_chromium_icu',
11 cxx: 'workerd-cxx',
12 sqlite: 'sqlite3',
13 capnp: 'capnp-cpp',
14 capnproto: 'capnp-cpp',
15 kj: 'capnp-cpp',
16};
17 
18// Ecosystem qualifiers: prefix syntax to disambiguate target type.
19// e.g. "rust:base64" → look up the Rust crate, not the C++ target.
20type Ecosystem = 'rust' | 'cpp' | null;
21 
22const ECOSYSTEM_PREFIXES: Record<string, Ecosystem> = {
23 'rust:': 'rust',
24 'crate:': 'rust',
25 'cpp:': 'cpp',
26 'cc:': 'cpp',
27};
28 
29/** Parse optional ecosystem qualifier from target string. */
30function parseQualifier(target: string): {
31 ecosystem: Ecosystem;
32 name: string;
33} {
34 for (const [prefix, ecosystem] of Object.entries(ECOSYSTEM_PREFIXES)) {
35 if (target.toLowerCase().startsWith(prefix)) {
36 return { ecosystem, name: target.slice(prefix.length) };
37 }
38 }
39 return { ecosystem: null, name: target };
40}
41 
42export default tool({
43 description:
44 'Bazel dependency lookup tool. Supports two directions:\n' +
45 '- "rdeps" (default): find what workerd targets depend ON a given dependency\n' +
46 '- "deps": find what a given workerd target depends on\n' +
47 'Resolves short names (v8, ada-url, ssl, thiserror) to Bazel labels, ' +
48 'runs queries, and returns results grouped by component.\n' +
49 'Supports ecosystem qualifiers to disambiguate targets: ' +
50 '"rust:base64" or "crate:base64" for Rust crates, ' +
51 '"cpp:base64" or "cc:base64" for C++ targets.',
52 args: {
53 target: tool.schema
54 .string()
55 .describe(
56 'Target to query. Can be a short dependency name (v8, ada-url, ssl, thiserror), ' +
57 'an external label (@ada-url//:ada), an internal label (//src/workerd/jsg:jsg), ' +
58 'or a file path (src/workerd/api/http.c++). ' +
59 'Use ecosystem qualifiers to disambiguate: "rust:base64" or "crate:base64" ' +
60 'for Rust crates, "cpp:base64" or "cc:base64" for C++ targets.'
61 ),
62 direction: tool.schema
63 .enum(['rdeps', 'deps'])
64 .optional()
65 .describe(
66 'Query direction: "rdeps" (default) finds what depends on this target; ' +
67 '"deps" finds what this target depends on'
68 ),
69 depth: tool.schema
70 .number()
71 .optional()
72 .describe(
73 'Depth of search (default: 1 for direct only). ' +
74 'Higher values include transitive results but are slower.'
75 ),
76 },
77 async execute(args, ctx) {
78 const root = ctx.worktree || ctx.directory;
79 const depth = args.depth ?? 1;
80 const direction = args.direction ?? 'rdeps';
81 const { ecosystem, name } = parseQualifier(args.target.trim());
82 
83 if (direction === 'deps') {
84 return await queryForwardDeps(name, depth, root, ecosystem);
85 } else {
86 return await queryReverseDeps(name, depth, root, ecosystem);
87 }
88 },
89});
90 
91// =============================================================================
92// Forward dependencies (deps): what does this target depend on?
93// =============================================================================
94 
95async function queryForwardDeps(
96 target: string,
97 depth: number,
98 root: string,
99 ecosystem: Ecosystem = null
100): Promise<string> {
101 // Resolve file paths to Bazel labels
102 const label = await resolveToInternalLabel(target, root, ecosystem);
103 if (!label) {
104 return `Could not resolve "${target}" to a Bazel target. Provide a full label like \`//src/workerd/api:http\`.`;
105 }
106 
107 // Run forward deps and rdeps in parallel
108 const [fwdResult, revResult] = await Promise.all([
109 bazelQuery(`deps(${label}, ${depth})`, root),
110 bazelQuery(`rdeps(//src/..., ${label}, 1)`, root),
111 ]);
112 
113 let out = `## Dependencies: ${target}\n\n`;
114 out += `**Target:** \`${label}\`\n\n`;
115 
116 // Forward deps: split into internal and external
117 const fwdTargets = fwdResult
118 .filter((t) => t !== label)
119 .filter(
120 (t) =>
121 !t.startsWith('@bazel_tools') &&
122 !t.startsWith('@platforms') &&
123 !t.startsWith('@@rules_')
124 );
125 
126 const internal = fwdTargets.filter((t) => t.startsWith('//src/')).sort();
127 const external = fwdTargets
128 .filter((t) => t.startsWith('@') || t.startsWith('@@'))
129 .sort();
130 
131 // Group internal deps
132 if (internal.length > 0) {
133 const groups = groupByComponent(internal);
134 out += `### Direct dependencies — internal (${internal.length} targets)\n\n`;
135 for (const [component, targets] of Object.entries(groups).sort()) {
136 out += `**${component}/**\n`;
137 for (const t of targets.slice(0, 15)) {
138 out += `- \`${t}\`\n`;
139 }
140 if (targets.length > 15) {
141 out += `- ... and ${targets.length - 15} more\n`;
142 }
143 out += '\n';
144 }
145 }
146 
147 // Group external deps by repo
148 if (external.length > 0) {
149 const byRepo = groupByRepo(external);
150 out += `### Direct dependencies — external (${external.length} targets across ${Object.keys(byRepo).length} repos)\n\n`;
151 for (const [repo, targets] of Object.entries(byRepo).sort()) {
152 if (targets.length <= 3) {
153 out += `- **${repo}**: ${targets.map((t) => `\`${t}\``).join(', ')}\n`;
154 } else {
155 out += `- **${repo}**: ${targets.length} targets\n`;
156 }
157 }
158 out += '\n';
159 }
160 
161 // Reverse deps (what depends on this target)
162 const revTargets = revResult
163 .filter((t) => t !== label && t.startsWith('//src/'))
164 .sort();
165 
166 if (revTargets.length > 0) {
167 const groups = groupByComponent(revTargets);
168 out += `### Reverse dependencies (${revTargets.length} targets depend on this)\n\n`;
169 for (const [component, targets] of Object.entries(groups).sort()) {
170 out += `**${component}/**\n`;
171 for (const t of targets.slice(0, 15)) {
172 out += `- \`${t}\`\n`;
173 }
174 if (targets.length > 15) {
175 out += `- ... and ${targets.length - 15} more\n`;
176 }
177 out += '\n';
178 }
179 } else {
180 out += `### Reverse dependencies\n\nNo targets within \`//src/...\` directly depend on this target.\n\n`;
181 }
182 
183 return out;
184}
185 
186// =============================================================================
187// Reverse dependencies (rdeps): what depends on this target?
188// =============================================================================
189 
190async function queryReverseDeps(
191 dep: string,
192 depth: number,
193 root: string,
194 ecosystem: Ecosystem = null
195): Promise<string> {
196 // Resolve the dependency to Bazel label(s)
197 const resolved = await resolveDep(dep, root, ecosystem);
198 if (resolved.error) {
199 return resolved.error;
200 }
201 
202 let out = `## Reverse dependencies: ${dep}\n\n`;
203 out += `**Resolved labels:** ${resolved.labels.map((l) => `\`${l}\``).join(', ')}\n`;
204 if (resolved.note) {
205 out += `**Note:** ${resolved.note}\n`;
206 }
207 out += '\n';
208 
209 // Run rdeps queries for all labels in parallel
210 const allResults = await Promise.all(
211 resolved.labels.map((label) =>
212 bazelQuery(`rdeps(//src/..., ${label}, ${depth})`, root)
213 )
214 );
215 
216 // Merge and deduplicate
217 const allTargets = new Set<string>();
218 for (const result of allResults) {
219 for (const target of result) {
220 allTargets.add(target);
221 }
222 }
223 
224 const srcTargets = [...allTargets]
225 .filter((t) => t.startsWith('//src/'))
226 .sort();
227 
228 if (srcTargets.length === 0) {
229 out += `No direct dependents found within \`//src/...\`.\n\n`;
230 out +=
231 'This dependency may only be consumed transitively through another dependency, ';
232 out += 'or the label may not be correct. Check with:\n';
233 out += '```\n';
234 for (const label of resolved.labels) {
235 const repo = label.split('//')[0];
236 out += `bazel query '${repo}//...' --output label 2>/dev/null | head -10\n`;
237 }
238 out += '```\n';
239 return out;
240 }
241 
242 const groups = groupByComponent(srcTargets);
243 
244 out += `### Direct dependents (${srcTargets.length} targets)\n\n`;
245 for (const [component, targets] of Object.entries(groups).sort()) {
246 out += `**${component}/**\n`;
247 for (const t of targets.slice(0, 15)) {
248 out += `- \`${t}\`\n`;
249 }
250 if (targets.length > 15) {
251 out += `- ... and ${targets.length - 15} more\n`;
252 }
253 out += '\n';
254 }
255 
256 // Summary
257 out += `### Summary\n\n`;
258 const componentCounts = Object.entries(groups)
259 .map(([c, ts]) => `${c} (${ts.length})`)
260 .join(', ');
261 out += `**${srcTargets.length} targets** across ${Object.keys(groups).length} components: ${componentCounts}\n`;
262 
263 if (Object.keys(groups).length === 1) {
264 out += `\n**Narrow usage** — only consumed by the \`${Object.keys(groups)[0]}/\` component.\n`;
265 } else if (Object.keys(groups).length <= 3) {
266 out += `\n**Moderate usage** — consumed by ${Object.keys(groups).length} components.\n`;
267 } else {
268 out += `\n**Broad usage** — consumed across ${Object.keys(groups).length} components.\n`;
269 }
270 
271 return out;
272}
273 
274// =============================================================================
275// Label resolution
276// =============================================================================
277 
278interface ResolveResult {
279 labels: string[];
280 note?: string;
281 error?: string;
282}
283 
284async function resolveDep(
285 dep: string,
286 root: string,
287 ecosystem: Ecosystem = null
288): Promise<ResolveResult> {
289 // Already a full label
290 if (dep.startsWith('//') || dep.startsWith('@')) {
291 return { labels: [dep] };
292 }
293 
294 // Check aliases (only for C++ or unqualified lookups)
295 const canonical =
296 ecosystem !== 'rust' ? (ALIASES[dep.toLowerCase()] ?? dep) : dep;
297 
298 // Rust-only lookup: skip C++ resolution entirely
299 if (ecosystem === 'rust') {
300 const crateLabels = await discoverRustCrate(dep, root);
301 if (crateLabels.length > 0) {
302 return { labels: crateLabels, note: 'Resolved as Rust crate' };
303 }
304 return {
305 labels: [],
306 error:
307 `Could not resolve Rust crate "${dep}".\n\n` +
308 `Tried: \`@crates_vendor//:${dep}\`\n\n` +
309 `Check available crates with: \`ls deps/rust/crates/BUILD.${dep}-*.bazel\``,
310 };
311 }
312 
313 // C++-only lookup: skip Rust resolution entirely
314 if (ecosystem === 'cpp') {
315 const extLabels = await discoverExternalTargets(canonical, root);
316 if (extLabels.length > 0) {
317 const note =
318 canonical !== dep
319 ? `Resolved alias "${dep}" → "${canonical}"`
320 : undefined;
321 return { labels: extLabels, note };
322 }
323 const fallbackLabels = await discoverExternalTargets(`@${canonical}`, root);
324 if (fallbackLabels.length > 0) {
325 return { labels: fallbackLabels };
326 }
327 return {
328 labels: [],
329 error:
330 `Could not resolve C++ dependency "${dep}".\n\n` +
331 `Tried: \`@${canonical}\`\n\n` +
332 `You can provide a full Bazel label instead (e.g., \`@ada-url//:ada\`).`,
333 };
334 }
335 
336 // Unqualified: try C++ first, then Rust (existing behavior)
337 const extLabels = await discoverExternalTargets(canonical, root);
338 if (extLabels.length > 0) {
339 const note =
340 canonical !== dep
341 ? `Resolved alias "${dep}" → "${canonical}"`
342 : undefined;
343 return { labels: extLabels, note };
344 }
345 
346 // Try as a Rust crate via crates_vendor
347 const crateLabels = await discoverRustCrate(dep, root);
348 if (crateLabels.length > 0) {
349 return { labels: crateLabels, note: 'Resolved as Rust crate' };
350 }
351 
352 // Try with @ prefix as a last resort
353 const fallbackLabels = await discoverExternalTargets(`@${canonical}`, root);
354 if (fallbackLabels.length > 0) {
355 return { labels: fallbackLabels };
356 }
357 
358 return {
359 labels: [],
360 error:
361 `Could not resolve "${dep}" to a Bazel dependency label.\n\n` +
362 `Tried:\n` +
363 `- External repo: \`@${canonical}\`\n` +
364 `- Rust crate: \`@crates_vendor//:${dep}\`\n\n` +
365 `You can provide a full Bazel label instead, or use a qualifier ` +
366 `(e.g., \`rust:${dep}\` or \`cpp:${dep}\`) to narrow the search.`,
367 };
368}
369 
370// Resolve a file path or short name to an internal //src/... label
371// For ecosystem-qualified targets (rust:foo, cpp:foo), resolve via resolveDep instead.
372async function resolveToInternalLabel(
373 target: string,
374 root: string,
375 ecosystem: Ecosystem = null
376): Promise<string | null> {
377 // Already a Bazel label
378 if (target.startsWith('//') || target.startsWith('@')) {
379 return target;
380 }
381 
382 // Ecosystem-qualified: resolve as a dependency, not an internal target
383 if (ecosystem !== null) {
384 const resolved = await resolveDep(target, root, ecosystem);
385 if (resolved.labels.length > 0) {
386 return resolved.labels[0];
387 }
388 return null;
389 }
390 
391 // File path — find its Bazel target
392 const filePath = target.startsWith('src/') ? target : `src/${target}`;
393 const dir = path.dirname(filePath);
394 
395 try {
396 // Use bazel query to find the target that owns this file
397 const result = await bazelQuery(`kind("rule", //${dir}/...)`, root);
398 if (result.length > 0) {
399 // Try to find a target matching the file's base name
400 const baseName = path.basename(filePath).replace(/\.[^.]+$/, '');
401 const exactMatch = result.find((t) => t.endsWith(`:${baseName}`));
402 return exactMatch ?? result[0];
403 }
404 } catch {
405 // fall through
406 }
407 
408 // Last resort: try as a label directly
409 return `//${dir}:${path.basename(dir)}`;
410}
411 
412// =============================================================================
413// External dependency discovery
414// =============================================================================
415 
416async function discoverExternalTargets(
417 repoName: string,
418 root: string
419): Promise<string[]> {
420 const repo = repoName.startsWith('@') ? repoName : `@${repoName}`;
421 try {
422 const targets = await bazelQuery(`${repo}//...`, root);
423 if (targets.length === 0) return [];
424 
425 // Prefer short top-level targets
426 const topLevel = targets.filter((t) => {
427 const label = t.split('//')[1] || '';
428 const parts = label.split(':');
429 return parts.length === 2 && !parts[1].includes('/');
430 });
431 
432 const candidates = topLevel.length > 0 ? topLevel : targets;
433 return candidates.slice(0, 5);
434 } catch {
435 return [];
436 }
437}
438 
439async function discoverRustCrate(
440 crateName: string,
441 root: string
442): Promise<string[]> {
443 try {
444 const targets = await bazelQuery('@crates_vendor//...', root);
445 const matching = targets.filter((l) => {
446 const lower = l.toLowerCase();
447 return (
448 lower.includes(`:${crateName.toLowerCase()}`) ||
449 lower.includes(`/${crateName.toLowerCase()}`)
450 );
451 });
452 if (matching.length > 0) {
453 return matching.slice(0, 3);
454 }
455 } catch {
456 // crates_vendor may not exist
457 }
458 
459 try {
460 const targets = await bazelQuery(
461 `filter("crates_vendor__${crateName}", //external:all-targets)`,
462 root
463 );
464 if (targets.length > 0) {
465 return targets.slice(0, 3);
466 }
467 } catch {
468 // not found
469 }
470 
471 return [];
472}
473 
474// =============================================================================
475// Bazel query helper
476// =============================================================================
477 
478async function bazelQuery(query: string, root: string): Promise<string[]> {
479 try {
480 const output =
481 await Bun.$`bazel query '${query}' --output label 2>/dev/null`
482 .cwd(root)
483 .text();
484 return output
485 .split('\n')
486 .map((l) => l.trim())
487 .filter(Boolean);
488 } catch {
489 return [];
490 }
491}
492 
493// =============================================================================
494// Grouping helpers
495// =============================================================================
496 
497function groupByComponent(targets: string[]): Record<string, string[]> {
498 const groups: Record<string, string[]> = {};
499 for (const target of targets) {
500 let component = 'other';
501 const m = target.match(/^\/\/src\/(.+?):/);
502 if (m) {
503 let p = m[1];
504 if (p.startsWith('workerd/')) p = p.slice('workerd/'.length);
505 component = p;
506 }
507 if (!groups[component]) groups[component] = [];
508 groups[component].push(target);
509 }
510 return groups;
511}
512 
513function groupByRepo(targets: string[]): Record<string, string[]> {
514 const groups: Record<string, string[]> = {};
515 for (const target of targets) {
516 let repo: string;
517 if (target.startsWith('@@')) {
518 // @@+crate_repositories+crates_vendor__foo-1.0 → crates_vendor__foo-1.0
519 const m = target.match(/@@[^/]*\+([^/]+)/);
520 repo = m ? m[1] : target.split('//')[0];
521 } else {
522 // @foo//bar:baz → @foo
523 repo = target.split('//')[0];
524 }
525 if (!groups[repo]) groups[repo] = [];
526 groups[repo].push(target);
527 }
528 return groups;
529}