Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ bun_collections.workspace = true
bun_crash_handler.workspace = true
bun_csrf.workspace = true
bun_cares_sys.workspace = true
bun_css.workspace = true
bun_dns.workspace = true
bun_dotenv.workspace = true
bun_event_loop.workspace = true
Expand Down
159 changes: 159 additions & 0 deletions test/internal/source-lints/cargo-dep-edges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { file } from "bun";
import { expect, test } from "bun:test";
import path from "path";

// Every `bun_*::` path a crate's code names must have a matching dependency
// edge in that crate's Cargo.toml. cargo enforces this at compile time, but
// only for the crates a given change rebuilds — a dead-code sweep that prunes
// a "textually unreferenced" edge can race a PR that adds new uses of it, and
// both land green (#37301 removed bun_runtime's `bun_css` edge after being
// verified against a tree that predated #39229's `bun_css::StyleSheet` calls
// in pm_diff_normalize.rs; merged main then failed to build everywhere).
//
// This lint re-checks the pairing textually on every tree, so whichever PR
// merges second goes red on its own CI instead of breaking main.

Check warning on line 14 in test/internal/source-lints/cargo-dep-edges.test.ts

View check run for this annotation

Claude / Claude Code Review

source-lints workflow paths filter does not include src/**/Cargo.toml

