@utoo/lint 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs CHANGED
@@ -9,8 +9,12 @@ const { createLegacyConfigResolver } = require("./lib/legacy-config.cjs");
9
9
  const { formatESLintResults } = require("./lib/stylish-formatter.cjs");
10
10
  const {
11
11
  findConfigPath: findConfigPathFromDirectory,
12
+ isExecutableConfigPath,
12
13
  readConfig
13
14
  } = require("./lib/config-loader.cjs");
15
+ const configModule = require("./lib/config-module.cjs");
16
+ const estreeParser = require("./lib/estree-parser.cjs");
17
+ const pluginRunner = require("./lib/plugin-runner.cjs");
14
18
 
15
19
  const version = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")).version;
16
20
 
@@ -25,10 +29,6 @@ const JS_KEYWORDS = new Set([
25
29
  "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try", "typeof",
26
30
  "var", "void", "while", "with", "yield"
27
31
  ]);
28
- const AST_TRAVERSAL_SKIP_KEYS = new Set([
29
- "type", "parent", "loc", "range", "tokens", "comments", "leadingComments", "trailingComments",
30
- "innerComments", "raw", "value", "name", "regex", "bigint", "errors"
31
- ]);
32
32
  const RULE_TESTER_INITIAL_CONFIG = { rules: {} };
33
33
  let ruleTesterDefaultConfig = { rules: {} };
34
34
  let ruleTesterDescribe = null;
@@ -480,6 +480,7 @@ const NATIVE_ONLY_RULE_IDS = [
480
480
  "semantic-errors"
481
481
  ];
482
482
  const NATIVE_RULE_IDS = [...BUILTIN_RULE_IDS, ...NATIVE_ONLY_RULE_IDS];
