From 4dcef48c647b9ec982cbc17ecdad91464df321a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:42:19 +0000 Subject: [PATCH 1/2] bun-types: type BuildMessage/ResolveMessage level and importKind as the strings the runtime reports level is bun_ast::Kind::string(), so the runtime reports "warn" and "note", never the "warning" / "info" the declarations listed. ResolveMessage.importKind has been ImportKind::label() ("import-statement", "require-call", ...) since the getter stopped using the enum tag names, but the declaration still listed the tag names. Both declarations now reuse one type each: Bun.ImportKind, which gains the "composes" label it was missing, and a new Bun.BuildMessageLevel. The docs copies are updated to match. ResolveMessage's getter used a verbatim copy of the label table; it now calls ImportKind::label() directly. A source lint parses both tables out of src/ast/lib.rs and fails when the bun-types unions or the docs snippets differ from them (AtConditional and HtmlManifest are the listed exclusions), and the source-lints workflow now also triggers on bun.d.ts and the docs so that lint runs for declaration-only changes. --- .github/workflows/source-lints.yml | 4 + docs/bundler/index.mdx | 24 +-- packages/bun-types/bun.d.ts | 22 +++ packages/bun-types/globals.d.ts | 16 +- src/ast/lib.rs | 1 + src/jsc/ResolveMessage.rs | 23 +-- test/integration/bun-types/fixture/build.ts | 16 ++ test/internal/source-lints/README.md | 5 + .../import-kind-and-level-names.test.ts | 143 ++++++++++++++++++ 9 files changed, 207 insertions(+), 47 deletions(-) create mode 100644 test/internal/source-lints/import-kind-and-level-names.test.ts diff --git a/.github/workflows/source-lints.yml b/.github/workflows/source-lints.yml index be44e6e90a55..003411d27d38 100644 --- a/.github/workflows/source-lints.yml +++ b/.github/workflows/source-lints.yml @@ -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" @@ -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" diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index d59d6e77e973..54927deafd1e 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -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 { @@ -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; } diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 175f4b6f4b4e..c04b0e807de7 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2755,14 +2755,25 @@ 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 ` */ | "entry-point-run" + /** An entry point passed to `bun build` or `Bun.build()` */ | "entry-point-build"; interface Import { @@ -2770,6 +2781,17 @@ declare module "bun" { 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"; diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index d576bcfcb400..ba374c09d2c8 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -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; } @@ -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 { diff --git a/src/ast/lib.rs b/src/ast/lib.rs index a7e8f91cb162..268255c73b5f 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -549,6 +549,7 @@ impl Kind { } } + /// Public API (`BuildMessage.level`); mirrored by bun-types `BuildMessageLevel`. #[inline] pub fn string(self) -> &'static [u8] { match self { diff --git a/src/jsc/ResolveMessage.rs b/src/jsc/ResolveMessage.rs index 594da9ebbbcd..a258e8ebf864 100644 --- a/src/jsc/ResolveMessage.rs +++ b/src/jsc/ResolveMessage.rs @@ -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. @@ -479,7 +458,7 @@ impl ResolveMessage { pub fn get_import_kind(this: &Self, global: &JSGlobalObject) -> JsResult { 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), }) diff --git a/test/integration/bun-types/fixture/build.ts b/test/integration/bun-types/fixture/build.ts index c87e2459cabb..703d231180f4 100644 --- a/test/integration/bun-types/fixture/build.ts +++ b/test/integration/bun-types/fixture/build.ts @@ -49,6 +49,22 @@ Bun.build({ expectType(result.success).is(); expectType(result.outputs).is(); expectType(result.logs).is>(); + + 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(); + if (log.level === "warn") { + expectType(log.level).is<"warn">(); + } + + if (log instanceof ResolveMessage) { + expectType(log.importKind).is(); + if (log.importKind === "require-call") { + expectType(log.importKind).is<"require-call">(); + } + } + } }); build.onBeforeParse( diff --git a/test/internal/source-lints/README.md b/test/internal/source-lints/README.md index 587425cc87cc..aa99b1d3b502 100644 --- a/test/internal/source-lints/README.md +++ b/test/internal/source-lints/README.md @@ -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/`. diff --git a/test/internal/source-lints/import-kind-and-level-names.test.ts b/test/internal/source-lints/import-kind-and-level-names.test.ts new file mode 100644 index 000000000000..a89523d32cf3 --- /dev/null +++ b/test/internal/source-lints/import-kind-and-level-names.test.ts @@ -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 { 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 (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 { + const body = + libRs.match(new RegExp(`pub fn ${fn}\\(self\\) -> &'static \\[u8\\] \\{\\s*match self \\{([^}]*)\\}`))?.[1] ?? ""; + const arms = new Map(); + 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])); +}); From e1ea82a0a9b284d19eae62b45de365eb9126c8e7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:08:06 +0000 Subject: [PATCH 2/2] ci: retrigger