This lint reads `src/*/Cargo.toml`, but `.github/workflows/source-lints.yml`'s `paths:` filters (both `push` and `pull_request`) don't include `src/**/Cargo.toml` — so a dead-edge sweep PR that only edits a Cargo.toml (exactly the #37301 shape this lint targets) won't trigger the workflow at PR-check time. Per `test/internal/source-lints/README.md`, lints that read files outside the listed paths need them added; add `"src/**/Cargo.toml"` to both `paths:` lists. The bare `merge_group:` trigger
Comment on lines +5 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This lint reads src/*/Cargo.toml, but .github/workflows/source-lints.yml's paths: filters (both push and pull_request) don't include src/**/Cargo.toml — so a dead-edge sweep PR that only edits a Cargo.toml (exactly the #37301 shape this lint targets) won't trigger the workflow at PR-check time. Per test/internal/source-lints/README.md, lints that read files outside the listed paths need them added; add "src/**/Cargo.toml" to both paths: lists. The bare merge_group: trigger backstops main, so this only costs PR-time feedback.

Extended reasoning...

What the bug is

test/internal/source-lints/README.md:19-22 documents an explicit contract for adding lints to this directory:

The workflow only triggers for the paths: listed in it. A lint that reads files outside those paths (for example packages/bun-types/bun.d.ts or the docs) needs them added there, or a change to those files is only checked by whichever later PR happens to trigger the workflow.

The new cargo-dep-edges.test.ts reads src/*/Cargo.toml (line 33: new Bun.Glob("src/*/Cargo.toml").scanSync), but .github/workflows/source-lints.yml lines 14-33 (push) and 35-54 (pull_request) list src/**/*.rs, src/**/*.classes.ts, src/jsc/bindings/**, etc. — no Cargo.toml pattern. This PR's changed-files list also doesn't include source-lints.yml, so the pattern isn't being added.

Why it matters for this specific lint

The lint's own header comment (lines 5-14) states its purpose is to catch a race between two PR classes: one that adds a bun_*:: reference and one that prunes the corresponding dependency edge from a Cargo.toml. The PR description says it should catch "whichever PR merges second."

Consider the two orderings:

  1. The .rs-editing PR merges second — it touches src/**/*.rs, which is in the paths filter → workflow runs on that PR ✓
  2. The Cargo.toml-editing PR merges second — this is exactly what Remove dead code from bun_ast, bun_runtime, and unused Cargo dependency edges #37301 was: a PR whose only functional change was removing bun_css.workspace = true from src/runtime/Cargo.toml. Such a PR touches only src/*/Cargo.toml + root Cargo.lock, neither of which matches any pattern in the pull_request paths filter → the source-lints workflow does not run on that PR's own CI, and the author gets no PR-time signal from this lint.

So one of the two race directions the lint is designed to catch doesn't get PR-level feedback.

Why this is a nit, not blocking

Two backstops mean main won't actually break:

  • merge_group: (line 55) has no paths: filter, so every PR entering the merge queue runs source-lints against the merged tree regardless of what files it touches. The Cargo.toml-only PR would fail there.
  • rust-lints.yml does list src/**/Cargo.toml in its own paths and runs cargo clippy at merge-queue time, which would catch the missing edge as a compile error on the merged tree independently.

So the only consequence of merging as-is is that the failure surfaces at merge-queue time instead of at PR-check time for Cargo.toml-only PRs. That's a degraded feedback loop, not a concrete failure — hence nit rather than blocking.

Step-by-step proof

  1. Author opens a PR that only removes bun_foo.workspace = true from src/bar/Cargo.toml (a dead-edge sweep, verified against a stale tree).
  2. GitHub evaluates the pull_request paths filter in source-lints.yml:35-54: src/bar/Cargo.toml matches none of src/**/*.rs, src/**/*.classes.ts, src/codegen/class-definitions.ts, src/js/builtins.d.ts, src/jsc/bindings/**, or any other listed pattern. Root Cargo.lock matches nothing either.
  3. The source-lints check does not appear on the PR. Per README line 6, these tests are also excluded from the Buildkite shards, so no other lane runs cargo-dep-edges.test.ts against this PR either.
  4. The author sees green PR checks and adds the PR to the merge queue.
  5. Only then does the merge_group: trigger run source-lints against the merged tree and fail — later than intended.

Fix

Add "src/**/Cargo.toml" to both the push: paths list (after line 15) and the pull_request: paths list (after line 36) in .github/workflows/source-lints.yml.

//
// Matching is deliberately conservative:
// - only crate names that exist as workspace members count;
// - only `bun_x::` path uses count (`::bun_x::` absolute paths, the
// macro-definition idiom that resolves at the expansion site, do not);
// - comments and string literals are stripped before matching.

const root = path.resolve(import.meta.dir, "..", "..", "..");

// Workspace member crates under src/*/ (the only layout the workspace uses;
// src/install/windows-shim is a standalone nested crate, excluded below).
interface Crate {
name: string;
dir: string; // repo-relative, posix separators
deps: Set<string>;
}

const crates: Crate[] = [];
for (const manifest of new Bun.Glob("src/*/Cargo.toml").scanSync({ cwd: root })) {
const dir = path.dirname(manifest).replaceAll(path.sep, "/");
const toml = await file(path.join(root, manifest)).text();
const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(toml)?.[1];
if (!name) continue;

// Keys of every [*dependencies*] section: [dependencies],
// [dev-dependencies], [build-dependencies], [target.'...'.dependencies].
// `alias = { package = "real_name", ... }` declares `real_name`, but the
// in-code path uses the alias, so record both.
const deps = new Set<string>();
let inDeps = false;
for (const line of toml.split("\n")) {
const section = /^\s*\[(.+)\]\s*$/.exec(line);
if (section) {
inDeps = /dependencies/.test(section[1]);
// `[dependencies.foo]` declares `foo` in the header itself.
const headerDep = /(?:^|\.)dependencies\.([A-Za-z0-9_-]+)$/.exec(section[1]);
if (headerDep) deps.add(headerDep[1].replaceAll("-", "_"));
continue;
}
if (!inDeps) continue;
// `name = ...`, `name.workspace = true`, `name.version = "..."`.
const key = /^\s*([A-Za-z0-9_-]+)\s*[.=]/.exec(line);
if (!key) continue;
deps.add(key[1].replaceAll("-", "_"));
const renamed = /\bpackage\s*=\s*"([^"]+)"/.exec(line);
if (renamed) deps.add(renamed[1].replaceAll("-", "_"));
}
crates.push({ name, dir, deps });
}

const workspaceNames = new Set(crates.map(c => c.name));

// Tracked .rs files only (a `git stash` round-trip can leave stray files).
const trackedRs: string[] = (() => {
const r = Bun.spawnSync({
cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"],
stdout: "pipe",
stderr: "ignore",
});
if (!r.success) throw new Error("git ls-tree failed");
return r.stdout
.toString()
.split("\0")
.filter(p => p.startsWith("src/") && p.endsWith(".rs"));
})();