483
+ const NATIVE_RULE_ID_SET = new Set(NATIVE_RULE_IDS);
483
484
  const FIXABLE_BUILTIN_RULE_IDS = new Set([
484
485
  "@typescript-eslint/no-explicit-any",
485
486
  "jest/no-deprecated-functions",
@@ -607,15 +608,15 @@ class UtooLint {
607
608
  throw new Error("'results' must be an array");
608
609
  }
609
610
  const options = eslintConstructorOptions(this.options);
610
- const customRuleConfig = [options.baseConfig, options.overrideConfig].filter(Boolean);
611
611
  const rulesByFilePath = new Map();
612
+ const cachedOptions = withConfigCache(options);
612
613
  return rulesMetaForResults(results, (ruleId, result) => {
613
- if (customRuleConfig.length === 0 || typeof result.filePath !== "string") {
614
+ if (typeof result.filePath !== "string") {
614
615
  return undefined;
615
616
  }
616
617
  const filePath = normalizeESLintFilePath(result.filePath, options.cwd);
617
618
  if (!rulesByFilePath.has(filePath)) {
618
- rulesByFilePath.set(filePath, customRuleMapForConfig(customRuleConfig, new Map(), filePath, options.cwd));
619
+ rulesByFilePath.set(filePath, jsPluginRuleMapForFile(cachedOptions, filePath));
619
620
  }
620
621
  return rulesByFilePath.get(filePath).get(ruleId)?.meta;
621
622
  });
@@ -1229,21 +1230,210 @@ function lintFiles(paths, options = {}) {
1229
1230
  }
1230
1231
 
1231
1232
  const nativeFlatOptions = nativeFlatConfigOptions(options);
1232
- if (nativeFlatOptions) {
1233
- const report = runNativeLintReport(lintPaths, nativeFlatOptions);
1234
- return finalizeLintReport(report, ignoredDiagnostics, options);
1235
- }
1233
+ const report = nativeFlatOptions
1234
+ ? runNativeLintReport(lintPaths, nativeFlatOptions)
1235
+ : runNativeConfigRunReports(lintPaths, options);
1236
+ // The flat-config path resolves one config for the whole run; only the
1237
+ // per-file path has already discovered (and cached) nested configs.
1238
+ appendPluginRuleDiagnostics(report, options, { perFileConfig: !nativeFlatOptions });
1239
+ return finalizeLintReport(report, ignoredDiagnostics, options);
1240
+ }
1236
1241
 
1242
+ function runNativeConfigRunReports(lintPaths, options) {
1237
1243
  const configRuns = nativeConfigRunsForFiles(expandNativeConfigRunPaths(lintPaths, options), options);
1238
1244
  if (configRuns) {
1239
- const report = mergeNativeReports(
1245
+ return mergeNativeReports(
1240
1246
  configRuns.map(({ paths: runPaths, options: runOptions }) => runNativeLintReport(runPaths, runOptions))
1241
1247
  );
1242
- return finalizeLintReport(report, ignoredDiagnostics, options);
1243
1248
  }
1244
1249
 
1245
- const report = runNativeLintReport(lintPaths, options);
1246
- return finalizeLintReport(report, ignoredDiagnostics, options);
1250
+ return runNativeLintReport(lintPaths, options);
1251
+ }
1252
+
1253
+ function pluginFixModeForOptions(options) {
1254
+ const extraArgs = Array.isArray(options.extraArgs) ? options.extraArgs : [];
1255
+ if (options.fix || extraArgs.includes("--fix-dry-run")) {
1256
+ return "dry-run";
1257
+ }
1258
+ return extraArgs.includes("--fix") ? "write" : null;
1259
+ }
1260
+
1261
+ function pluginMessageToDiagnostic(message, filePath) {
1262
+ const toFix = (fix) => ({ range: [fix.range[0], fix.range[1]], text: fix.text });
1263
+ const diagnostic = {
1264
+ filePath,
1265
+ line: message.line,
1266
+ column: message.column,
1267
+ severity: message.severity === 2 ? "error" : "warning",
1268
+ message: message.message,
1269
+ ruleId: message.ruleId,
1270
+ fixes: ruleFixItems(message.fix).map(toFix),
1271
+ suggestions: (message.suggestions ?? []).map((suggestion) => ({
1272
+ desc: suggestion.desc,
1273
+ fix: ruleFixItems(suggestion.fix).map(toFix)
1274
+ }))
1275
+ };
1276
+ if (typeof message.endLine === "number") {
1277
+ diagnostic.endLine = message.endLine;
1278
+ }
1279
+ if (typeof message.endColumn === "number") {
1280
+ diagnostic.endColumn = message.endColumn;
1281
+ }
1282
+ return diagnostic;
1283
+ }
1284
+
1285
+ function sortReportDiagnostics(report) {
1286
+ const order = new Map((report.filePaths ?? []).map((filePath, index) => [filePath, index]));
1287
+ const rank = (diagnostic) => order.get(diagnostic.filePath) ?? Number.MAX_SAFE_INTEGER;
1288
+ const compare = (left, right) => rank(left) - rank(right)
1289
+ || (left.line ?? 0) - (right.line ?? 0)
1290
+ || (left.column ?? 0) - (right.column ?? 0);
1291
+ report.diagnostics = [...(report.diagnostics ?? [])].sort(compare);
1292
+ report.suppressedDiagnostics = [...(report.suppressedDiagnostics ?? [])].sort(compare);
1293
+ }
1294
+
1295
+ // Runs the ESLint plugin rules a config mounts and merges their diagnostics
1296
+ // into the native report. Plugin rules never reach the native binary; see
1297
+ // `withoutJsPluginRules`.
1298
+ function appendPluginRuleDiagnostics(report, options, { perFileConfig = true } = {}) {
1299
+ if (options.noConfig && !options.baseConfig && !options.overrideConfig) {
1300
+ return report;
1301
+ }
1302
+ const filePaths = report.filePaths ?? [];
1303
+ if (filePaths.length === 0 || !anyConfigDeclaresPlugins(options)) {
1304
+ return report;
1305
+ }
1306
+ const cwd = options.cwd ?? process.cwd();
1307
+ const singleMatchPath = filePaths.length === 1
1308
+ ? options.filePath ?? options.filename ?? options.pluginTextFilePath
1309
+ : undefined;
1310
+ const fixMode = pluginFixModeForOptions(options);
1311
+ let appended = false;
1312
+
1313
+ for (const filePath of filePaths) {
1314
+ const matchPath = singleMatchPath ?? filePath;
1315
+ const ruleMap = jsPluginRuleMapForFile(options, matchPath, perFileConfig);
1316
+ if (ruleMap.size === 0) {
1317
+ continue;
1318
+ }
1319
+ const ruleEntries = pluginRuleEntriesForFile(options, matchPath, ruleMap, perFileConfig);
1320
+ if (ruleEntries.length === 0) {
1321
+ continue;
1322
+ }
1323
+
1324
+ // The native report echoes targets as given, so relative paths resolve
1325
+ // against the lint cwd rather than the process cwd.
1326
+ const absolutePath = normalizeESLintFilePath(filePath, cwd);
1327
+ const outputIndex = (report.outputs ?? []).findIndex((fixed) => fixed.filePath === filePath);
1328
+ let code = outputIndex === -1 ? undefined : report.outputs[outputIndex].output;
1329
+ if (typeof code !== "string") {
1330
+ try {
1331
+ code = readFileSync(absolutePath, "utf8");
1332
+ } catch {
1333
+ continue;
1334
+ }
1335
+ }
1336
+
1337
+ const config = pluginConfigSourcesForFile(options, matchPath, perFileConfig).map((source) => source.config);
1338
+ const parserOptions = { ...parserOptionsForConfig(config, matchPath, cwd), filePath: matchPath, requireParser: true };
1339
+ const lintOptions = {
1340
+ cwd,
1341
+ filename: matchPath,
1342
+ config: calculatedConfig({ cwd, noConfig: true, baseConfig: config }, matchPath)
1343
+ };
1344
+ const lintOnce = (text) => {
1345
+ const sourceCode = createLinterSourceCode(text, null, parserOptions);
1346
+ return applyDisableDirectives(runCustomLinterRules(ruleEntries, sourceCode, lintOptions), sourceCode);
1347
+ };
1348
+
1349
+ let filtered = lintOnce(code);
1350
+ let relintedFromReport = false;
1351
+ if (fixMode) {
1352
+ let changed = false;
1353
+ for (let pass = 0; pass < MAX_AUTOFIX_PASSES; pass += 1) {
1354
+ const output = applyMessageFixes(code, filtered.messages);
1355
+ if (output === code) {
1356
+ break;
1357
+ }
1358
+ changed = true;
1359
+ code = output;
1360
+ filtered = lintOnce(code);
1361
+ }
1362
+ if (changed) {
1363
+ // Plugin fixes changed the text, so the native diagnostics for this
1364
+ // file describe stale source. Re-lint the fixed text through the whole
1365
+ // pipeline (native rules, plugin rules, further fixes) and replace this
1366
+ // file's diagnostics with the result.
1367
+ const depth = options.pluginFixDepth ?? 0;
1368
+ if (depth < MAX_AUTOFIX_PASSES) {
1369
+ const relinted = lintText(code, {
1370
+ ...options,
1371
+ filePath: matchPath,
1372
+ filename: undefined,
1373
+ config: pluginConfigPathForFile(options, matchPath, perFileConfig) ?? options.config,
1374
+ fix: true,
1375
+ extraArgs: (options.extraArgs ?? []).filter((arg) => arg !== "--fix" && arg !== "--fix-dry-run"),
1376
+ useDefaultConfigTargets: undefined,
1377
+ pluginTextFilePath: undefined,
1378
+ pluginFixDepth: depth + 1
1379
+ });
1380
+ code = relinted.outputs?.find((fixed) => fixed.filePath === matchPath)?.output ?? code;
1381
+ filtered = {
1382
+ messages: relinted.diagnostics ?? [],
1383
+ suppressedMessages: relinted.suppressedDiagnostics ?? []
1384
+ };
1385
+ report.diagnostics = (report.diagnostics ?? []).filter((diagnostic) => diagnostic.filePath !== filePath);
1386
+ report.suppressedDiagnostics = (report.suppressedDiagnostics ?? []).filter((diagnostic) => diagnostic.filePath !== filePath);
1387
+ relintedFromReport = true;
1388
+ }
1389
+ if (fixMode === "write") {
1390
+ writeFileSync(absolutePath, code);
1391
+ } else if (outputIndex === -1) {
1392
+ report.outputs = [...(report.outputs ?? []), { filePath, output: code }];
1393
+ } else {
1394
+ report.outputs[outputIndex] = { ...report.outputs[outputIndex], output: code };
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ if (relintedFromReport) {
1400
+ // Already native-shaped diagnostics from the nested lint; only the
1401
+ // file path needs to match this report.
1402
+ report.diagnostics = [
1403
+ ...(report.diagnostics ?? []),
1404
+ ...filtered.messages.map((diagnostic) => ({ ...diagnostic, filePath }))
1405
+ ];
1406
+ report.suppressedDiagnostics = [
1407
+ ...(report.suppressedDiagnostics ?? []),
1408
+ ...filtered.suppressedMessages.map((diagnostic) => ({ ...diagnostic, filePath }))
1409
+ ];
1410
+ appended = true;
1411
+ continue;
1412
+ }
1413
+ if (filtered.messages.length === 0 && filtered.suppressedMessages.length === 0) {
1414
+ continue;
1415
+ }
1416
+ report.diagnostics = [
1417
+ ...(report.diagnostics ?? []),
1418
+ ...filtered.messages.map((message) => pluginMessageToDiagnostic(message, filePath))
1419
+ ];
1420
+ report.suppressedDiagnostics = [
1421
+ ...(report.suppressedDiagnostics ?? []),
1422
+ ...filtered.suppressedMessages.map((message) => ({
1423
+ ...pluginMessageToDiagnostic(message, filePath),
1424
+ suppression: {
1425
+ kind: message.suppressions?.[0]?.kind ?? "directive",
1426
+ justification: message.suppressions?.[0]?.justification ?? ""
1427
+ }
1428
+ }))
1429
+ ];
1430
+ appended = true;
1431
+ }
1432
+
1433
+ if (appended) {
1434
+ sortReportDiagnostics(report);
1435
+ }
1436
+ return report;
1247
1437
  }
1248
1438
 
1249
1439
  function nativeFlatConfigOptions(options) {
@@ -1273,6 +1463,10 @@ function nativeFlatConfigOptions(options) {
1273
1463
  return undefined;
1274
1464
  }
1275
1465
 
1466
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(config);
1467
+ if (jsPluginRuleIds.size > 0) {
1468
+ config = configWithoutCustomRules(config, jsPluginRuleIds);
1469
+ }
1276
1470
  if (configCanUseNativeRulesFastPath(config)) {
1277
1471
  return undefined;
1278
1472
  }
@@ -1399,6 +1593,9 @@ function nativeConfigRunsForFiles(paths, options) {
1399
1593
  if (configured && options.rules) {
1400
1594
  rules = selectedRulesWithConfigOptions(rules, options.rules);
1401
1595
  }
1596
+ if (configured) {
1597
+ rules = withoutJsPluginRules(rules, jsPluginRuleIdsForOptions(options, matchPath));
1598
+ }
1402
1599
  const signature = configured ? stableConfigSignature({ rules, settings }) : "<native-defaults>";
1403
1600
  if (!groups.has(signature)) {
1404
1601
  groups.set(signature, { paths: [], rules, settings, configured });
@@ -1571,7 +1768,8 @@ function lintText(code, options = {}) {
1571
1768
  cwd: options.cwd,
1572
1769
  config: options.config ?? discoveredConfig,
1573
1770
  noConfig: options.noConfig,
1574
- deferDiagnosticConfigFiltering: true
1771
+ deferDiagnosticConfigFiltering: true,
1772
+ pluginTextFilePath: requestedPath
1575
1773
  });
1576
1774
  report.filePaths = (report.filePaths ?? []).map((filePath) => (filePath === tempFile ? requestedPath : filePath));
1577
1775
  report.outputs = (report.outputs ?? []).map((fixed) => ({
@@ -1613,11 +1811,12 @@ function buildNativeLintArgs(paths, options) {
1613
1811
  const configForRuleSelection = options.noConfig
1614
1812
  ? undefined
1615
1813
  : options.config ? readConfig(options.config, options.cwd, options.configCache) : undefined;
1814
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(configForRuleSelection, options.baseConfig, options.overrideConfig);
1616
1815
  const enabledRules = enabledRuleNamesFromConfigs(
1617
1816
  configForRuleSelection,
1618
1817
  options.baseConfig,
1619
1818
  options.overrideConfig
1620
- );
1819
+ ).filter((rule) => !jsPluginRuleIds.has(rule));
1621
1820
  if (enabledRules.length > 0) {
1622
1821
  cliArgs.push(`--rules=${enabledRules.join(",")}`);
1623
1822
  }
@@ -2095,9 +2294,11 @@ function withTemporaryConfig(options, callback) {
2095
2294
  ? readConfig(fileConfigPath, options.cwd, options.configCache)
2096
2295
  : undefined;
2097
2296
  const configs = [options.baseConfig, fileConfig, options.overrideConfig];
2098
- const rules = shouldMaterializeFileConfig
2099
- ? materializedRulesFromConfigs(...configs)
2100
- : runtimeRulesFromConfigs(...configs);
2297
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(...configs);
2298
+ const rules = withoutJsPluginRules(
2299
+ shouldMaterializeFileConfig ? materializedRulesFromConfigs(...configs) : runtimeRulesFromConfigs(...configs),
2300
+ jsPluginRuleIds
2301
+ );
2101
2302
  const settings = configs.reduce(
2102
2303
  (result, config) => ({
2103
2304
  ...result,
@@ -2116,7 +2317,7 @@ function withTemporaryConfig(options, callback) {
2116
2317
  }
2117
2318
  return callback(options);
2118
2319
  }
2119
- const enabledRules = enabledRuleNamesFromConfigs(...configs);
2320
+ const enabledRules = enabledRuleNamesFromConfigs(...configs).filter((rule) => !jsPluginRuleIds.has(rule));
2120
2321
  const hasExplicitOffRules = Object.values(rules).some((value) => ruleConfigSeverity(value) === 0);
2121
2322
  if (!shouldMaterializeFileConfig && !hasSettings && !hasRuleOptions(rules) && !hasExplicitOffRules && !options.forceMaterializedConfig) {
2122
2323
  return callback({
@@ -2245,14 +2446,14 @@ class Linter {
2245
2446
  const verifyOptions = typeof options === "string" ? { filename: options } : { ...options };
2246
2447
  const filePath = verifyOptions.filename ?? verifyOptions.filePath ?? "input.js";
2247
2448
  const normalizedFilePath = normalizeESLintFilePath(filePath, verifyOptions.cwd);
2449
+ const customRuleMap = customRuleMapForConfig(config, this.rules, normalizedFilePath, verifyOptions.cwd);
2450
+ const customRuleFilterMap = customRuleMapForConfig(config, this.rules);
2451
+ const customRules = customRuleEntriesForConfig(config, customRuleMap, normalizedFilePath, verifyOptions.cwd);
2248
2452
  const sourceCode = createLinterSourceCode(
2249
2453
  code,
2250
2454
  parserForConfig(config, this.parsers, normalizedFilePath, verifyOptions.cwd),
2251
- parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd)
2455
+ { ...parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd), requireParser: customRules.length > 0 }
2252
2456
  );
2253
- const customRuleMap = customRuleMapForConfig(config, this.rules, normalizedFilePath, verifyOptions.cwd);
2254
- const customRuleFilterMap = customRuleMapForConfig(config, this.rules);
2255
- const customRules = customRuleEntriesForConfig(config, customRuleMap, normalizedFilePath, verifyOptions.cwd);
2256
2457
  const nativeConfig = configWithoutCustomRules(config, customRuleFilterMap);
2257
2458
  const lintOptions = {
2258
2459
  cwd: verifyOptions.cwd,
@@ -2282,10 +2483,10 @@ class Linter {
2282
2483
  ];
2283
2484
  this.times = { passes: [] };
2284
2485
  this.fixPassCount = 0;
2285
- return [
2486
+ return sortLintMessages([
2286
2487
  ...nativeRuleFilter.messages,
2287
2488
  ...customRuleFilter.messages
2288
- ];
2489
+ ]);
2289
2490
  }
2290
2491
 
2291
2492
  verifyAndFix(code, config = {}, options = {}) {
@@ -2294,7 +2495,7 @@ class Linter {
2294
2495
  let fixPassCount = 0;
2295
2496
  for (let pass = 0; pass < MAX_AUTOFIX_PASSES; pass += 1) {
2296
2497
  const messages = this.verify(output, config, options);
2297
- const fixedOutput = applyRuleFixes(output, messages.flatMap((message) => ruleFixItems(message.fix)));
2498
+ const fixedOutput = applyMessageFixes(output, messages);
2298
2499
  if (fixedOutput === output) {
2299
2500
  this.fixPassCount = fixPassCount;
2300
2501
  return {
@@ -2377,9 +2578,17 @@ function parserForConfig(config, parsers, filePath, cwd) {
2377
2578
 
2378
2579
  function parserOptionsForConfig(config, filePath, cwd) {
2379
2580
  const configData = configDataFromConfig(config, filePath, cwd);
2380
- return {
2581
+ const languageOptions = configData.languageOptions ?? {};
2582
+ const parserOptions = {
2381
2583
  ...(configData.parserOptions ?? {}),
2382
- ...(configData.languageOptions?.parserOptions ?? {}),
2584
+ ...(languageOptions.parserOptions ?? {})
2585
+ };
2586
+ return {
2587
+ ...parserOptions,
2588
+ sourceType: languageOptions.sourceType ?? parserOptions.sourceType,
2589
+ ecmaVersion: languageOptions.ecmaVersion ?? parserOptions.ecmaVersion,
2590
+ ecmaFeatures: parserOptions.ecmaFeatures ?? {},
2591
+ globals: { ...(configData.globals ?? {}), ...(languageOptions.globals ?? {}) },
2383
2592
  filename: filePath,
2384
2593
  filePath,
2385
2594
  cwd
@@ -2425,13 +2634,176 @@ function pluginRulesForConfig(config, filePath, cwd) {
2425
2634
  continue;
2426
2635
  }
2427
2636
  for (const [ruleName, rule] of Object.entries(plugin.rules)) {
2428
- rules.set(`${pluginName}/${ruleName}`, rule);
2637
+ const ruleId = `${pluginName}/${ruleName}`;
2638
+ // A native rule takes precedence over a plugin rule mounted under the
2639
+ // same id, so a config copied from ESLint keeps its native speed.
2640
+ if (NATIVE_RULE_ID_SET.has(ruleId)) {
2641
+ continue;
2642
+ }
2643
+ rules.set(ruleId, rule);
2429
2644
  }
2430
2645
  }
2431
2646
  }
2432
2647
  return rules;
2433
2648
  }
2434
2649
 
2650
+ const pluginRuleIdsByConfig = new WeakMap();
2651
+
2652
+ function jsPluginRuleIdsFromConfigs(...configs) {
2653
+ const ids = new Set();
2654
+ for (const config of configs) {
2655
+ if (!config || typeof config !== "object") {
2656
+ continue;
2657
+ }
2658
+ let ruleIds = pluginRuleIdsByConfig.get(config);
2659
+ if (!ruleIds) {
2660
+ ruleIds = [...pluginRulesForConfig(config).keys()];
2661
+ pluginRuleIdsByConfig.set(config, ruleIds);
2662
+ }
2663
+ for (const ruleId of ruleIds) {
2664
+ ids.add(ruleId);
2665
+ }
2666
+ }
2667
+ return ids;
2668
+ }
2669
+
2670
+ function withoutJsPluginRules(rules, jsPluginRuleIds) {
2671
+ if (!rules || typeof rules !== "object" || jsPluginRuleIds.size === 0) {
2672
+ return rules;
2673
+ }
2674
+ const filtered = {};
2675
+ for (const [ruleId, ruleConfig] of Object.entries(rules)) {
2676
+ if (!jsPluginRuleIds.has(ruleId)) {
2677
+ filtered[ruleId] = ruleConfig;
2678
+ }
2679
+ }
2680
+ return filtered;
2681
+ }
2682
+
2683
+ function configDeclaresPlugins(config) {
2684
+ if (Array.isArray(config)) {
2685
+ return config.some((entry) => configDeclaresPlugins(entry));
2686
+ }
2687
+ return Boolean(
2688
+ config
2689
+ && typeof config === "object"
2690
+ && config.plugins
2691
+ && typeof config.plugins === "object"
2692
+ && !Array.isArray(config.plugins)
2693
+ && Object.keys(config.plugins).length > 0
2694
+ );
2695
+ }
2696
+
2697
+ // Cheap pre-check so runs without plugins skip the per-file plugin work. Every
2698
+ // config the native run consulted was read through `options.configCache`.
2699
+ function anyConfigDeclaresPlugins(options) {
2700
+ if (configDeclaresPlugins(options.baseConfig) || configDeclaresPlugins(options.overrideConfig)) {
2701
+ return true;
2702
+ }
2703
+ if (options.noConfig) {
2704
+ return false;
2705
+ }
2706
+ let sawConfig = false;
2707
+ for (const config of options.configCache?.values() ?? []) {
2708
+ if (config && typeof config === "object") {
2709
+ sawConfig = true;
2710
+ if (configDeclaresPlugins(config)) {
2711
+ return true;
2712
+ }
2713
+ }
2714
+ }
2715
+ if (sawConfig) {
2716
+ return false;
2717
+ }
2718
+ const configPath = configPathForOptions(options);
2719
+ return Boolean(configPath) && configDeclaresPlugins(readConfig(configPath, options.cwd, options.configCache));
2720
+ }
2721
+
2722
+ // The native engine receives the JSON form of a config module. Plugin objects
2723
+ // (and their `create()` functions) only survive an in-process import, so a
2724
+ // config module that mounts plugins is loaded again in this process.
2725
+ function liveConfigForPath(configPath, options) {
2726
+ const config = readConfig(configPath, options.cwd, options.configCache);
2727
+ if (!isExecutableConfigPath(configPath) || !configDeclaresPlugins(config)) {
2728
+ return config;
2729
+ }
2730
+ return configModule.readConfigModule(configPath, options.cwd);
2731
+ }
2732
+
2733
+ function pluginConfigPathForFile(options, matchPath, perFileConfig = true) {
2734
+ if (options.noConfig) {
2735
+ return undefined;
2736
+ }
2737
+ return perFileConfig && matchPath ? configPathForFile(options, matchPath) : configPathForOptions(options);
2738
+ }
2739
+
2740
+ function pluginConfigSourcesForFile(options, matchPath, perFileConfig = true) {
2741
+ const cwd = options.cwd ?? process.cwd();
2742
+ const sources = [];
2743
+ if (options.baseConfig) {
2744
+ sources.push({ config: options.baseConfig, cwd });
2745
+ }
2746
+ const configPath = pluginConfigPathForFile(options, matchPath, perFileConfig);
2747
+ if (configPath) {
2748
+ sources.push({ config: liveConfigForPath(configPath, options), cwd: dirname(configPath) });
2749
+ }
2750
+ if (options.overrideConfig) {
2751
+ sources.push({ config: options.overrideConfig, cwd });
2752
+ }
2753
+ return sources;
2754
+ }
2755
+
2756
+ function jsPluginRuleIdsForOptions(options, matchPath) {
2757
+ const configs = [options.baseConfig, options.overrideConfig];
2758
+ if (!options.noConfig) {
2759
+ const configPath = matchPath ? configPathForFile(options, matchPath) : configPathForOptions(options);
2760
+ if (configPath) {
2761
+ configs.push(readConfig(configPath, options.cwd, options.configCache));
2762
+ }
2763
+ }
2764
+ return jsPluginRuleIdsFromConfigs(...configs);
2765
+ }
2766
+
2767
+ function jsPluginRuleMapForFile(options, matchPath, perFileConfig = true) {
2768
+ const rules = new Map();
2769
+ for (const { config, cwd } of pluginConfigSourcesForFile(options, matchPath, perFileConfig)) {
2770
+ for (const [ruleId, rule] of pluginRulesForConfig(config, matchPath, cwd)) {
2771
+ rules.set(ruleId, rule);
2772
+ }
2773
+ }
2774
+ return rules;
2775
+ }
2776
+
2777
+ function pluginRuleEntriesForFile(options, matchPath, ruleMap, perFileConfig = true) {
2778
+ const entries = new Map();
2779
+ for (const { config, cwd } of pluginConfigSourcesForFile(options, matchPath, perFileConfig)) {
2780
+ for (const [ruleId, ruleConfig] of Object.entries(rulesFromConfig(config, matchPath, cwd))) {
2781
+ if (!ruleMap.has(ruleId)) {
2782
+ continue;
2783
+ }
2784
+ const severity = ruleConfigSeverity(ruleConfig);
2785
+ if (severity === 0) {
2786
+ entries.delete(ruleId);
2787
+ continue;
2788
+ }
2789
+ entries.set(ruleId, {
2790
+ ruleId,
2791
+ rule: ruleMap.get(ruleId),
2792
+ severity,
2793
+ options: Array.isArray(ruleConfig) ? ruleConfig.slice(1) : []
2794
+ });
2795
+ }
2796
+ }
2797
+ if (options.rules) {
2798
+ // `--rules` disables everything first and then enables only the listed
2799
+ // rules, keeping configured severities and options like the native path.
2800
+ return Object.keys(rulesFromNativeRuleList(options.rules))
2801
+ .filter((ruleId) => ruleMap.has(ruleId))
2802
+ .map((ruleId) => entries.get(ruleId) ?? { ruleId, rule: ruleMap.get(ruleId), severity: 1, options: [] });
2803
+ }
2804
+ return [...entries.values()];
2805
+ }
2806
+
2435
2807
  function matchingConfigEntries(config, filePath = undefined, cwd = undefined) {
2436
2808
  if (!config) {
2437
2809
  return [];
@@ -2483,7 +2855,7 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
2483
2855
  }
2484
2856
  let result = results.find((item) => item.filePath === filePath);
2485
2857
  const effectiveCode = typeof result?.output === "string" ? result.output : code;
2486
- const sourceCode = createLinterSourceCode(effectiveCode);
2858
+ const sourceCode = createLinterSourceCode(effectiveCode, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
2487
2859
  const messages = runCustomLinterRules(customRules, sourceCode, {
2488
2860
  cwd: options.cwd,
2489
2861
  filename: filePath,
@@ -2498,13 +2870,74 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
2498
2870
  result = emptyESLintResult(filePath, effectiveCode);
2499
2871
  results.push(result);
2500
2872
  }
2501
- result.messages.push(...filtered.messages);
2873
+ if (relintAfterCustomFixes(result, effectiveCode, filePath, customRules, config, rules, options)) {
2874
+ return results;
2875
+ }
2876
+ result.messages = sortLintMessages([...result.messages, ...filtered.messages]);
2502
2877
  result.suppressedMessages.push(...filtered.suppressedMessages);
2503
2878
  applyResultFixes(result, effectiveCode, options);
2504
2879
  finalizeESLintResult(result);
2505
2880
  return results;
2506
2881
  }
2507
2882
 
2883
+ // When custom fixes change a file, the native messages computed earlier are
2884
+ // stale. Re-lint the fixed text with native rules and custom rules together
2885
+ // until neither changes it, then rebuild the result from that final pass.
2886
+ function relintAfterCustomFixes(result, code, filePath, customRules, config, rules, options) {
2887
+ if (!options.fix) {
2888
+ return false;
2889
+ }
2890
+ const nativeOptions = {
2891
+ ...lintOptionsWithoutCustomRules(options, rules),
2892
+ filePath,
2893
+ filename: undefined,
2894
+ fix: true,
2895
+ useDefaultConfigTargets: undefined,
2896
+ pluginTextFilePath: undefined
2897
+ };
2898
+ const parserOptions = { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true };
2899
+ const customLintOptions = {
2900
+ cwd: options.cwd,
2901
+ filename: filePath,
2902
+ config: calculatedConfig({ cwd: options.cwd, noConfig: true, baseConfig: options.baseConfig, overrideConfig: options.overrideConfig }, filePath)
2903
+ };
2904
+ const lintCustom = (text) => {
2905
+ const sourceCode = createLinterSourceCode(text, null, parserOptions);
2906
+ return applyDisableDirectives(runCustomLinterRules(customRules, sourceCode, customLintOptions), sourceCode);
2907
+ };
2908
+
2909
+ let output = code;
2910
+ let custom = lintCustom(output);
2911
+ let fixedOutput = applyMessageFixes(output, custom.messages);
2912
+ if (fixedOutput === output) {
2913
+ return false;
2914
+ }
2915
+ let nativeReport;
2916
+ for (let pass = 0; pass < MAX_AUTOFIX_PASSES && fixedOutput !== output; pass += 1) {
2917
+ nativeReport = lintText(fixedOutput, nativeOptions);
2918
+ output = nativeReport.outputs?.find((fixed) => fixed.filePath === filePath)?.output ?? fixedOutput;
2919
+ custom = lintCustom(output);
2920
+ fixedOutput = applyMessageFixes(output, custom.messages);
2921
+ }
2922
+ if (output !== fixedOutput) {
2923
+ output = fixedOutput;
2924
+ nativeReport = lintText(output, nativeOptions);
2925
+ custom = lintCustom(output);
2926
+ }
2927
+
2928
+ const nativeResult = reportToESLintResults(nativeReport, {
2929
+ source: result.source,
2930
+ filePath,
2931
+ includeEmptyTextResult: true,
2932
+ ruleSeverityForFile: (path) => ruleSeverityMapForOptions(nativeOptions, path)
2933
+ })[0];
2934
+ result.messages = sortLintMessages([...(nativeResult?.messages ?? []), ...custom.messages]);
2935
+ result.suppressedMessages = [...(nativeResult?.suppressedMessages ?? []), ...custom.suppressedMessages];
2936
+ result.output = output;
2937
+ finalizeESLintResult(result);
2938
+ return true;
2939
+ }
2940
+
2508
2941
  function appendCustomLintFileMessages(results, config, options) {
2509
2942
  for (const result of results) {
2510
2943
  const filePath = result.filePath;
@@ -2521,7 +2954,7 @@ function appendCustomLintFileMessages(results, config, options) {
2521
2954
  continue;
2522
2955
  }
2523
2956
  }
2524
- const sourceCode = createLinterSourceCode(code);
2957
+ const sourceCode = createLinterSourceCode(code, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
2525
2958
  const messages = runCustomLinterRules(customRules, sourceCode, {
2526
2959
  cwd: options.cwd,
2527
2960
  filename: filePath,
@@ -2531,7 +2964,10 @@ function appendCustomLintFileMessages(results, config, options) {
2531
2964
  if (filtered.messages.length === 0 && filtered.suppressedMessages.length === 0) {
2532
2965
  continue;
2533
2966
  }
2534
- result.messages.push(...filtered.messages);
2967
+ if (relintAfterCustomFixes(result, code, filePath, customRules, config, rules, options)) {
2968
+ continue;
2969
+ }
2970
+ result.messages = sortLintMessages([...result.messages, ...filtered.messages]);
2535
2971
  result.suppressedMessages.push(...filtered.suppressedMessages);
2536
2972
  applyResultFixes(result, code, options);
2537
2973
  finalizeESLintResult(result);
@@ -2539,11 +2975,22 @@ function appendCustomLintFileMessages(results, config, options) {
2539
2975
  return results;
2540
2976
  }
2541
2977
 
2978
+ // ESLint orders a file's messages by position; native and plugin messages
2979
+ // arrive as two separate batches.
2980
+ function sortLintMessages(messages) {
2981
+ return messages
2982
+ .map((message, index) => ({ message, index }))
2983
+ .sort((left, right) => (left.message.line ?? 0) - (right.message.line ?? 0)
2984
+ || (left.message.column ?? 0) - (right.message.column ?? 0)
2985
+ || left.index - right.index)
2986
+ .map(({ message }) => message);
2987
+ }
2988
+
2542
2989
  function applyResultFixes(result, code, options) {
2543
2990
  if (!options.fix) {
2544
2991
  return;
2545
2992
  }
2546
- const output = applyRuleFixes(code, result.messages.flatMap((message) => ruleFixItems(message.fix)));
2993
+ const output = applyMessageFixes(code, result.messages);
2547
2994
  if (output !== code) {
2548
2995
  result.output = output;
2549
2996
  }
@@ -2556,14 +3003,39 @@ function runCustomLinterRules(ruleEntries, sourceCode, options) {
2556
3003
 
2557
3004
  const messages = [];
2558
3005
  const program = sourceCode.ast ?? { type: "Program", range: [0, sourceCode.text.length] };
3006
+ const hasAst = Array.isArray(program.body);
3007
+ const listenersBySelector = new Map();
2559
3008
  for (const ruleEntry of ruleEntries) {
3009
+ if (typeof ruleEntry.rule !== "function" && typeof ruleEntry.rule?.create !== "function") {
3010
+ throw new Error(
3011
+ `utoo-lint: rule "${ruleEntry.ruleId}" has no create() function. ` +
3012
+ "ESLint plugins must be mounted from a utlint.config.ts (or .js) module so their rule objects are available at lint time."
3013
+ );
3014
+ }
2560
3015
  const context = createCustomRuleContext(ruleEntry, sourceCode, options, messages);
2561
3016
  const listeners = customRuleListeners(ruleEntry.rule, context);
2562
- if (customRuleChildNodes(program, sourceCode.visitorKeys).length > 0) {
2563
- traverseCustomRuleAst(program, listeners, context, sourceCode.visitorKeys);
2564
- } else {
3017
+ if (!hasAst) {
2565
3018
  runCustomRuleTokenListeners(program, sourceCode.tokens, listeners, context);
3019
+ continue;
2566
3020
  }
3021
+ for (const [selector, listener] of Object.entries(listeners)) {
3022
+ if (typeof listener !== "function") {
3023
+ continue;
3024
+ }
3025
+ if (!listenersBySelector.has(selector)) {
3026
+ listenersBySelector.set(selector, []);
3027
+ }
3028
+ listenersBySelector.get(selector).push({
3029
+ ruleId: ruleEntry.ruleId,
3030
+ listener(node) {
3031
+ context.setCurrentNode(node);
3032
+ listener(node);
3033
+ }
3034
+ });
3035
+ }
3036
+ }
3037
+ if (hasAst && listenersBySelector.size > 0) {
3038
+ pluginRunner.runRuleListeners(program, listenersBySelector, { visitorKeys: sourceCode.visitorKeys });
2567
3039
  }
2568
3040
  return messages;
2569
3041
  }
@@ -2720,231 +3192,6 @@ function disableDirectiveMatchesRule(directive, ruleId) {
2720
3192
  return directive.ruleId == null || directive.ruleId === ruleId;
2721
3193
  }
2722
3194
 
2723
- function traverseCustomRuleAst(node, listeners, context, visitorKeys, seen = new Set(), ancestors = [], siblings = {}) {
2724
- if (!node || typeof node !== "object" || typeof node.type !== "string" || seen.has(node)) {
2725
- return;
2726
- }
2727
- seen.add(node);
2728
- for (const listener of customRuleMatchingListeners(listeners, node, false, ancestors, siblings, visitorKeys)) {
2729
- context.setCurrentNode(node);
2730
- listener(node);
2731
- }
2732
- const children = customRuleChildNodes(node, visitorKeys);
2733
- for (const [index, child] of children.entries()) {
2734
- traverseCustomRuleAst(child, listeners, context, visitorKeys, seen, [...ancestors, node], {
2735
- previous: children[index - 1] ?? null,
2736
- previousAll: children.slice(0, index)
2737
- });
2738
- }
2739
- for (const listener of customRuleMatchingListeners(listeners, node, true, ancestors, siblings, visitorKeys)) {
2740
- context.setCurrentNode(node);
2741
- listener(node);
2742
- }
2743
- }
2744
-
2745
- function customRuleMatchingListeners(listeners, node, exit, ancestors = [], siblings = {}, visitorKeys = null) {
2746
- const matches = [];
2747
- for (const [selector, listener] of Object.entries(listeners)) {
2748
- if (typeof listener !== "function" || !customRuleSelectorMatches(selector, node, exit, ancestors, siblings, visitorKeys)) {
2749
- continue;
2750
- }
2751
- matches.push(listener);
2752
- }
2753
- return matches;
2754
- }
2755
-
2756
- function customRuleSelectorMatches(selector, node, exit, ancestors = [], siblings = {}, visitorKeys = null) {
2757
- const suffix = ":exit";
2758
- const isExit = selector.endsWith(suffix);
2759
- if (isExit !== exit) {
2760
- return false;
2761
- }
2762
- const expression = isExit ? selector.slice(0, -suffix.length) : selector;
2763
- const childSelector = customRuleSplitTopLevelSelector(expression, ">");
2764
- if (childSelector) {
2765
- const parent = ancestors.at(-1);
2766
- return Boolean(parent)
2767
- && customRuleSimpleSelectorMatches(childSelector[0].trim(), parent, visitorKeys)
2768
- && customRuleSimpleSelectorMatches(childSelector[1].trim(), node, visitorKeys);
2769
- }
2770
- const adjacentSelector = customRuleSplitTopLevelSelector(expression, "+");
2771
- if (adjacentSelector) {
2772
- return Boolean(siblings.previous)
2773
- && customRuleSimpleSelectorMatches(adjacentSelector[0].trim(), siblings.previous, visitorKeys)
2774
- && customRuleSimpleSelectorMatches(adjacentSelector[1].trim(), node, visitorKeys);
2775
- }
2776
- const siblingSelector = customRuleSplitTopLevelSelector(expression, "~");
2777
- if (siblingSelector) {
2778
- return (siblings.previousAll ?? []).some((sibling) => customRuleSimpleSelectorMatches(siblingSelector[0].trim(), sibling, visitorKeys))
2779
- && customRuleSimpleSelectorMatches(siblingSelector[1].trim(), node, visitorKeys);
2780
- }
2781
- const descendantSelector = customRuleSplitTopLevelDescendantSelector(expression);
2782
- if (descendantSelector) {
2783
- return ancestors.some((ancestor) => customRuleSimpleSelectorMatches(descendantSelector[0].trim(), ancestor, visitorKeys))
2784
- && customRuleSimpleSelectorMatches(descendantSelector[1].trim(), node, visitorKeys);
2785
- }
2786
- return customRuleSimpleSelectorMatches(expression, node, visitorKeys);
2787
- }
2788
-
2789
- function customRuleSimpleSelectorMatches(expression, node, visitorKeys = null) {
2790
- const hasSelector = expression.match(/^(.+?):has\((.+)\)$/u);
2791
- if (hasSelector) {
2792
- return customRuleSimpleSelectorMatches(hasSelector[1].trim(), node, visitorKeys)
2793
- && customRuleDescendants(node, visitorKeys).some((descendant) => (
2794
- customRuleSplitSelectorList(hasSelector[2]).some((selector) => customRuleSimpleSelectorMatches(selector.trim(), descendant, visitorKeys))
2795
- ));
2796
- }
2797
- const notSelector = expression.match(/^(.+?):not\((.+)\)$/u);
2798
- if (notSelector) {
2799
- return customRuleSimpleSelectorMatches(notSelector[1].trim(), node, visitorKeys)
2800
- && !customRuleSimpleSelectorMatches(notSelector[2].trim(), node, visitorKeys);
2801
- }
2802
- const matchesSelector = expression.match(/^(.*?):matches\((.+)\)$/u);
2803
- if (matchesSelector) {
2804
- const prefix = matchesSelector[1].trim();
2805
- if (prefix && !customRuleSimpleSelectorMatches(prefix, node, visitorKeys)) {
2806
- return false;
2807
- }
2808
- return customRuleSplitSelectorList(matchesSelector[2]).some((selector) => (
2809
- customRuleSimpleSelectorMatches(selector.trim(), node, visitorKeys)
2810
- ));
2811
- }
2812
- if (expression === node.type) {
2813
- return true;
2814
- }
2815
- const match = expression.match(/^([A-Za-z_$][\w$-]*|\*)?(?:\[([^\]]+)\])?$/u);
2816
- if (!match) {
2817
- return false;
2818
- }
2819
- const [, type = "*", attribute] = match;
2820
- if (type !== "*" && type !== node.type) {
2821
- return false;
2822
- }
2823
- return attribute ? customRuleAttributeSelectorMatches(node, attribute.trim()) : type === "*" || type === node.type;
2824
- }
2825
-
2826
- function customRuleDescendants(node, visitorKeys, seen = new Set()) {
2827
- const descendants = [];
2828
- for (const child of customRuleChildNodes(node, visitorKeys)) {
2829
- if (seen.has(child)) {
2830
- continue;
2831
- }
2832
- seen.add(child);
2833
- descendants.push(child, ...customRuleDescendants(child, visitorKeys, seen));
2834
- }
2835
- return descendants;
2836
- }
2837
-
2838
- function customRuleSplitTopLevelSelector(expression, separator) {
2839
- let state = customRuleSelectorScanState();
2840
- for (let index = 0; index < expression.length; index += 1) {
2841
- state = customRuleUpdateSelectorScanState(state, expression[index]);
2842
- if (state.depth === 0 && !state.quote && expression[index] === separator) {
2843
- return [expression.slice(0, index), expression.slice(index + 1)];
2844
- }
2845
- }
2846
- return null;
2847
- }
2848
-
2849
- function customRuleSplitTopLevelDescendantSelector(expression) {
2850
- let state = customRuleSelectorScanState();
2851
- for (let index = 0; index < expression.length; index += 1) {
2852
- state = customRuleUpdateSelectorScanState(state, expression[index]);
2853
- if (state.depth === 0 && !state.quote && /\s/u.test(expression[index])) {
2854
- const left = expression.slice(0, index).trim();
2855
- const right = expression.slice(index).trim();
2856
- return left && right ? [left, right] : null;
2857
- }
2858
- }
2859
- return null;
2860
- }
2861
-
2862
- function customRuleSplitSelectorList(value) {
2863
- const selectors = [];
2864
- let state = customRuleSelectorScanState();
2865
- let start = 0;
2866
- for (let index = 0; index < value.length; index += 1) {
2867
- state = customRuleUpdateSelectorScanState(state, value[index]);
2868
- if (state.depth === 0 && !state.quote && value[index] === ",") {
2869
- selectors.push(value.slice(start, index));
2870
- start = index + 1;
2871
- }
2872
- }
2873
- selectors.push(value.slice(start));
2874
- return selectors.filter((selector) => selector.trim());
2875
- }
2876
-
2877
- function customRuleSelectorScanState() {
2878
- return { depth: 0, quote: null, escaped: false };
2879
- }
2880
-
2881
- function customRuleUpdateSelectorScanState(state, char) {
2882
- if (state.escaped) {
2883
- return { ...state, escaped: false };
2884
- }
2885
- if (char === "\\") {
2886
- return { ...state, escaped: true };
2887
- }
2888
- if (state.quote) {
2889
- return char === state.quote ? { ...state, quote: null } : state;
2890
- }
2891
- if (char === "\"" || char === "'") {
2892
- return { ...state, quote: char };
2893
- }
2894
- if (char === "[" || char === "(") {
2895
- return { ...state, depth: state.depth + 1 };
2896
- }
2897
- if ((char === "]" || char === ")") && state.depth > 0) {
2898
- return { ...state, depth: state.depth - 1 };
2899
- }
2900
- return state;
2901
- }
2902
-
2903
- function customRuleAttributeSelectorMatches(node, attribute) {
2904
- const match = attribute.match(/^([\w$.-]+)\s*(!=|=)\s*(.+)$/u);
2905
- if (!match) {
2906
- return customRuleValueByPath(node, attribute) != null;
2907
- }
2908
- const [, path, operator, rawExpected] = match;
2909
- const actual = customRuleValueByPath(node, path);
2910
- const expected = customRuleSelectorValue(rawExpected);
2911
- return operator === "="
2912
- ? String(actual) === expected
2913
- : String(actual) !== expected;
2914
- }
2915
-
2916
- function customRuleSelectorValue(raw) {
2917
- const value = raw.trim();
2918
- const quote = value[0];
2919
- if ((quote === "\"" || quote === "'") && value.at(-1) === quote) {
2920
- return value.slice(1, -1);
2921
- }
2922
- return value;
2923
- }
2924
-
2925
- function customRuleValueByPath(node, path) {
2926
- return path.split(".").reduce((value, key) => (
2927
- value && typeof value === "object" ? value[key] : undefined
2928
- ), node);
2929
- }
2930
-
2931
- function customRuleChildNodes(node, visitorKeys) {
2932
- const keys = Array.isArray(visitorKeys?.[node.type])
2933
- ? visitorKeys[node.type]
2934
- : Object.keys(node).filter((key) => !AST_TRAVERSAL_SKIP_KEYS.has(key));
2935
- const children = [];
2936
- for (const key of keys) {
2937
- const value = node[key];
2938
- const values = Array.isArray(value) ? value : [value];
2939
- for (const child of values) {
2940
- if (child && typeof child === "object" && typeof child.type === "string") {
2941
- children.push(child);
2942
- }
2943
- }
2944
- }
2945
- return children;
2946
- }
2947
-
2948
3195
  function runCustomRuleTokenListeners(program, tokens, listeners, context) {
2949
3196
  if (typeof listeners.Program === "function") {
2950
3197
  context.setCurrentNode(program);
@@ -2979,7 +3226,7 @@ function createCustomRuleContext(ruleEntry, sourceCode, options, messages) {
2979
3226
  let currentNode = sourceCode.ast ?? null;
2980
3227
  const context = {
2981
3228
  id: ruleEntry.ruleId,
2982
- options: ruleEntry.options,
3229
+ options: pluginRunner.applyRuleOptionDefaults(ruleEntry.rule, ruleEntry.options),
2983
3230
  filename,
2984
3231
  physicalFilename: filename,
2985
3232
  cwd,
@@ -3197,7 +3444,7 @@ class SourceCode {
3197
3444
  this.text = textOrConfig.text;
3198
3445
  this.ast = textOrConfig.ast ?? null;
3199
3446
  this.parserServices = textOrConfig.parserServices ?? {};
3200
- this.scopeManager = textOrConfig.scopeManager ?? null;
3447
+ defineScopeManager(this, textOrConfig.scopeManager ?? null);
3201
3448
  this.visitorKeys = textOrConfig.visitorKeys ?? null;
3202
3449
  this.hasBOM = Boolean(textOrConfig.hasBOM);
3203
3450
  } else {
@@ -3389,7 +3636,17 @@ class SourceCode {
3389
3636
  }
3390
3637
 
3391
3638
  getAncestors(node) {
3392
- return node ? sourceAncestorsForNode(this.ast, node) ?? [] : [];
3639
+ if (!node) {
3640
+ return [];
3641
+ }
3642
+ if (node.parent !== undefined) {
3643
+ const ancestors = [];
3644
+ for (let current = node.parent; current; current = current.parent) {
3645
+ ancestors.unshift(current);
3646
+ }
3647
+ return ancestors;
3648
+ }
3649
+ return sourceAncestorsForNode(this.ast, node) ?? [];
3393
3650
  }
3394
3651
 
3395
3652
  getDeclaredVariables(node) {
@@ -3397,10 +3654,20 @@ class SourceCode {
3397
3654
  }
3398
3655
 
3399
3656
  getScope(node) {
3400
- if (node && typeof this.scopeManager?.acquire === "function") {
3401
- return this.scopeManager.acquire(node, true) ?? this.scopeManager.acquire(node, false) ?? this.scopeManager.globalScope ?? null;
3657
+ const scopeManager = this.scopeManager;
3658
+ if (!scopeManager || typeof scopeManager.acquire !== "function") {
3659
+ return null;
3660
+ }
3661
+ if (node) {
3662
+ const inner = node.type !== "Program";
3663
+ for (let current = node; current; current = current.parent) {
3664
+ const scope = scopeManager.acquire(current, inner);
3665
+ if (scope) {
3666
+ return scope.type === "function-expression-name" ? scope.childScopes[0] : scope;
3667
+ }
3668
+ }
3402
3669
  }
3403
- return this.scopeManager?.globalScope ?? null;
3670
+ return scopeManager.scopes?.[0] ?? scopeManager.globalScope ?? null;
3404
3671
  }
3405
3672
 
3406
3673
  markVariableAsUsed(name, node) {
@@ -3436,6 +3703,31 @@ class SourceCode {
3436
3703
  }
3437
3704
  }
3438
3705
 
3706
+ function defineScopeManager(sourceCode, scopeManager) {
3707
+ if (typeof scopeManager !== "function") {
3708
+ sourceCode.scopeManager = scopeManager;
3709
+ return;
3710
+ }
3711
+ // Scope analysis is comparatively expensive; run it only when a rule asks.
3712
+ let computed = false;
3713
+ let value = null;
3714
+ Object.defineProperty(sourceCode, "scopeManager", {
3715
+ configurable: true,
3716
+ enumerable: true,
3717
+ get() {
3718
+ if (!computed) {
3719
+ computed = true;
3720
+ value = scopeManager() ?? null;
3721
+ }
3722
+ return value;
3723
+ },
3724
+ set(next) {
3725
+ computed = true;
3726
+ value = next;
3727
+ }
3728
+ });
3729
+ }
3730
+
3439
3731
  function sourceLineStartIndices(text) {
3440
3732
  const indices = [0];
3441
3733
  for (let index = 0; index < text.length; index += 1) {
@@ -3725,12 +4017,10 @@ class RuleTester {
3725
4017
  RuleTester[testCase.only ? "itOnly" : "it"](testCase.name ?? testCase.code, () => {
3726
4018
  const config = ruleTesterConfig(ruleName, this.config, testCase, "error");
3727
4019
  const options = ruleTesterOptions(testCase);
3728
- const result = Object.hasOwn(testCase, "output")
3729
- ? linter.verifyAndFix(testCase.code, config, options)
3730
- : { messages: linter.verify(testCase.code, config, options), output: testCase.code };
3731
- const messages = result.messages;
4020
+ const messages = linter.verify(testCase.code, config, options);
3732
4021
  assertRuleTesterErrors(testCase, messages, rule);
3733
- assertRuleTesterOutput(testCase, result.output);
4022
+ // Like ESLint's RuleTester, `output` is the result of one autofix pass.
4023
+ assertRuleTesterOutput(testCase, Object.hasOwn(testCase, "output") ? applyMessageFixes(testCase.code, messages) : testCase.code);
3734
4024
  });
3735
4025
  }
3736
4026
  });
@@ -3877,6 +4167,47 @@ function applyRuleFixes(code, fix) {
3877
4167
  .reduce((output, item) => output.slice(0, item.range[0]) + item.text + output.slice(item.range[1]), code);
3878
4168
  }
3879
4169
 
4170
+ // Applies one non-overlapping fix per message, like ESLint's SourceCodeFixer.
4171
+ // Fixes that overlap an earlier one are left for a later pass.
4172
+ function applyMessageFixes(code, messages) {
4173
+ const fixes = [];
4174
+ for (const message of messages) {
4175
+ const items = ruleFixItems(message?.fix)
4176
+ .slice()
4177
+ .sort((left, right) => left.range[0] - right.range[0] || left.range[1] - right.range[1]);
4178
+ if (items.length === 0) {
4179
+ continue;
4180
+ }
4181
+ let text = "";
4182
+ let cursor = items[0].range[0];
4183
+ let valid = true;
4184
+ for (const item of items) {
4185
+ if (item.range[0] < cursor) {
4186
+ valid = false;
4187
+ break;
4188
+ }
4189
+ text += code.slice(cursor, item.range[0]) + item.text;
4190
+ cursor = item.range[1];
4191
+ }
4192
+ if (valid) {
4193
+ fixes.push({ range: [items[0].range[0], cursor], text });
4194
+ }
4195
+ }
4196
+ fixes.sort((left, right) => left.range[0] - right.range[0] || left.range[1] - right.range[1]);
4197
+ let output = "";
4198
+ let position = 0;
4199
+ let lastEnd = -Infinity;
4200
+ for (const fix of fixes) {
4201
+ if (lastEnd >= fix.range[0]) {
4202
+ continue;
4203
+ }
4204
+ output += code.slice(position, fix.range[0]) + fix.text;
4205
+ position = fix.range[1];
4206
+ lastEnd = fix.range[1];
4207
+ }
4208
+ return output + code.slice(position);
4209
+ }
4210
+
3880
4211
  function ruleFixItems(fix) {
3881
4212
  if (!fix) {
3882
4213
  return [];
@@ -3929,10 +4260,29 @@ function createLinterSourceCode(text, parser = null, parserOptions = {}) {
3929
4260
  });
3930
4261
  }
3931
4262
 
3932
- function parseSourceCode(text, parser, parserOptions) {
3933
- if (!parser) {
4263
+ function parseSourceCodeWithDefaultParser(text, parserOptions) {
4264
+ let parsed;
4265
+ try {
4266
+ parsed = estreeParser.parseForESLint(text, parserOptions);
4267
+ } catch (error) {
4268
+ if (parserOptions.requireParser) {
4269
+ throw error;
4270
+ }
3934
4271
  return null;
3935
4272
  }
4273
+ return new SourceCode({
4274
+ text,
4275
+ ast: parsed.ast,
4276
+ parserServices: parsed.services ?? {},
4277
+ scopeManager: parsed.scopeManager ?? null,
4278
+ visitorKeys: parsed.visitorKeys ?? null
4279
+ });
4280
+ }
4281
+
4282
+ function parseSourceCode(text, parser, parserOptions = {}) {
4283
+ if (!parser) {
4284
+ return parserOptions.tokensOnly ? null : parseSourceCodeWithDefaultParser(text, parserOptions);
4285
+ }
3936
4286
  if (typeof parser.parseForESLint === "function") {
3937
4287
  const result = parser.parseForESLint(text, parserOptions) ?? {};
3938
4288
  return new SourceCode({
@@ -4198,7 +4548,7 @@ function applyCompatibilityDisableDirectives(result, textOptions) {
4198
4548
  return;
4199
4549
  }
4200
4550
 
4201
- const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source));
4551
+ const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source, null, { tokensOnly: true }));
4202
4552
  result.messages = filtered.messages;
4203
4553
  result.suppressedMessages.push(...filtered.suppressedMessages);
4204
4554
  }
@@ -4932,6 +5282,12 @@ function diagnosticToESLintMessage(diagnostic, ruleSeverities) {
4932
5282
  column: diagnostic.column,
4933
5283
  nodeType: null
4934
5284
  };
5285
+ if (typeof diagnostic.endLine === "number") {
5286
+ message.endLine = diagnostic.endLine;
5287
+ }
5288
+ if (typeof diagnostic.endColumn === "number") {
5289
+ message.endColumn = diagnostic.endColumn;
5290
+ }
4935
5291
  if (diagnostic.endLine != null) {
4936
5292
  message.endLine = diagnostic.endLine;
4937
5293
  }