Skip to content

Commit 9cc837b

Browse files
committed
Merge with main
2 parents 936a8d9 + f3fcbe2 commit 9cc837b

File tree

58 files changed

+668
-239
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+668
-239
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,6 @@ UPCOMING.md
5252
# This is created to later publish its content on the pipeline and do not need to be included on git as
5353
# artifacts or bundleAnalysis folders. See scripts/pack-build-output.sh for more details.
5454
build_output_archive
55+
56+
# Claude Code
57+
.claude/

build-tools/packages/build-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"@types/chai": "^5.2.3",
150150
"@types/chai-arrays": "^2.0.3",
151151
"@types/debug": "^4.1.12",
152+
"@types/eslint": "^9.6.1",
152153
"@types/fs-extra": "^11.0.4",
153154
"@types/issue-parser": "^3.0.5",
154155
"@types/mdast": "^4.0.4",

build-tools/packages/build-cli/src/commands/generate/typetests.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,6 @@ export default class GenerateTypetestsCommand extends PackageCommand<
204204
* Generated by flub generate:typetests in @fluid-tools/build-cli.
205205
*
206206
* Baseline (previous) version: ${previousVersionToUse}
207-
* Current version: ${currentVersionToUse}
208207
*/
209208
210209
${imports.join("\n")}

build-tools/packages/build-cli/src/library/repoPolicyCheck/fluidBuildTasks.ts

Lines changed: 115 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55

66
import fs from "node:fs";
7-
import { createRequire } from "node:module";
87
import path from "node:path";
98
import {
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";
2421
import * as semver from "semver";
2522
import type { TsConfigJson } from "type-fest";
2623
import { getFlubConfig } from "../../config.js";
2724
import { type Handler, readFile } from "./common.js";
2825
import { 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

build-tools/packages/build-tools/src/fluidBuild/tasks/taskUtils.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,23 @@ import type { PackageJson } from "../../common/npmPackage";
1414
import { lookUpDirSync } from "../../common/utils";
1515

1616
export function getEsLintConfigFilePath(dir: string) {
17+
// ESLint 9 flat config files (checked first as they take precedence)
18+
// Then legacy eslintrc files for backwards compatibility
1719
// TODO: we currently don't support .yaml and .yml, or config in package.json
18-
const possibleConfig = [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc"];
20+
const possibleConfig = [
21+
// ESLint 9 flat config files
22+
"eslint.config.mjs",
23+
"eslint.config.mts",
24+
"eslint.config.cjs",
25+
"eslint.config.cts",
26+
"eslint.config.js",
27+
"eslint.config.ts",
28+
// Legacy eslintrc files
29+
".eslintrc.js",
30+
".eslintrc.cjs",
31+
".eslintrc.json",
32+
".eslintrc",
33+
];
1934
for (const configFile of possibleConfig) {
2035
const configFileFullPath = path.join(dir, configFile);
2136
if (existsSync(configFileFullPath)) {

build-tools/pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/build/eslint-config-fluid/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ Packages can now use `eslint.config.mjs` instead of `.eslintrc.cjs`, but the leg
7474

7575
- `unicorn/import-style`: Changed from `"error"` to `"off"`
7676
- `unicorn/consistent-destructuring`: Changed from `"error"` to `"off"`
77+
- `unicorn/no-array-callback-reference`: Changed from `"error"` to `"off"`
78+
- Yields false positives for calls to `map` methods on non-array types.
7779

7880
**Unicorn rules changed to warnings** (to surface occurrences without breaking builds):
7981

@@ -82,6 +84,16 @@ Packages can now use `eslint.config.mjs` instead of `.eslintrc.cjs`, but the leg
8284
- `unicorn/prefer-string-replace-all`: Changed from `"off"` to `"warn"`
8385
- `unicorn/prefer-structured-clone`: New rule set to `"warn"`
8486

87+
#### Rule promotions
88+
89+
**recommended -> minimal**
90+
91+
- `@typescript-eslint/explicit-function-return-type`
92+
93+
#### Rule modifications
94+
95+
- `jsdoc/multiline-blocks`: Updated to allow single-line comments to be expressed as a single line. E.g. `/** Single-line comment */`.
96+
8597
## [9.0.0](https://github.com/microsoft/FluidFramework/releases/tag/eslint-config-fluid_v9.0_0)
8698

8799
### eslint-plugin-eslint-comments replaced by @eslint-community/eslint-plugin-eslint-comments

common/build/eslint-config-fluid/minimal-deprecated.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ module.exports = {
172172
"@typescript-eslint/no-non-null-assertion": "error",
173173
"@typescript-eslint/no-unnecessary-type-assertion": "error",
174174

175+
// In some cases, type inference can be wrong, and this can cause a "flip-flop" of type changes in our
176+
// API documentation. For example, type inference might decide a function returns a concrete type
177+
// instead of an interface. This has no runtime impact, but would cause compilation problems.
178+
"@typescript-eslint/explicit-function-return-type": [
179+
"error",
180+
{
181+
allowExpressions: true,
182+
allowTypedFunctionExpressions: true,
183+
allowHigherOrderFunctions: true,
184+
allowDirectConstAssertionInArrowFunctions: true,
185+
allowConciseArrowFunctionExpressionsStartingWithVoid: false,
186+
},
187+
],
188+
175189
"@typescript-eslint/no-restricted-imports": [
176190
"error",
177191
{
@@ -224,7 +238,6 @@ module.exports = {
224238
* Disabled because we don't require that all variable declarations be explicitly typed.
225239
*/
226240
"@rushstack/typedef-var": "off",
227-
"@typescript-eslint/explicit-function-return-type": "off",
228241
"@typescript-eslint/explicit-member-accessibility": "off",
229242

230243
"@typescript-eslint/member-ordering": "off",

common/build/eslint-config-fluid/printed-configs/default.json

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,10 +1294,7 @@
12941294
"error"
12951295
],
12961296
"jsdoc/multiline-blocks": [
1297-
"error",
1298-
{
1299-
"noSingleLineBlocks": true
1300-
}
1297+
"error"
13011298
],
13021299
"jsdoc/no-bad-blocks": [
13031300
"error"
@@ -2050,7 +2047,7 @@
20502047
"error"
20512048
],
20522049
"unicorn/no-array-callback-reference": [
2053-
"error"
2050+
"off"
20542051
],
20552052
"unicorn/no-array-for-each": [
20562053
"error"

common/build/eslint-config-fluid/printed-configs/minimal.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,14 @@
666666
}
667667
],
668668
"@typescript-eslint/explicit-function-return-type": [
669-
"off"
669+
"error",
670+
{
671+
"allowExpressions": true,
672+
"allowTypedFunctionExpressions": true,
673+
"allowHigherOrderFunctions": true,
674+
"allowDirectConstAssertionInArrowFunctions": true,
675+
"allowConciseArrowFunctionExpressionsStartingWithVoid": false
676+
}
670677
],
671678
"@typescript-eslint/explicit-member-accessibility": [
672679
"off"

0 commit comments

Comments
 (0)