// Nested standalone crates: their files belong to themselves, not to the
// src/*/ crate that contains them.
const nestedCrateDirs = ["src/install/windows-shim/"];

// Files mounted into a different crate's module tree via `#[path]`; their
// references resolve against the mounting crate's dependencies.
const mountedElsewhere: Record<string, string> = {
"src/jsc/generated_classes_list.rs": "bun_runtime", // src/runtime/lib.rs `#[path] pub mod generated_classes_list`
};

Check warning on line 89 in test/internal/source-lints/cargo-dep-edges.test.ts

View check run for this annotation

Claude / Claude Code Review

mountedElsewhere is missing src/bun.js.rs

`mountedElsewhere` covers `src/jsc/generated_classes_list.rs` but not `src/bun.js.rs`, which is `#[path]`-mounted six lines above it in `src/runtime/lib.rs:49`. That file lives directly under `src/`, so `crateFor("src/bun.js.rs")` returns `undefined` and its five `bun_*::` references (bun_core, bun_jsc, bun_standalone_graph, bun_bundler, bun_options_types) are silently skipped. Add `"src/bun.js.rs": "bun_runtime"` to close the gap.
Comment on lines +87 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 mountedElsewhere covers src/jsc/generated_classes_list.rs but not src/bun.js.rs, which is #[path]-mounted six lines above it in src/runtime/lib.rs:49. That file lives directly under src/, so crateFor("src/bun.js.rs") returns undefined and its five bun_*:: references (bun_core, bun_jsc, bun_standalone_graph, bun_bundler, bun_options_types) are silently skipped. Add "src/bun.js.rs": "bun_runtime" to close the gap.

Extended reasoning...

What the gap is

src/runtime/lib.rs mounts two out-of-tree files into bun_runtime via #[path]:

#[path = "../bun.js.rs"]
pub mod run_main;                                       // line 49
...
#[path = "../jsc/generated_classes_list.rs"]
pub mod generated_classes_list;                         // line 55

mountedElsewhere maps only the second one. The first — src/bun.js.rs — is the sole tracked .rs file directly under src/ (verified via git ls-tree) and it uses bun_core::, bun_jsc::, bun_standalone_graph::, bun_bundler::, and bun_options_types::.

Why it falls through crateFor

Walk crateFor("src/bun.js.rs"):

  1. nestedCrateDirs.some(d => rel.startsWith(d)) → false (only src/install/windows-shim/).
  2. mountedElsewhere["src/bun.js.rs"] → undefined.
  3. crates.find(c => rel.startsWith(c.dir + "/")) → every crate dir is src/<name>, so the test is "src/bun.js.rs".startsWith("src/<name>/"). The file has no directory component after src/, so no crate matches. Returns undefined.

The main scan then hits if (!crate) continue; and never puts the file into stripped or byCrate. Its five workspace-crate references are never checked against src/runtime/Cargo.toml.

Why the existing entry doesn't generalize

generated_classes_list.rs needed a mountedElsewhere entry because without it the file would be mis-attributed to bun_jsc (it lives under src/jsc/), producing false positives. bun.js.rs without an entry produces the mirror failure — a silent skip (false negative). Both are #[path] mounts declared in the same block of lib.rs; the PR description explicitly says the lint "understands ... files mounted into another crate with #[path]", so per the review rule "Fix the whole class in the same PR ... grep for every sibling site sharing the pattern" the sibling six lines away should be listed too.

Impact

Nothing breaks today — all five referenced crates are currently in src/runtime/Cargo.toml. But the whole point of this lint is to catch a future dead-edge sweep that drops one of them while src/bun.js.rs still uses it (the exact bun_css failure mode this PR is fixing). As written, that scenario for src/bun.js.rs would sail through the lint.

Fix

One-line addition:

const mountedElsewhere: Record<string, string> = {
  "src/bun.js.rs": "bun_runtime", // src/runtime/lib.rs `#[path] pub mod run_main`
  "src/jsc/generated_classes_list.rs": "bun_runtime",
};


