44 */
55
66import fs from "node:fs" ;
7- import { createRequire } from "node:module" ;
87import path from "node:path" ;
98import {
109 updatePackageJsonFile ,
@@ -15,19 +14,75 @@ import {
1514 type Package ,
1615 type PackageJson ,
1716 TscUtils ,
18- getEsLintConfigFilePath ,
1917 getFluidBuildConfig ,
2018 getTaskDefinitions ,
2119 normalizeGlobalTaskDefinitions ,
2220} from "@fluidframework/build-tools" ;
23- import JSON5 from "json5" ;
2421import * as semver from "semver" ;
2522import type { TsConfigJson } from "type-fest" ;
2623import { getFlubConfig } from "../../config.js" ;
2724import { type Handler , readFile } from "./common.js" ;
2825import { FluidBuildDatabase } from "./fluidBuildDatabase.js" ;
2926
30- const require = createRequire ( import . meta. url ) ;
27+ /**
28+ * Parser options structure used by typescript-eslint parser.
29+ * The `project` field specifies which tsconfig files to use for type-aware linting.
30+ */
31+ interface ParserOptions {
32+ project ?: string | string [ ] | boolean | undefined ;
33+ }
34+
35+ /**
36+ * Computed ESLint configuration returned by {@link calculateConfigForFile}.
37+ * Supports both legacy eslintrc format and ESLint 9 flat config format.
38+ */
39+ interface ComputedESLintConfig {
40+ // Legacy eslintrc format: parserOptions at top level
41+ parserOptions ?: ParserOptions ;
42+ // ESLint 9 flat config format: parserOptions nested under languageOptions
43+ languageOptions ?: {
44+ parserOptions ?: ParserOptions ;
45+ } ;
46+ }
47+
48+ /**
49+ * Interface for ESLint instance with calculateConfigForFile method.
50+ */
51+ interface ESLintInstance {
52+ calculateConfigForFile ( filePath : string ) : Promise < ComputedESLintConfig > ;
53+ }
54+
55+ /**
56+ * Type for the ESLint module exports.
57+ * Requires ESLint 8.57.0+ which introduced the loadESLint API.
58+ */
59+ interface ESLintModuleType {
60+ loadESLint : ( opts ?: { cwd ?: string } ) => Promise <
61+ new ( instanceOpts ?: { cwd ?: string } ) => ESLintInstance
62+ > ;
63+ }
64+
65+ /**
66+ * Dynamically load ESLint and get the appropriate ESLint class for the config format.
67+ * This uses ESLint's loadESLint function (added in 8.57.0) which auto-detects flat vs legacy config.
68+ */
69+ async function getESLintInstance ( cwd : string ) : Promise < ESLintInstance > {
70+ // Dynamic import with cast to a custom interface because ESLint's types differ
71+ // significantly between v8 and v9. The cast through `unknown` is safe because:
72+ // 1. ESLintModuleType is a minimal interface covering only the loadESLint API we use
73+ // 2. We validate loadESLint exists at runtime before using it (see check below)
74+ // 3. If validation fails, we throw a descriptive error guiding users to upgrade
75+ const eslintModule = ( await import ( "eslint" ) ) as unknown as ESLintModuleType ;
76+
77+ if ( eslintModule . loadESLint === undefined ) {
78+ throw new Error (
79+ "ESLint 8.57.0 or later is required for config detection. Please upgrade your ESLint dependency." ,
80+ ) ;
81+ }
82+
83+ const ESLintClass = await eslintModule . loadESLint ( { cwd } ) ;
84+ return new ESLintClass ( { cwd } ) ;
85+ }
3186
3287/**
3388 * Get and cache the tsc check ignore setting
@@ -164,19 +219,41 @@ function findTscScript(json: Readonly<PackageJson>, project: string): string | u
164219 throw new Error ( `'${ project } ' used in scripts '${ tscScripts . join ( "', '" ) } '` ) ;
165220}
166221
167- // This should be TSESLint.Linter.Config or .ConfigType from @typescript -eslint/utils
168- // but that can only be used once this project is using Node16 resolution. PR #20972
169- // We could derive type from @typescript -eslint/eslint-plugin, but that it will add
170- // peer dependency requirements.
171- interface EslintConfig {
172- parserOptions ?: {
173- // https://typescript-eslint.io/packages/parser/#project
174- // eslint-disable-next-line @rushstack/no-new-null
175- project ?: string | string [ ] | boolean | null ;
176- } ;
222+ /**
223+ * Find a representative TypeScript source file in the package directory.
224+ * This is needed because ESLint's calculateConfigForFile requires an actual file path.
225+ * @param packageDir - The directory of the package.
226+ * @returns The path to a representative source file, or undefined if none is found.
227+ */
228+ function findRepresentativeSourceFile ( packageDir : string ) : string | undefined {
229+ // Common source directories to check
230+ const sourceDirs = [ "src" , "lib" , "source" , "." ] ;
231+ const extensions = new Set ( [ ".ts" , ".tsx" , ".js" , ".jsx" , ".mts" , ".cts" ] ) ;
232+
233+ for ( const dir of sourceDirs ) {
234+ const fullDir = path . join ( packageDir , dir ) ;
235+ if ( ! fs . existsSync ( fullDir ) || ! fs . statSync ( fullDir ) . isDirectory ( ) ) {
236+ continue ;
237+ }
238+
239+ try {
240+ const files = fs . readdirSync ( fullDir ) ;
241+ for ( const file of files ) {
242+ const ext = path . extname ( file ) ;
243+ if ( extensions . has ( ext ) ) {
244+ return path . join ( fullDir , file ) ;
245+ }
246+ }
247+ } catch {
248+ // Directory not readable, try next
249+ }
250+ }
251+
252+ return undefined ;
177253}
254+
178255/**
179- * Get a list of build script names that the eslint depends on, based on .eslintrc file.
256+ * Get a list of build script names that eslint depends on, based on eslint config file.
180257 * @remarks eslint does not depend on build tasks for the projects it references. (The
181258 * projects' configurations guide eslint typescript parser to use original typescript
182259 * source.) The packages that those projects depend on must be built. So effectively
@@ -195,39 +272,36 @@ async function eslintGetScriptDependencies(
195272 return [ ] ;
196273 }
197274
198- const eslintConfig = getEsLintConfigFilePath ( packageDir ) ;
199- // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
200- if ( ! eslintConfig ) {
201- throw new Error ( `Unable to find eslint config file for package in ${ packageDir } ` ) ;
275+ // Use ESLint's API to load and compute the effective configuration.
276+ // This handles both legacy eslintrc and ESLint 9 flat config formats,
277+ // as well as TypeScript config files (.mts, .cts, .ts) and ESM configs (.mjs).
278+ const eslint = await getESLintInstance ( packageDir ) ;
279+
280+ // Find a representative TypeScript file to calculate config for.
281+ // We need an actual file path because calculateConfigForFile requires it.
282+ const representativeFile = findRepresentativeSourceFile ( packageDir ) ;
283+ if ( representativeFile === undefined ) {
284+ // No source files found, assume no eslint dependencies
285+ return [ ] ;
202286 }
203287
204- let config : EslintConfig ;
288+ let projects : string | string [ ] | boolean | undefined ;
205289 try {
206- const { ext } = path . parse ( eslintConfig ) ;
207- if ( ext === ".mjs" ) {
208- throw new Error ( `Eslint config '${ eslintConfig } ' is ESM; only CommonJS is supported.` ) ;
209- }
290+ const config = await eslint . calculateConfigForFile ( representativeFile ) ;
210291
211- if ( ext !== ".js" && ext !== ".cjs" ) {
212- // TODO: optimize double read for TscDependentTask.getDoneFileContent and there.
213- const configFile = fs . readFileSync ( eslintConfig , "utf8" ) ;
214- config = JSON5 . parse ( configFile ) ;
215- } else {
216- // This code assumes that the eslint config will be in CommonJS, because if it's ESM the require call will fail.
217- config = require ( path . resolve ( eslintConfig ) ) as EslintConfig ;
218- if ( config === undefined ) {
219- throw new Error ( `Exports not found in ${ eslintConfig } ` ) ;
220- }
221- }
292+ // Handle both legacy eslintrc and flat config structures:
293+ // - Legacy: config.parserOptions?.project
294+ // - Flat config: config.languageOptions?.parserOptions?.project
295+ projects = config . languageOptions ?. parserOptions ?. project ?? config . parserOptions ?. project ;
222296 } catch ( error ) {
223- throw new Error ( `Unable to load eslint config file ${ eslintConfig } . ${ error } ` ) ;
297+ throw new Error (
298+ `Unable to load eslint config for package in ${ packageDir } . ${ error instanceof Error ? error . message : error } ` ,
299+ ) ;
224300 }
225301
226- let projects = config . parserOptions ?. project ;
227302 if ( ! Array . isArray ( projects ) && typeof projects !== "string" ) {
228- // "config" is normally the raw configuration as file is on disk and has not
229- // resolved and merged any extends specifications. So, "project" is what is
230- // set in top file.
303+ // The computed config merges extends and overrides, so "project" reflects
304+ // the effective setting for the representative file.
231305 if ( projects === false || projects === null ) {
232306 // type based linting is disabled - assume no task prerequisites
233307 return [ ] ;
@@ -254,7 +328,7 @@ async function eslintGetScriptDependencies(
254328
255329 if ( found === undefined ) {
256330 throw new Error (
257- `Unable to find tsc script using project '${ project } ' specified in ' ${ eslintConfig } ' within package '${ json . name } '` ,
331+ `Unable to find tsc script using project '${ project } ' specified in eslint config within package '${ json . name } '` ,
258332 ) ;
259333 }
260334
0 commit comments