From c08dca0087d30aeab7b4b0363f8cc7c61bad1b5c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:58:40 +0000 Subject: [PATCH 1/4] lint: read the directory of a tsconfig.json and of --ignore-pattern as a path With an .oxlintrc.json, a tsconfig.json without `include` applies to the files below its directory, and an `include` or `exclude` entry that names a file applies to that file. oxlint compares paths in both cases. `bun lint` compiled the directory and the entry as one pattern, so a directory named `[x]` or a file named `[id].ts` was a character class there. With settings["import/resolver"].typescript, the directory of a tsconfig.json is a path in its `include` and `exclude` patterns, as in eslint-import-resolver-typescript. For the files that go to the installed ESLint, the patterns of `--ignore-pattern` and of bunfig.toml get their directory as `basePath` if that ESLint is 9.30 or later, which is what ESLint passes itself. An older ESLint gets the directory in front of each pattern, as before. --- src/lint/driver/for_tests.rs | 10 + src/lint/glob.rs | 27 + src/lint/graph/lib.rs | 132 +++-- src/lint/js_plugin/eslint.rs | 2 +- src/lint/js_plugin/worker/eslint.js | 24 +- src/lint/paths.rs | 38 ++ test/cli/lint/glob.test.ts | 463 +++++++++++++++++- .../lint/oracle/plugins/oxlint/expected.json | 18 +- .../lint/oracle/plugins/oxlint/projects.ts | 54 ++ 9 files changed, 715 insertions(+), 53 deletions(-) diff --git a/src/lint/driver/for_tests.rs b/src/lint/driver/for_tests.rs index 79cad6b4bc46..ed280cb7ca84 100644 --- a/src/lint/driver/for_tests.rs +++ b/src/lint/driver/for_tests.rs @@ -288,6 +288,16 @@ pub fn run_path_tests(args: &[&[u8]]) -> Option> { b"namespaced" => paths::namespaced(a), b"normalize" => paths::normalize(a), b"join" => paths::join_normalized(a, b), + // `split_joined` with each join that a directory and an option or a `tsconfig.json` entry go through. + b"split_resolved" | b"split_joined" | b"split_included" => { + let (joined, separators) = match *function { + b"split_resolved" => (paths::resolve_as(style, a, b), style.separators()), + b"split_joined" => (paths::join_normalized(a, b), style.separators()), + _ => (bun_sema::resolve::join(a, b), &b"/\\"[..]), + }; + let (directory, below) = paths::split_joined(a, &[b], separators, &joined); + [directory, b" + ", below].concat() + } _ => return None, }); out.push(b'\n'); diff --git a/src/lint/glob.rs b/src/lint/glob.rs index 5de78e119c89..10e967dce793 100644 --- a/src/lint/glob.rs +++ b/src/lint/glob.rs @@ -235,6 +235,33 @@ impl Minimatch { } } +/// A pattern for the paths below a directory, with the directory kept out of the pattern. Joined into one pattern, a +/// directory named `[app]` or `{a,b}` is a class or a group, and matches no path in it. `directory` is compared byte +/// by byte, and `below` matches the rest of the path. +pub struct Under { + directory: Box<[u8]>, + below: M, +} + +impl Under { + /// `directory` and the text of `below` are the two parts of [`paths::split_joined`]. + pub fn new(directory: &[u8], below: M) -> Under { + Under { + directory: directory.into(), + below, + } + } + + /// The pattern and the part of `path` that it is for, if `path` starts with the directory. + pub fn below<'p>(&self, path: &'p [u8]) -> Option<(&M, &'p [u8])> { + // Without a directory, `strip_prefix` is a call that compares no bytes. + match self.directory.is_empty() { + true => Some((&self.below, path)), + false => Some((&self.below, path.strip_prefix(&self.directory[..])?)), + } + } +} + /// Converts `1..3` from inside braces the way picomatch does. It builds the regex class `[1-3]` from the sorted parts. /// If that class is invalid, the text matches literally, without the braces. /// Returns `None` unless the parts are letters, digits and `-`. diff --git a/src/lint/graph/lib.rs b/src/lint/graph/lib.rs index 2511a465f332..b4219bd3f2c1 100644 --- a/src/lint/graph/lib.rs +++ b/src/lint/graph/lib.rs @@ -23,12 +23,13 @@ use bun_core::strings; use bun_glob::{BunGlobWalker, Pattern}; use bun_lint::ast::File; +use bun_lint::glob::Under; use bun_lint::language::{LanguageOptions, Parser}; use bun_lint::modules::{ Declaration, Flavor, Import, ListFiles, Listed, Lookup, MakeRecord, ModuleId, Modules, Reader, Record, Request, RequestKind, ResolveBy, Resolved, requests_of, }; -use bun_lint::paths::{is_absolute, relative}; +use bun_lint::paths::{is_absolute, relative, split_joined}; use bun_sema::atom::Interner; use bun_sema::bind::{BindOptions, Recycled, bind_for_lint_in}; use bun_sema::config::{ @@ -146,7 +147,8 @@ struct Caches<'h> { resolvers: ShardedMap, ProjectResolver<'h>>, /// Maps a directory to the `tsconfig.json` path for its files. With [`Store::tsconfig`], the key is a file. configs: ShardedMap, Vec>, - /// Caches [`Graph::scopes_in`] and [`Graph::scopes_of`]. + /// Caches [`Graph::scopes_in`] and [`Graph::scopes_of`], and [`Graph::scopes_of_typescript_resolver`] with a NUL + /// before the key. scopes: ShardedMap, Vec>, /// Caches [`Graph::configs_of_typescript_resolver`], keyed by the directory and the projects, and /// [`Graph::listed_projects`], keyed by the projects. @@ -226,6 +228,34 @@ fn with_file( /// Calls the function with every index below the count, on any number of threads. pub type Parallel<'p> = &'p dyn Fn(usize, &(dyn Fn(usize) + Sync)); +/// How the directory of a `tsconfig.json` is read in an `include` or `exclude` entry that is joined to it. +#[derive(Copy, Clone)] +enum Directory { + /// As a part of the pattern, like oxc-resolver does. A directory named `[x]` or `{y}` is a class or a group, so + /// oxlint applies no `include` pattern in it. + Pattern, + /// As a path, whatever its name is. For eslint-import-resolver-typescript a `[` or a `{` is no syntax anywhere in + /// an entry. Here the rest of the entry is still a pattern as oxc-resolver reads it. + Path, +} + +/// An `include` or `exclude` entry of a `tsconfig.json`. +struct Entry { + pattern: Under, + /// The entry's path, if the pattern doesn't match it. oxc-resolver compares the two before it matches. + path: Option>, + /// The pattern ends in a `*`, in which case it only matches JavaScript and TypeScript files. + is_for_scripts: bool, +} + +impl Entry { + fn has(&self, path: &[u8]) -> bool { + let below = self.pattern.below(path); + below.is_some_and(|(pattern, rest)| pattern.matches(rest)) + || self.path.as_deref() == Some(path) + } +} + /// The set of files a `tsconfig.json` applies to, as decided by oxc-resolver, which oxlint uses. TypeScript decides /// this differently. struct Scope { @@ -234,10 +264,8 @@ struct Scope { /// `baseUrl`, or else the config's directory. base: Vec, files: Vec>, - /// Absolute patterns. The flag is true if the pattern ends in a `*`, in which case it only matches JavaScript and - /// TypeScript files. - include: Vec<(Pattern, bool)>, - exclude: Vec, + include: Vec, + exclude: Vec, allows_js: bool, } @@ -251,32 +279,49 @@ impl Scope { allows_js: false, }; - fn new(project: &Project) -> Scope { + fn new(project: &Project, reads: Directory) -> Scope { let directory = directory_of(&project.config_path); let absolute = |relative_path: &[u8]| match relative_path.strip_prefix(b"${configDir}") { Some(rest) => join(directory, rest.strip_prefix(b"/").unwrap_or(rest)), None => join(directory, relative_path), }; - let pattern = |config_glob: &Vec| { - let mut path = absolute(config_glob); + let entry = |config_glob: &[u8], reads: Directory| { + let path = absolute(config_glob); + let written = config_glob + .strip_prefix(b"${configDir}") + .unwrap_or(config_glob); + let (directory, below) = match reads { + Directory::Pattern => (&b""[..], &path[..]), + // `join` takes both for separators on every system. + Directory::Path => split_joined(directory, &[written], b"/\\", &path), + }; + // Only these make a pattern that doesn't match its own text. + let is_own_match = !strings::contains_any(below, b"[{\\"); // A last segment without a `.` or a wildcard is treated as a directory. let last = &path[directory_of(&path).len()..]; - if !strings::contains_any(last, b".*?") { - path.extend_from_slice(b"/**/*"); + let below = match strings::contains_any(last, b".*?") { + true => Cow::Borrowed(below), + false => Cow::Owned([below, b"/**/*"].concat()), + }; + Entry { + pattern: Under::new( + directory, + Pattern::new(&bun_glob::pattern::of_fast_glob(&below)), + ), + is_for_scripts: below.ends_with(b"*"), + path: (!is_own_match).then(|| path[..].into()), } - ( - Pattern::new(&bun_glob::pattern::of_fast_glob(&path)), - path.ends_with(b"*"), - ) }; - let patterns = - |config_globs: &[Vec]| config_globs.iter().map(pattern).collect::>(); + let entries = |config_globs: &[Vec]| { + let entries = config_globs.iter().map(|it| entry(it, reads)); + entries.collect::>() + }; let raw_config = &project.written; - let everything = [b"**/*".to_vec()]; let include = match (&raw_config.include, &raw_config.files) { - (Some(include), _) => &include[..], - (None, Some(_)) => &[], - (None, None) => &everything[..], + (Some(include), _) => entries(include), + (None, Some(_)) => Vec::new(), + // Everything in the directory, which oxc-resolver compares as a path too. + (None, None) => vec![entry(b"**/*", Directory::Path)], }; let allow_js = (project.raw_compiler_options.iter()).find(|it| it.0 == b"allowJs"); let base_url = (project.raw_compiler_options.iter()).find(|it| it.0 == b"baseUrl"); @@ -288,10 +333,8 @@ impl Scope { files: (raw_config.files.iter().flatten()) .map(|it| absolute(it)) .collect(), - include: patterns(include), - exclude: (patterns(raw_config.exclude.as_deref().unwrap_or_default()).into_iter()) - .map(|it| it.0) - .collect(), + include, + exclude: entries(raw_config.exclude.as_deref().unwrap_or_default()), allows_js: allow_js.is_some_and(|it| it.1 == Json::Bool(true)), } } @@ -307,8 +350,8 @@ impl Scope { return false; } let is_script = is_js || matches!(extension, b"ts" | b"tsx" | b"mts" | b"cts"); - let mut include = self.include.iter().filter(|it| is_script || !it.1); - include.any(|it| it.0.matches(path)) && !self.exclude.iter().any(|it| it.matches(path)) + let mut include = (self.include.iter()).filter(|it| is_script || !it.is_for_scripts); + include.any(|it| it.has(path)) && !self.exclude.iter().any(|it| it.has(path)) } } @@ -409,7 +452,10 @@ impl<'h> Graph<'h> { fn scopes_in(&self, directory: &[u8]) -> &[Scope] { match self.caches().scopes.get_ref(directory) { Some(scopes) => scopes, - None => self.load_scopes(directory, &join(directory, b"tsconfig.json")), + None => { + let config = join(directory, b"tsconfig.json"); + self.load_scopes(directory, &config, Directory::Pattern) + } } } @@ -417,11 +463,21 @@ impl<'h> Graph<'h> { fn scopes_of(&self, config: &[u8]) -> &[Scope] { match self.caches().scopes.get_ref(config) { Some(scopes) => scopes, - None => self.load_scopes(config, config), + None => self.load_scopes(config, config, Directory::Pattern), + } + } + + /// The same for eslint-import-resolver-typescript, which applies `include` and `exclude` in a directory of any + /// name. + fn scopes_of_typescript_resolver(&self, config: &[u8]) -> &[Scope] { + let key = [b"\0", config].concat(); + match self.caches().scopes.get_ref(&key[..]) { + Some(scopes) => scopes, + None => self.load_scopes(&key, config, Directory::Path), } } - fn load_scopes(&self, key: &[u8], config: &[u8]) -> &[Scope] { + fn load_scopes(&self, key: &[u8], config: &[u8], reads: Directory) -> &[Scope] { let caches = self.caches(); let _loading = self.loading.lock(); if let Some(scopes) = caches.scopes.get_ref(key) { @@ -430,7 +486,7 @@ impl<'h> Graph<'h> { let disk = WithoutListings(self.store.disk()); let load = |config: &[u8]| { let project = load_overriding(&disk, &self.store.session, config, &|_| Vec::new()); - project.ok().map(|it| Scope::new(&it)) + project.ok().map(|it| Scope::new(&it, reads)) }; let project = (disk.is_file(config)) .then(|| load_overriding(&disk, &self.store.session, config, &|_| Vec::new()).ok()); @@ -438,7 +494,7 @@ impl<'h> Graph<'h> { let referenced = project.references.iter(); let referenced = referenced.map(|it| load(&resolve_config_file_name_of_project_reference(&it.path))); - let own = Some(Scope::new(&project)); + let own = Some(Scope::new(&project, reads)); let all: Option> = referenced.chain([own]).collect(); all.unwrap_or_else(|| vec![Scope::BROKEN]) }); @@ -513,8 +569,8 @@ impl<'h> Graph<'h> { }; bun_lint::utils::sort::sort_by_key(&mut listed, distance); listed.dedup(); - let applies_to_file = - |it: &Vec| self.scopes_of(it).last().is_some_and(|own| own.has(from)); + let scopes_of = |config: &Vec| self.scopes_of_typescript_resolver(config); + let applies_to_file = |it: &Vec| scopes_of(it).last().is_some_and(|own| own.has(from)); let chosen = match listed.len() { 0 if self.store.uses_nearest_tsconfig => { let mut above = bun_lint::paths::ancestors(directory); @@ -525,6 +581,9 @@ impl<'h> Graph<'h> { } 0 => self.config_in(&self.store.cwd).into_iter().collect(), 1 => listed, + // The closest project that includes the file, alone. The resolver does that once the project has resolved + // an import. Before, it also tries the next projects that include the file, so what it reports depends on + // the order of the files and of their imports. _ => match listed.iter().position(applies_to_file) { Some(at) => vec![listed.swap_remove(at)], None => listed, @@ -533,10 +592,7 @@ impl<'h> Graph<'h> { let mut configs = Vec::new(); for config in chosen { // `references: "auto"` - let referenced = self - .scopes_of(&config) - .split_last() - .map_or(&[][..], |it| it.1); + let referenced = scopes_of(&config).split_last().map_or(&[][..], |it| it.1); let contains_directory = |it: &&Scope| { let rest = directory.strip_prefix(&it.base[..]); !it.config.is_empty() && rest.is_some_and(|it| matches!(it, [] | [b'/', ..])) diff --git a/src/lint/js_plugin/eslint.rs b/src/lint/js_plugin/eslint.rs index 538f3e5a5319..5787cde2422f 100644 --- a/src/lint/js_plugin/eslint.rs +++ b/src/lint/js_plugin/eslint.rs @@ -135,7 +135,7 @@ impl Host<'_> { /// Calls ESLint's `Linter.verify`. `run` is JSON with what all files of a config file share: `from`, `basePath`, /// `allowInlineConfig`, `onlyErrors`, `aboutLayout`, and for [`Configuration::whole`] `file`, `ignores` and `added`. - /// `reviveOverride` in `worker/eslint.js` turns each object in `added` into one that every ESLint 9 and 10 accepts. + /// `reviveOverride` in `worker/eslint.js` turns each object in `added` into one that the installed ESLint accepts. pub fn lint_with_eslint( &self, configuration: &Configuration, diff --git a/src/lint/js_plugin/worker/eslint.js b/src/lint/js_plugin/worker/eslint.js index ecd6c72c13c8..6eb097bf11f1 100644 --- a/src/lint/js_plugin/worker/eslint.js +++ b/src/lint/js_plugin/worker/eslint.js @@ -11,8 +11,9 @@ const CONFIGURE_ESLINT = 21; const NEEDS_ESLINT_CONFIGURATION = "4"; const NOT_INSTALLED = "5"; -// By the directory that it is looked for from: `{ Linter, FlatConfigArray }`, or `null` if it is not installed. `FlatConfigArray`, -// which the package does not export, is `null` if it is not where it has been since ESLint 8.21. +// By the directory that it is looked for from: `{ Linter, FlatConfigArray, knowsBasePath }`, or `null` if it is not installed. +// `FlatConfigArray`, which the package does not export, is `null` if it is not where it has been since ESLint 8.21. +// `knowsBasePath`: a config object can say what its patterns are relative to, since ESLint 9.30. An older one rejects the key. const eslintPackages = new Map(); function eslintFrom(directory) { @@ -26,7 +27,9 @@ function eslintFrom(directory) { } catch {} found = null; if (root !== null) { - found = { Linter: from("eslint").Linter, FlatConfigArray: null }; + found = { Linter: from("eslint").Linter, FlatConfigArray: null, knowsBasePath: false }; + const [major, minor] = String(found.Linter.version).split(".").map(Number); + found.knowsBasePath = major > 9 || (major === 9 && minor >= 30); try { found.FlatConfigArray = require(nodePath.join(root, "lib", "config", "flat-config-array.js")).FlatConfigArray ?? null; @@ -112,17 +115,18 @@ function movedPattern(pattern, from, to) { } // Prepares a config object that the command line or bunfig.toml adds. Its plugins and parser arrive as names. Its `basePath` says -// what its patterns are relative to, if that isn't `arrayBase`, the base path of the config array. ESLint only knows `basePath` -// in config objects since 9.30 and rejects unknown keys, so the key is never passed on. -async function reviveOverride(object, arrayBase) { +// what its patterns are relative to, if that isn't `arrayBase`, the base path of the config array. ESLint passes the directory +// of `--ignore-pattern` that way since 9.30. An older one rejects the key, and wrote the directory in front of each pattern. +async function reviveOverride(object, arrayBase, knowsBasePath) { const resolve = createRequire(nodePath.join(cwd, "noop.js")).resolve; const importFrom = async name => load(pathToFileURL(resolve(name)).href); const { $globsOfBun: isOfBunfig, $jsPlugins: located = {}, basePath = arrayBase, ...revived } = object; if (isOfBunfig) { revived.files = [bunfigMatcher(object.files, basePath)]; if (object.ignores !== undefined) revived.ignores = [bunfigMatcher(object.ignores, basePath)]; - } else if (object.ignores !== undefined) { - revived.ignores = object.ignores.map(it => movedPattern(it, basePath, arrayBase)); + } else if (object.ignores !== undefined && nodePath.relative(arrayBase, basePath) !== "") { + if (knowsBasePath) revived.basePath = basePath; + else revived.ignores = object.ignores.map(it => movedPattern(it, basePath, arrayBase)); } if (object.plugins !== undefined) { revived.plugins = {}; @@ -157,7 +161,7 @@ async function reviveOverride(object, arrayBase) { // ESLint's `calculateConfigArray`: all of the configuration file, for a file whose configuration has what only JavaScript can say. // `file`: `null` under `--no-config-lookup`. -async function wholeConfiguration({ FlatConfigArray }, { file, basePath, ignores, added }) { +async function wholeConfiguration({ FlatConfigArray, knowsBasePath }, { file, basePath, ignores, added }) { // Of an array that is only an array the `Linter` makes one for each file. const configs = FlatConfigArray === null ? [] : new FlatConfigArray([], { basePath, shouldIgnore: ignores }); if (file !== null) { @@ -168,7 +172,7 @@ async function wholeConfiguration({ FlatConfigArray }, { file, basePath, ignores } // The `Linter` turns a plain array into a config array whose base path is its `cwd`. const arrayBase = FlatConfigArray === null ? cwd : basePath; - for (const object of added) configs.push(await reviveOverride(object, arrayBase)); + for (const object of added) configs.push(await reviveOverride(object, arrayBase, knowsBasePath)); await configs.normalize?.(); if (configs.getConfig === undefined) return configs; return Object.assign([], { getConfig: name => withoutProcessor(configs.getConfig(name)) }); diff --git a/src/lint/paths.rs b/src/lint/paths.rs index f07df89e6bf5..a786fb40407f 100644 --- a/src/lint/paths.rs +++ b/src/lint/paths.rs @@ -30,6 +30,14 @@ impl Style { _ => Style::Posix, } } + + /// What separates the names of a path in this style. + pub fn separators(self) -> &'static [u8] { + match self { + Style::Posix => b"/", + Style::Windows => b"/\\", + } + } } /// Converts a native path or a command-line argument. Replaces `\` with `/` on platforms where `\` is a separator. @@ -217,6 +225,36 @@ pub fn inside_as<'p>(style: Style, directory: &[u8], path: &'p [u8]) -> Option<& } } +/// Splits `joined`, which is `texts` joined to `directory` one after the other, into what is left of the directory and +/// what the texts say. The first part is a path, whatever bytes it has. A `..` that leads out of the texts takes a +/// name off it. The second part is a pattern if a text is one. Unless the first part is empty, the second is empty or +/// starts with a `/`, so it matches what it matched as a part of `joined`. `separators` are the bytes that the join +/// took for separators in the texts. +pub fn split_joined<'j>( + directory: &[u8], + texts: &[&[u8]], + separators: &[u8], + joined: &'j [u8], +) -> (&'j [u8], &'j [u8]) { + let names = texts + .iter() + .flat_map(|text| strings::split_any(text, separators)); + let (mut depth, mut left) = (0usize, directory); + for name in names { + match name { + b"" | b"." => {} + b".." if depth == 0 => left = dirname(left), + b".." => depth -= 1, + _ => depth += 1, + } + } + // A root has no name, and only a root ends with a `/`. + match joined.strip_prefix(left) { + Some([] | [b'/', ..]) if !left.ends_with(b"/") => joined.split_at(left.len()), + _ => (b"", joined), + } +} + /// True if `path` is `outer` or inside it. Both must be resolved. pub fn is_at_or_inside(outer: &[u8], path: &[u8]) -> bool { let is_same = match Style::of(outer) { diff --git a/test/cli/lint/glob.test.ts b/test/cli/lint/glob.test.ts index d39e7d5e7c63..c325a1798d53 100644 --- a/test/cli/lint/glob.test.ts +++ b/test/cli/lint/glob.test.ts @@ -1,6 +1,7 @@ -import { afterAll, expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; -import { join, relative } from "node:path"; +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDir } from "harness"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative } from "node:path"; import { endChildren, spawn } from "../children"; afterAll(endChildren); @@ -543,3 +544,459 @@ test.concurrent.each(["{a,", "a{,", "*{a,", "[*", "{*", "a\\"])( expect(exitCode).toBe(0); }, ); + +// Where `bun lint` joins a directory in front of a pattern, the directory is a path, whatever bytes its name has: +// `[proj]` is no class, `{a,b}` no group and `+(old)` no extglob. + +// A debug build takes seconds to lint a project, and more with an ESLint 8 config, with a config file that is a +// program, or with the installed ESLint. +const debugTimeout = isDebug || isASAN ? 60_000 : undefined; + +type Files = Record; + +/** + * Lints `files` from the directory `cwd`. Returns each report as `file:line rule`, with the file relative to the + * directory `root`. `files` can be a function of the directory that they are written to. + */ +async function reportedFrom( + cwd: string, + files: Files | ((directory: string) => Files), + args: string[] = [], + root = ".", +) { + using dir = tempDir("bun-lint-glob", typeof files === "function" ? {} : files); + if (typeof files === "function") { + for (const [path, text] of Object.entries(files(String(dir)))) { + mkdirSync(dirname(join(String(dir), path)), { recursive: true }); + writeFileSync(join(String(dir), path), text); + } + } + await using proc = spawn({ + cmd: [bunExe(), "lint", "-f", "unix", ...args], + env, + cwd: join(String(dir), cwd), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0 && exitCode !== 1) throw new Error(`exit code ${exitCode}\n${stdout}\n${stderr}`); + // With an oxlint config the paths are relative to the working directory and a rule is `import(no-cycle)`. + return [...stdout.matchAll(/^(.+?):(\d+):\d+: .* \[\w+\/(.+)\]\r?$/gm)] + .map(([, path, line, rule]) => { + const absolute = isAbsolute(path) ? path : join(String(dir), cwd, path); + const file = relative(join(String(dir), root), absolute).replaceAll("\\", "/"); + return `${file}:${line} ${rule.replace(/^(\w+)\((.+)\)$/, "$1/$2")}`; + }) + .sort(); +} + +/** The same for a project that is in a directory named `name`, from its subdirectory `cwd`. */ +function reportedIn(name: string, files: Files, args: string[] = [], cwd = ".") { + const inside = Object.fromEntries(Object.entries(files).map(([path, text]) => [`${name}/${path}`, text])); + return reportedFrom(join(name, cwd), inside, args, name); +} + +// `bun lint --run-path-tests` is compiled into debug and canary builds only. +const hasRunner = isDebug || Bun.spawnSync({ cmd: [bunExe(), "--revision"], env: bunEnv }).stdout.includes("canary"); + +test.skipIf(!hasRunner)("what is joined to a directory is split where the directory ends in it", async () => { + // The directory, what is joined to it, and the two parts of the result: what is left of the directory, which is no + // pattern, and the rest. + const rows = [ + ["/t/[p]", "src/**", "/t/[p]", "/src/**"], + ["/t/[p]", "./src/../lib/*.js", "/t/[p]", "/lib/*.js"], + ["/t/[p]", ".", "/t/[p]", ""], + // A `..` leaves the directory. + ["/t/[p]/app", "../lib/*.js", "/t/[p]", "/lib/*.js"], + ["/t/[p]/app", "src/../../lib/*.js", "/t/[p]", "/lib/*.js"], + ["/t/[p]", "../../../x/*.js", "", "/x/*.js"], + // What comes back into the directory by its name is written, so it is a pattern. + ["/t/[p]/app", "../../[p]/app/*.js", "/t", "/[p]/app/*.js"], + // A root has no name. + ["/", "src/*.js", "", "/src/*.js"], + ["/t", "../src/*.js", "", "/src/*.js"], + ]; + // `path.resolve`, `path.join`, and the join of a tsconfig.json entry. Only the last takes a `\` for a separator in a + // POSIX path, and the checker writes a drive as `/C:`. + const joins = { + split_resolved: [...rows, ["/t/[p]", "..\\lib/*.js", "/t/[p]", "/..\\lib/*.js"]], + split_joined: [...rows, ["/t/[p]", "/t/[p]/src/**", "/t/[p]", "/t/[p]/src/**"]], + split_included: [ + ...rows, + ["/t/[p]/app", "..\\lib/*.js", "/t/[p]", "/lib/*.js"], + ["/C:/r/[p]/app", "..\\lib/*.js", "/C:/r/[p]", "/lib/*.js"], + // An absolute entry is a pattern, unless it goes through the directory. + ["/t/[p]/app", "/t/[p]/lib/*.js", "", "/t/[p]/lib/*.js"], + ["/t/[p]", "/t/[p]/src/**", "/t/[p]", "/src/**"], + ], + }; + const windows = { + split_resolved: [ + ["C:/r/[p]", "src\\**", "C:/r/[p]", "/src/**"], + ["C:/r/[p]/app", "..\\lib/*.js", "C:/r/[p]", "/lib/*.js"], + ["C:/r", "../src/*.js", "", "C:/src/*.js"], + ["//server/share/[p]", "src/**", "//server/share/[p]", "/src/**"], + ["//server/share/[p]", "../x/**", "", "//server/share/x/**"], + ], + }; + // Not in a directory whose `package.json` has a `lint` script. + using dir = tempDir("bun-lint-glob", {}); + const run = async (style: string, table: Record) => { + const calls = Object.entries(table).flatMap(([join, rows]) => rows.flatMap(row => [join, row[0], row[1]])); + await using proc = spawn({ + cmd: [bunExe(), "lint", "--run-path-tests", style, ...calls], + env, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { lines: stdout.split(/\r?\n/).slice(0, -1), stderr, exitCode }; + }; + const expected = (table: Record) => ({ + lines: Object.values(table).flatMap(rows => rows.map(([, , directory, rest]) => `${directory} + ${rest}`)), + stderr: "", + exitCode: 0, + }); + expect(await run("posix", joins)).toEqual(expected(joins)); + expect(await run("windows", windows)).toEqual(expected(windows)); +}); + +const oxlintrc = { + ".oxlintrc.json": JSON.stringify({ + plugins: ["import"], + categories: { correctness: "off" }, + rules: { "import/no-cycle": "error" }, + }), +}; +const paths = { compilerOptions: { paths: { "@/*": ["./src/*"] } } }; +// `a` imports `other` and `c`, and both import `a`. A cycle is only found where the tsconfig.json applies to both of +// its files, because only then `@/` means `src/`. +const cycles = (other: string) => ({ + ...oxlintrc, + "src/a.ts": `import { b } from "@/${other}";\nimport { c } from "@/c";\nexport const a = [b, c];\n`, + [`src/${other}.ts`]: `import { a } from "@/a";\nexport const b = a;\n`, + "src/c.ts": `import { a } from "@/a";\nexport const c = a;\n`, +}); +const cycleOfOther = (other: string) => [`src/${other}.ts:1`, "src/a.ts:1"]; +const cycleOfC = ["src/a.ts:2", "src/c.ts:1"]; + +// The expected reports are those of oxlint 1.89. Its resolver compares the directory of a tsconfig.json as a path +// where no `include` pattern is joined to it, and it compares an entry with the file before it reads the entry as a +// pattern. So an entry that names a file applies to it, also if the name or a directory above has a `[` or a `{`. +// With `include: ["src"]` oxlint applies nothing in a directory named `[proj]`, and neither does `bun lint`. +test.concurrent.each([ + ["[proj]", "b", {}, [...cycleOfC, ...cycleOfOther("b")]], + ["{a,b}", "b", { include: ["src/a.ts", "src/b.ts", "src/c.ts"] }, [...cycleOfC, ...cycleOfOther("b")]], + ["[proj]", "b", { exclude: ["src/b.ts"] }, cycleOfC], + ["[proj]", "b", { include: ["src"] }, []], + ["proj", "[id]", { include: ["src/a.ts", "src/[id].ts", "src/c.ts"] }, [...cycleOfC, ...cycleOfOther("[id]")]], + ["proj", "[id]", { exclude: ["src/[id].ts"] }, cycleOfC], +])( + "with an .oxlintrc.json in a directory named %s and a file %s.ts, a tsconfig.json with %j", + async (name, other, scope, expected) => { + const files = { ...cycles(other), "tsconfig.json": JSON.stringify({ ...paths, ...scope }) }; + expect(await reportedIn(name, files)).toEqual(expected.map(it => `${it} import/no-cycle`).sort()); + }, + debugTimeout, +); + +// A config that `--tsconfig` names, with a project that it references and that has no `include`. +test.concurrent.each(["[proj]", "{a,b}"])( + "with an .oxlintrc.json, a referenced project without `include` applies in a directory named %s", + async name => { + const { "src/c.ts": _, ...files } = cycles("b"); + const configs = { + "tsconfig.json": JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }] }), + "tsconfig.app.json": JSON.stringify(paths), + "src/a.ts": `import { b } from "@/b";\nexport const a = b;\n`, + }; + expect(await reportedIn(name, { ...files, ...configs }, ["--tsconfig", "tsconfig.json"])).toEqual( + cycleOfOther("b") + .map(it => `${it} import/no-cycle`) + .sort(), + ); + }, + debugTimeout, +); + +/** The reports with `settings["import/resolver"].typescript.project` set to `projects`. */ +function reportedWithProjects(name: string, projects: string[], rule: string, files: Files) { + const settings = `"import/resolver" = { typescript = { project = ${JSON.stringify(projects)} } }`; + return reportedIn(name, { + "package.json": `{ "name": "p", "type": "module" }`, + "bunfig.toml": `[lint.rules]\n"${rule}" = "error"\n[lint.settings]\n${settings}\n`, + ...files, + }); +} +const aliasedTo = (directory: string) => ({ compilerOptions: { paths: { "@/*": [`./${directory}/*`] } } }); +const exporting = (name: string) => `export const only${name} = 1;\n`; +const importing = (name: string) => `import { only${name} } from "@/y";\nexport default only${name};\n`; + +// `bun lint` uses the closest `project` whose `include` has the file, alone. eslint-import-resolver-typescript 4.4.5 +// does that once the project has resolved an import, and these are its reports. It reads the directory of a +// tsconfig.json as a path. Like TypeScript, it takes a last name with a `.` for a file, so in `my.app` the entry `.` +// includes nothing, and the paths of the other project apply. +test.concurrent.each([ + ["[proj]", ["b"], "b/u.ts"], + ["{a,b}", ["b"], "b/u.ts"], + ["[proj]", ["."], "b/u.ts"], + ["proj", ["."], "b/u.ts"], + ["my.app", ["b"], "b/u.ts"], + ["my.app", ["."], "b/t.ts"], +])( + "settings[import/resolver].typescript in a directory named %s, with `include` %j in the second project", + async (name, include, reported) => { + const files = { + "tsconfig.a.json": JSON.stringify({ ...aliasedTo("a"), include: ["a"] }), + "tsconfig.b.json": JSON.stringify({ ...aliasedTo("b"), include }), + "a/y.ts": exporting("A"), + "b/y.ts": exporting("B"), + "b/t.ts": importing("B"), + "b/u.ts": importing("A"), + }; + const projects = ["tsconfig.a.json", "tsconfig.b.json"]; + expect(await reportedWithProjects(name, projects, "import/named", files)).toEqual([`${reported}:1 import/named`]); + }, + debugTimeout, +); + +// An entry of the second project leaves its directory, or is excluded there. These are the reports of ESLint 9.39.5 +// with the resolver in every directory. +const shared = { + "tsconfig.a.json": JSON.stringify({ ...aliasedTo("a"), include: ["a"] }), + "a/y.ts": exporting("A"), + "app/src/y.ts": exporting("App"), + "shared/t.ts": importing("App"), + "shared/u.ts": importing("A"), +}; +test.concurrent.each([ + ["[ws]", ["src", "../shared"]], + ["{a,b}", ["${configDir}/src", "${configDir}/../shared"]], + ["[ws]", ["src", "..\\shared"]], +])( + "settings[import/resolver].typescript in a directory named %s, with `include` %j in a project below it", + async (name, include) => { + const files = { ...shared, "app/tsconfig.json": JSON.stringify({ ...aliasedTo("src"), include }) }; + const projects = ["tsconfig.a.json", "app/tsconfig.json"]; + expect(await reportedWithProjects(name, projects, "import/named", files)).toEqual(["shared/u.ts:1 import/named"]); + }, + debugTimeout, +); + +test.concurrent( + "settings[import/resolver].typescript in a directory named {a,b}, with an `exclude` in the first project", + async () => { + const files = { + "tsconfig.b.json": JSON.stringify({ ...aliasedTo("b"), include: ["b"], exclude: ["b/gen"] }), + "tsconfig.a.json": JSON.stringify({ ...aliasedTo("a"), include: ["a", "b/gen"] }), + "a/y.ts": exporting("A"), + "b/y.ts": exporting("B"), + "b/gen/t.ts": importing("A"), + "b/gen/u.ts": importing("B"), + }; + const projects = ["tsconfig.b.json", "tsconfig.a.json"]; + expect(await reportedWithProjects("{a,b}", projects, "import/named", files)).toEqual(["b/gen/u.ts:1 import/named"]); + }, + debugTimeout, +); + +// Both projects include b/t.ts. `bun lint` uses the first one alone, in a directory of any name. The resolver goes on +// to the second one until the first one has resolved an import. So it reports the same if `./y` comes first, and +// nothing if `@b/y` does. +test.concurrent.each([ + [`import "./y";\nimport "@b/y";\n`, "b/t.ts:2 import/no-unresolved"], + [`import "@b/y";\n`, "b/t.ts:1 import/no-unresolved"], +])( + "settings[import/resolver].typescript in a directory named [ws], with two projects for a file that has %j", + async (code, reported) => { + const files = { + "tsconfig.a.json": JSON.stringify({ compilerOptions: { paths: { "@a/*": ["./a/*"] } }, include: ["a", "b"] }), + "tsconfig.b.json": JSON.stringify({ compilerOptions: { paths: { "@b/*": ["./b/*"] } }, include: ["b"] }), + "b/y.ts": exporting("B"), + "b/t.ts": code, + }; + const projects = ["tsconfig.a.json", "tsconfig.b.json"]; + expect(await reportedWithProjects("[ws]", projects, "import/no-unresolved", files)).toEqual([reported]); + }, + debugTimeout, +); + +// No project includes b/. `bun lint` then tries each one, in a directory of any name. The resolver does that in a +// directory named `ws`. In one named `[ws]` it takes the path of each project for a pattern, and resolves nothing. +test.concurrent( + "settings[import/resolver].typescript in a directory named [ws], for a file that no project includes", + async () => { + const files = { + "tsconfig.a.json": JSON.stringify({ ...aliasedTo("a"), include: ["a"] }), + "tsconfig.b.json": JSON.stringify({ ...aliasedTo("b"), include: ["c"] }), + "a/y.ts": exporting("A"), + "b/t.ts": importing("A"), + "b/u.ts": importing("B"), + }; + const projects = ["tsconfig.a.json", "tsconfig.b.json"]; + expect(await reportedWithProjects("[ws]", projects, "import/named", files)).toEqual(["b/u.ts:1 import/named"]); + }, + debugTimeout, +); + +// `--ignore-pattern` is relative to the working directory and `ignorePatterns` to bunfig.toml. ESLint passes the +// directory of its own patterns as their `basePath` since 9.30, and `bun lint` does the same for it. An older ESLint +// refuses the key. It writes the directory in front of each pattern, and so does `bun lint` for it. +describe("ignore patterns for the installed ESLint, from another directory than the config file's", () => { + // A stand-in for the `eslint` package that ignores like ESLint's config array. The last pattern that matches decides. + // An object with a `basePath` is for what is below that directory. Nothing below an ignored directory is linted. + const eslintAt = (version: string) => ({ + "node_modules/eslint/package.json": JSON.stringify({ name: "eslint", version, main: "index.js" }), + "node_modules/eslint/lib/config/flat-config-array.js": ` + const path = require("node:path"); + const [major, minor] = ${JSON.stringify(version)}.split(".").map(Number); + exports.FlatConfigArray = class { + #configs = []; + constructor(configs, { basePath }) { + this.basePath = basePath; + this.push(...configs); + } + push(...configs) { + this.#configs.push(...configs); + } + normalize() { + const knowsBasePath = major > 9 || (major === 9 && minor >= 30); + if (!knowsBasePath && this.#configs.some(it => "basePath" in it)) throw new Error('Unexpected key "basePath" found.'); + } + #isIgnored(target, slash) { + let isIgnored = false; + for (const { basePath = this.basePath, ignores = [], ...rest } of this.#configs) { + if (Object.keys(rest).length > 0) continue; + const relative = path.relative(basePath, target).replaceAll("\\\\", "/"); + if (relative === "" || relative === ".." || relative.startsWith("../")) continue; + for (const it of ignores) { + const isNegated = it.startsWith("!"); + if (new Bun.Glob(isNegated ? it.slice(1) : it).match(relative + slash)) isIgnored = !isNegated; + } + } + return isIgnored; + } + getConfig(file) { + for (let directory = path.dirname(file); directory !== this.basePath; directory = path.dirname(directory)) { + if (this.#isIgnored(directory, "/")) return undefined; + } + return this.#isIgnored(file, "") ? undefined : { hasBasePath: this.#configs.some(it => "basePath" in it) }; + } + };`, + "node_modules/eslint/index.js": ` + exports.Linter = class { + static version = ${JSON.stringify(version)}; + verify(text, configs, { filename }) { + const config = configs.getConfig(filename); + const message = config === undefined ? "ignored" : config.hasBasePath ? "linted, with a basePath" : "linted"; + return [{ ruleId: null, severity: 1, message, line: 1, column: 1 }]; + } + getSuppressedMessages() { + return []; + } + };`, + }); + // The parser is an inline object, so the .vue files go to the installed ESLint with the whole config file. + const config = ` + const parser = { meta: { name: "vue-eslint-parser" }, parseForESLint() {} }; + export default [{ ignores: ["**/dist/**/*"] }, { files: ["**/*.vue"], languageOptions: { parser } }];`; + + /** + * Lints the directory `pkg` or `name` from `cwd`, with the config file in the directory `config`. Returns what the + * installed ESLint says about each file that `bun lint` sends to it. + */ + async function sent(version: string, args: string[], { name = "pkg", config: at = ".", cwd = name, more = {} } = {}) { + using dir = tempDir("bun-lint-glob", { + ...eslintAt(version), + [join(at, "eslint.config.mjs")]: config, + ...Object.fromEntries( + ["keep", "dist/keep", "dist/x", "src/y"].map(it => [`${name}/${it}.vue`, "