function crateFor(rel: string): Crate | undefined {
if (nestedCrateDirs.some(d => rel.startsWith(d))) return undefined;
const mounted = mountedElsewhere[rel];
if (mounted) return crates.find(c => c.name === mounted);
return crates.find(c => rel.startsWith(c.dir + "/"));
}

/** Remove comments and string literals so prose mentions don't count. */
function stripNonCode(src: string): string {
return src.replace(
// raw strings, byte strings, plain strings, char literals, line and block
// comments. The char-literal arm matches exactly one unit (`'x'`, `'\n'`,
// `'\u{1F600}'`) so a lifetime's lone `'` can never open a match that
// swallows code.
/r#*"[\s\S]*?"#*|b?"(?:[^"\\]|\\[\s\S])*"|b?'(?:\\u\{[^}']*\}|\\[^\n]|[^'\\\n])'|\/\/[^\n]*|\/\*[\s\S]*?\*\//g,
" ",
);
}

// `bun_x::` as a path head. The lookbehind rejects `::bun_x::` (absolute
// paths inside macro definitions resolve where the macro expands) and
// identifier tails like `other_bun_x::`.
const PATH_USE = /(?<![\w:])(bun_[a-z0-9_]+)(?=::)/g;

// A `bun_*` name can be bound locally instead of naming the crate:
// extern crate bun_core as bun_output; (crate-wide rename)
// use bun_core::output as bun_output; (module alias)
// pub mod bun_spawn { ... } (local module shadows the crate)
// Any such binding anywhere in a crate takes that name out of this lint for
// the whole crate; rustc resolves those for real.

Check failure on line 120 in test/internal/source-lints/cargo-dep-edges.test.ts

View check run for this annotation

Claude / Claude Code Review

LOCAL_BINDING regex falsely shadows crates via cast/UFCS expressions

The `as\s+(bun_[a-z0-9_]+)\b` arm matches cast/UFCS expressions like `s.st_mode as bun_sys::Mode` and `<HTMLBundle as bun_jsc::JsClass>` because `\b` holds between a word char and `:`, so those crate names land in `shadowed` and are skipped entirely at line 151. In `bun_runtime` alone this exempts `bun_sys`, `bun_jsc`, `bun_core`, `bun_bundler`, `bun_ptr`, `bun_uws`, `bun_watcher`, and `bun_options_types` from the check — add `(?!\s*::)` after the capture, mirroring the `mod` arm.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The as\s+(bun_[a-z0-9_]+)\b arm matches cast/UFCS expressions like s.st_mode as bun_sys::Mode and <HTMLBundle as bun_jsc::JsClass> because \b holds between a word char and :, so those crate names land in shadowed and are skipped entirely at line 151. In bun_runtime alone this exempts bun_sys, bun_jsc, bun_core, bun_bundler, bun_ptr, bun_uws, bun_watcher, and bun_options_types from the check — add (?!\s*::) after the capture, mirroring the mod arm.

Extended reasoning...

What the bug is

LOCAL_BINDING has two arms: mod\s+(bun_…)\b(?!\s*::) and as\s+(bun_…)\b. The as arm is meant to recognise extern crate x as bun_y and use x as bun_y renames so the lint doesn't flag a name that a crate has bound locally. But a \b assertion is satisfied between a word character and :, so the arm also matches Rust cast expressions and UFCS trait paths — expr as bun_sys::Mode, <T as bun_jsc::JsClass>::from_js(…). Each match adds the captured crate name to the whole-crate shadowed set, and the check loop then skips that name unconditionally: if (shadowed.has(used)) continue;.

Step-by-step proof

Running the actual regex against real source lines:

const re = /\b(?:mod\s+(bun_[a-z0-9_]+)\b(?!\s*::)|as\s+(bun_[a-z0-9_]+)\b)/g;
[..."s.st_mode as bun_sys::Mode".matchAll(re)]               // → m[2] === 'bun_sys'
[..."<HTMLBundle as bun_jsc::JsClass>::from_js(x)".matchAll(re)] // → m[2] === 'bun_jsc'

Both capture the crate name into m[2], which lands in entry.shadowed at line 137. Then in the violation loop, line 151 (if (shadowed.has(used)) continue;) skips every PATH_USE hit for that name across the entire crate — not just the file the cast appeared in.

