Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/source-lints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ on:
paths:
- "src/**/*.rs"
- "src/jsc/bindings/**"
- "packages/bun-types/bun.d.ts"
- "docs/**/*.mdx"
- "scripts/build/**"
- "scripts/glob-sources.ts"
- "test/harness.ts"
Expand All @@ -26,6 +28,8 @@ on:
paths:
- "src/**/*.rs"
- "src/jsc/bindings/**"
- "packages/bun-types/bun.d.ts"
- "docs/**/*.mdx"
- "scripts/build/**"
- "scripts/glob-sources.ts"
- "test/harness.ts"
Expand Down
24 changes: 12 additions & 12 deletions docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1608,7 +1608,7 @@ class BuildMessage {
name: string;
position?: Position;
message: string;
level: "error" | "warning" | "info" | "debug" | "verbose";
level: "error" | "warn" | "note" | "debug" | "verbose";
}

class ResolveMessage extends BuildMessage {
Expand Down Expand Up @@ -1814,17 +1814,17 @@ declare class ResolveMessage {
readonly referrer: string;
readonly specifier: string;
readonly importKind:
| "entry_point"
| "stmt"
| "require"
| "import"
| "dynamic"
| "require_resolve"
| "at"
| "at_conditional"
| "url"
| "internal";
readonly level: "error" | "warning" | "info" | "debug" | "verbose";
| "import-statement"
| "require-call"
| "require-resolve"
| "dynamic-import"
| "import-rule"
| "url-token"
| "composes"
| "internal"
| "entry-point-run"
| "entry-point-build";
readonly level: "error" | "warn" | "note" | "debug" | "verbose";

toString(): string;
}
Expand Down
22 changes: 22 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2755,21 +2755,43 @@ declare module "bun" {
}

type ImportKind =
/** An `import` or `export ... from` statement */
| "import-statement"
/** A `require()` call */
| "require-call"
/** A `require.resolve()` call */
| "require-resolve"
/** An `import()` expression */
| "dynamic-import"
/** A CSS `@import` rule */
| "import-rule"
/** A CSS `url()` token */
| "url-token"
/** A CSS modules `composes: ... from "./other.module.css"` declaration */
| "composes"
/** An import injected by Bun itself */
| "internal"
/** An entry point passed to `bun run` or `bun <file>` */
| "entry-point-run"
/** An entry point passed to `bun build` or `Bun.build()` */
| "entry-point-build";

interface Import {
path: string;
kind: ImportKind;
}

/**
* Severity of a {@link BuildMessage} or {@link ResolveMessage}.
*
* - `"error"`: the build (or module load) failed because of this message
* - `"warn"`: reported, but the build went on
* - `"note"`: additional context for another message
* - `"debug"` and `"verbose"`: only reported when the log level is raised
* above the default, for example with {@link TranspilerOptions.logLevel}
*/
type BuildMessageLevel = "error" | "warn" | "note" | "debug" | "verbose";

namespace Build {
type Architecture = "x64" | "arm64" | "aarch64";
type Libc = "glibc" | "musl";
Expand Down
16 changes: 3 additions & 13 deletions packages/bun-types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,18 +996,8 @@ declare class ResolveMessage {
readonly message: string;
readonly referrer: string;
readonly specifier: string;
readonly importKind:
| "entry_point"
| "stmt"
| "require"
| "import"
| "dynamic"
| "require_resolve"
| "at"
| "at_conditional"
| "url"
| "internal";
readonly level: "error" | "warning" | "info" | "debug" | "verbose";
readonly importKind: Bun.ImportKind;
readonly level: Bun.BuildMessageLevel;

toString(): string;
}
Expand All @@ -1016,7 +1006,7 @@ declare class BuildMessage {
readonly name: "BuildMessage";
readonly position: Position | null;
readonly message: string;
readonly level: "error" | "warning" | "info" | "debug" | "verbose";
readonly level: Bun.BuildMessageLevel;
}

interface ErrorOptions {
Expand Down
1 change: 1 addition & 0 deletions src/ast/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ impl Kind {
}
}

/// Public API (`BuildMessage.level`); mirrored by bun-types `BuildMessageLevel`.
#[inline]
pub fn string(self) -> &'static [u8] {
match self {
Expand Down
23 changes: 1 addition & 22 deletions src/jsc/ResolveMessage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,6 @@ impl Default for ResolveMessage {
}
}

/// `ImportKind.label()` — the canonical table lives in
/// `bun_ast::ImportKind::label`, but
/// `bun_ast::MetadataResolve.import_kind` is the type-only `bun_ast::ImportKind`.
/// Replicate the table here verbatim.
fn import_kind_label(kind: ImportKind) -> &'static [u8] {
match kind {
ImportKind::EntryPointRun => b"entry-point-run",
ImportKind::EntryPointBuild => b"entry-point-build",
ImportKind::Stmt => b"import-statement",
ImportKind::Require => b"require-call",
ImportKind::Dynamic => b"dynamic-import",
ImportKind::RequireResolve => b"require-resolve",
ImportKind::At => b"import-rule",
ImportKind::AtConditional => b"",
ImportKind::Url => b"url-token",
ImportKind::Composes => b"composes",
ImportKind::Internal => b"internal",
ImportKind::HtmlManifest => b"html_manifest",
}
}

/// Host-agnostic bare-specifier check for Node ESM error shaping. Must not vary by host:
/// relative, separator-led, and ASCII-letter drive forms are path-like; everything else is a
/// package. Unlike `bun_paths::is_absolute`, the drive byte must be alphabetic.
Expand Down Expand Up @@ -479,7 +458,7 @@ impl ResolveMessage {
pub fn get_import_kind(this: &Self, global: &JSGlobalObject) -> JsResult<JSValue> {
Ok(match &this.msg.metadata {
bun_ast::Metadata::Resolve(resolve) => {
ZigString::init(import_kind_label(resolve.import_kind)).to_js(global)
ZigString::init(resolve.import_kind.label()).to_js(global)
}
_ => ZigString::init(b"").to_js(global),
})
Expand Down
16 changes: 16 additions & 0 deletions test/integration/bun-types/fixture/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ Bun.build({
expectType(result.success).is<boolean>();
expectType(result.outputs).is<Bun.BuildArtifact[]>();
expectType(result.logs).is<Array<BuildMessage | ResolveMessage>>();

for (const log of result.logs) {
// The unions themselves are checked against src/ast/lib.rs by
// test/internal/source-lints/import-kind-and-level-names.test.ts.
expectType(log.level).is<Bun.BuildMessageLevel>();
if (log.level === "warn") {
expectType(log.level).is<"warn">();
}

if (log instanceof ResolveMessage) {
expectType(log.importKind).is<Bun.ImportKind>();
if (log.importKind === "require-call") {
expectType(log.importKind).is<"require-call">();
}
}
}
});

build.onBeforeParse(
Expand Down
5 changes: 5 additions & 0 deletions test/internal/source-lints/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ The workflow runs on a bare checkout (no `bun install`), so tests here may
only import built-ins, relative paths, and `harness` (resolved via
`test/tsconfig.json` paths).

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.

To run locally: `bun test test/internal/source-lints/`.
143 changes: 143 additions & 0 deletions test/internal/source-lints/import-kind-and-level-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Glob } from "bun";
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import path from "node:path";

// `BuildMessage.level` / `ResolveMessage.level` are `bun_ast::Kind::string()`
// and `ResolveMessage.importKind` (also the metafile's and scanImports()' `kind`)
// is `bun_ast::ImportKind::label()`, both in src/ast/lib.rs. The bun-types
// `BuildMessageLevel` and `ImportKind` unions and the `class BuildMessage` /
// `class ResolveMessage` snippets in the docs are hand-written copies of those
// strings, and they had drifted from the runtime for years (`"warning"`,
// `"stmt"`, no `"composes"`). This lint fails when any copy differs from the
// Rust tables.
//
// A variant that is deliberately kept out of the union goes into `notPublic`
// below with the reason; every other label has to be in every copy. Deleting
// such a variant from the enum fails the first test until it is removed here too.
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..");
const read = (rel: string) => readFileSync(path.join(repoRoot, rel), "utf8");

const libRs = read("src/ast/lib.rs");

const notPublic = [
// `@import` rules with conditions have an empty label, so a ResolveMessage for
// one currently reports `importKind: ""`. A runtime gap, not a documented value.
"AtConditional",
// Reported today only as the metafile `kind` of the first server-side importer
// of an HTML file, and #38621 removes that rewrite while keeping the variant,
// so a documented "html_manifest" would outlive its last use without this
// lint noticing. If #38621 is dropped instead, delete this entry.
"HtmlManifest",
];

/** The variants of `pub enum <name> { A = 0, B = 1, … }` in src/ast/lib.rs. */
function enumVariants(name: string): string[] {
const body = libRs.match(new RegExp(`^pub enum ${name} \\{([^}]*)\\}`, "m"))?.[1];
expect(body).toBeDefined();
return [...body!.matchAll(/^\s*([A-Z][A-Za-z0-9]*)(?:\s*=\s*\d+)?,/gm)].map(([, variant]) => variant);
}

/**
* `variant -> string` for each arm of `pub fn <fn>(self) -> &'static [u8] { match self { … } }`
* in src/ast/lib.rs. Empty when the function was not found; the first test
* below compares the arms with the enum's variants, which catches that.
*/
function matchArms(enumName: string, fn: string): Map<string, string> {
const body =
libRs.match(new RegExp(`pub fn ${fn}\\(self\\) -> &'static \\[u8\\] \\{\\s*match self \\{([^}]*)\\}`))?.[1] ?? "";
const arms = new Map<string, string>();
for (const [, variant, label] of body.matchAll(new RegExp(`^\\s*${enumName}::([A-Za-z0-9]+) => b"([^"]*)",`, "gm"))) {
arms.set(variant, label);
}
return arms;
}

const levelArms = matchArms("Kind", "string");
const labelArms = matchArms("ImportKind", "label");

/** What every copy of the level union has to list, sorted. */
const levels = [...levelArms.values()].sort();

/** What every copy of the import kind union has to list, sorted. */
const importKinds = [...labelArms]
.filter(([variant]) => !notPublic.includes(variant))
.map(([, label]) => label)
.sort();

/**
* The members of the string-literal union that follows each match of `prefix`
* in `source`, sorted, one entry per match. Comments between the members are
* ignored. A match followed by something other than a string-literal union
* (for example `importKind: ImportKind;`) yields `null`.
*/
function unionsAfter(source: string, prefix: RegExp): (string[] | null)[] {
return [...source.matchAll(prefix)].map(match => {
const body = source
.slice(match.index! + match[0].length)
.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "")
.match(/^([^;]*);/)?.[1];
expect(body).toBeDefined();
const members = [...body!.matchAll(/"([^"]*)"/g)].map(([, name]) => name);
return members.length > 0 && body!.replace(/"[^"]*"|\||\s/g, "") === "" ? members.sort() : null;
});
}

const typeAlias = (name: string) => new RegExp(`^\\s*type ${name}\\s*=`, "gm");
const property = (name: string) => new RegExp(`^\\s*(?:readonly\\s+)?${name}:`, "gm");

test("src/ast/lib.rs: Kind::string() and ImportKind::label() were parsed for every variant", () => {
expect([...levelArms.keys()].sort()).toEqual(enumVariants("Kind").sort());
expect([...labelArms.keys()].sort()).toEqual(enumVariants("ImportKind").sort());
expect(notPublic.filter(variant => !labelArms.has(variant))).toEqual([]);
expect(levels.length).toBeGreaterThanOrEqual(5);
expect(importKinds.length).toBeGreaterThanOrEqual(10);
expect(new Set(levels).size).toBe(levels.length);
expect(new Set(importKinds).size).toBe(importKinds.length);
});

test("src/ast/lib.rs: a variant without a label is listed in `notPublic`", () => {
const unlabeled = [...labelArms].filter(([, label]) => label === "").map(([variant]) => variant);
expect(unlabeled.filter(variant => !notPublic.includes(variant))).toEqual([]);
expect(importKinds).not.toContain("");
});

test("packages/bun-types/bun.d.ts: BuildMessageLevel lists Kind::string()", () => {
expect(unionsAfter(read("packages/bun-types/bun.d.ts"), typeAlias("BuildMessageLevel"))).toEqual([levels]);
});

test("packages/bun-types/bun.d.ts: ImportKind lists ImportKind::label()", () => {
expect(unionsAfter(read("packages/bun-types/bun.d.ts"), typeAlias("ImportKind"))).toEqual([importKinds]);
});

test("docs: every BuildMessage / ResolveMessage snippet lists the same strings", () => {
// docs/runtime/transpiler.mdx lists the kinds scanImports() returns, which is
// deliberately a subset of ImportKind (no CSS or HTML kinds); it declares
// neither class, so it is not picked up here.
const snippets: [file: string, body: string][] = [];
for (const rel of [...new Glob("**/*.mdx").scanSync({ cwd: path.join(repoRoot, "docs") })].sort()) {
const file = `docs/${rel.replaceAll("\\", "/")}`;
for (const [, body] of read(file).matchAll(
/^(?:declare )?class (?:BuildMessage|ResolveMessage)\b[^{]*\{([^}]*)\}/gm,
)) {
snippets.push([file, body]);
}
}
// Currently the two snippets under "Logs and errors" and the reference block
// in docs/bundler/index.mdx. An empty scan means the glob or pattern broke.
expect(snippets.length).toBeGreaterThan(0);

const levelCopies = snippets.flatMap(([file, body]) =>
unionsAfter(body, property("level")).map(copy => [file, copy]),
);
expect(levelCopies.length).toBeGreaterThan(0);
expect(levelCopies).toEqual(levelCopies.map(([file]) => [file, levels]));

// `importKind: ImportKind;` (a reference rather than a copy) is fine; a
// spelled-out union has to match.
const importKindCopies = snippets
.flatMap(([file, body]) => unionsAfter(body, property("importKind")).map(copy => [file, copy]))
.filter(([, copy]) => copy !== null);
expect(importKindCopies.length).toBeGreaterThan(0);
expect(importKindCopies).toEqual(importKindCopies.map(([file]) => [file, importKinds]));
});
Loading