grep -rhoP '\bas\s+bun_[a-z0-9_]+::' src/runtime/ --include='*.rs' | sort -u shows the crates spuriously shadowed in bun_runtime: bun_bundler, bun_core, bun_jsc, bun_options_types, bun_ptr, bun_sys, bun_uws, bun_watcher. Live examples: src/runtime/server/DirectoryRoute.rs (s.st_mode as bun_sys::Mode), src/runtime/server/server_body.rs (<HTMLBundle as bun_jsc::JsClass>), src/runtime/server/StaticRoute.rs (<Self as bun_ptr::CellRefCounted>::deref), src/runtime/jsc_hooks.rs (<RuntimeTranspilerCache as bun_bundler::RuntimeTranspilerCacheExt>). The same pattern hits other crates too — bun_crash_handler shadows bun_zlib, bun_libarchive shadows bun_sys, bun_install shadows bun_spawn, etc.

Why existing code doesn't prevent it

The mod arm already guards against exactly this shape with (?!\s*::) — so mod bun_x::y (impossible in Rust anyway) wouldn't be captured. The as arm asymmetrically lacks the same guard. Nothing else in the pipeline distinguishes a rename-binding from a cast: the match goes straight into shadowed, and shadowed is consulted before violations.push. Notably, x as bun_sys::Mode is simultaneously a PATH_USE hit (requiring the dep) and a LOCAL_BINDING hit (suppressing the check) on the same token — the very expression that needs the edge is the one that hides its absence.

Impact

If any of the eight edges above were dropped from src/runtime/Cargo.toml (exactly the failure class this lint exists to catch — a dead-code sweep racing a new use), the lint would stay green. That is a silent false negative on the most heavily-used workspace crates. The manifest fix in this PR happens to be for bun_css, which is not in the shadowed set, so the PR's own verification ("the lint fails on main without the manifest change") passed — but the lint would not have caught the same race for bun_sys, bun_jsc, or bun_core.

Fix

Add the same negative lookahead the mod arm uses:

const LOCAL_BINDING = /\b(?:mod\s+(bun_[a-z0-9_]+)\b(?!\s*::)|as\s+(bun_[a-z0-9_]+)\b(?!\s*::))/g;

This still matches use x as bun_y; and extern crate x as bun_y; (a ; or whitespace follows the identifier), and rejects … as bun_y::Item. Alternatively, anchor the arm to (?:use|extern\s+crate)\b[^;]*\bas\s+…, but the lookahead is the smaller change and symmetric with the existing arm.

const LOCAL_BINDING = /\b(?:mod\s+(bun_[a-z0-9_]+)\b(?!\s*::)|as\s+(bun_[a-z0-9_]+)\b)/g;

// The scan runs at module scope (like the other lints here): per-test
// timeouts then cover only the assertion, not the repo walk, which is slow
// under a debug + ASAN build.
const byCrate = new Map<Crate, { files: string[]; shadowed: Set<string> }>();
const stripped = new Map<string, string>();

for (const rel of trackedRs) {
const crate = crateFor(rel);
if (!crate) continue;
const code = stripNonCode(await file(path.join(root, rel)).text());
stripped.set(rel, code);
let entry = byCrate.get(crate);
if (!entry) byCrate.set(crate, (entry = { files: [], shadowed: new Set() }));
entry.files.push(rel);
for (const m of code.matchAll(LOCAL_BINDING)) entry.shadowed.add(m[1] ?? m[2]);
}

const violations: string[] = [];
for (const [crate, { files, shadowed }] of byCrate) {
for (const rel of files) {
const seen = new Set<string>();
for (const m of stripped.get(rel)!.matchAll(PATH_USE)) {
const used = m[1];
if (seen.has(used)) continue;
seen.add(used);
if (used === crate.name) continue;
if (!workspaceNames.has(used)) continue;
if (crate.deps.has(used)) continue;
if (shadowed.has(used)) continue;
violations.push(`${rel}: uses \`${used}::\` but ${crate.dir}/Cargo.toml has no \`${used}\` dependency`);
}
}
}

test("every bun_*:: reference has a Cargo.toml dependency edge", () => {
expect(violations).toEqual([]);
});
Loading