@utoo/lint 0.3.8 → 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;
@@ -397,6 +397,10 @@ const BUILTIN_RULE_IDS = [
397
397
  "react/void-dom-elements-no-children",
398
398
  "react-hooks/exhaustive-deps",
399
399
  "react-hooks/rules-of-hooks",
400
+ "react-hooks/use-memo",
401
+ "react-hooks/void-use-memo",
402
+ "react-hooks/set-state-in-render",
403
+ "react-hooks/purity",
400
404
  "unused-imports/no-unused-imports",
401
405
  "@typescript-eslint/adjacent-overload-signatures",
402
406
  "@typescript-eslint/array-type",
@@ -476,6 +480,7 @@ const NATIVE_ONLY_RULE_IDS = [
476
480
  "semantic-errors"
477
481
  ];
478
482
  const NATIVE_RULE_IDS = [...BUILTIN_RULE_IDS, ...NATIVE_ONLY_RULE_IDS];
483
+ const NATIVE_RULE_ID_SET = new Set(NATIVE_RULE_IDS);
479
484
  const FIXABLE_BUILTIN_RULE_IDS = new Set([
480
485
  "@typescript-eslint/no-explicit-any",
481
486
  "jest/no-deprecated-functions",
@@ -603,15 +608,15 @@ class UtooLint {
603
608
  throw new Error("'results' must be an array");
604
609
  }
605
610
  const options = eslintConstructorOptions(this.options);
606
- const customRuleConfig = [options.baseConfig, options.overrideConfig].filter(Boolean);
607
611
  const rulesByFilePath = new Map();
612
+ const cachedOptions = withConfigCache(options);
608
613
  return rulesMetaForResults(results, (ruleId, result) => {
609
- if (customRuleConfig.length === 0 || typeof result.filePath !== "string") {
614
+ if (typeof result.filePath !== "string") {
610
615
  return undefined;
611
616
  }
612
617
  const filePath = normalizeESLintFilePath(result.filePath, options.cwd);
613
618
  if (!rulesByFilePath.has(filePath)) {
614
- rulesByFilePath.set(filePath, customRuleMapForConfig(customRuleConfig, new Map(), filePath, options.cwd));
619
+ rulesByFilePath.set(filePath, jsPluginRuleMapForFile(cachedOptions, filePath));
615
620
  }
616
621
  return rulesByFilePath.get(filePath).get(ruleId)?.meta;
617
622
  });
@@ -1225,21 +1230,210 @@ function lintFiles(paths, options = {}) {
1225
1230
  }
1226
1231
 
1227
1232
  const nativeFlatOptions = nativeFlatConfigOptions(options);
1228
- if (nativeFlatOptions) {
1229
- const report = runNativeLintReport(lintPaths, nativeFlatOptions);
1230
- return finalizeLintReport(report, ignoredDiagnostics, options);
1231
- }
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
+ }
1232
1241
 
1242
+ function runNativeConfigRunReports(lintPaths, options) {
1233
1243
  const configRuns = nativeConfigRunsForFiles(expandNativeConfigRunPaths(lintPaths, options), options);
1234
1244
  if (configRuns) {
1235
- const report = mergeNativeReports(
1245
+ return mergeNativeReports(
1236
1246
  configRuns.map(({ paths: runPaths, options: runOptions }) => runNativeLintReport(runPaths, runOptions))
1237
1247
  );
1238
- return finalizeLintReport(report, ignoredDiagnostics, options);
1239
1248
  }
1240
1249
 
1241
- const report = runNativeLintReport(lintPaths, options);
1242
- 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;
1243
1437
  }
1244
1438
 
1245
1439
  function nativeFlatConfigOptions(options) {
@@ -1269,6 +1463,10 @@ function nativeFlatConfigOptions(options) {
1269
1463
  return undefined;
1270
1464
  }
1271
1465
 
1466
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(config);
1467
+ if (jsPluginRuleIds.size > 0) {
1468
+ config = configWithoutCustomRules(config, jsPluginRuleIds);
1469
+ }
1272
1470
  if (configCanUseNativeRulesFastPath(config)) {
1273
1471
  return undefined;
1274
1472
  }
@@ -1395,6 +1593,9 @@ function nativeConfigRunsForFiles(paths, options) {
1395
1593
  if (configured && options.rules) {
1396
1594
  rules = selectedRulesWithConfigOptions(rules, options.rules);
1397
1595
  }
1596
+ if (configured) {
1597
+ rules = withoutJsPluginRules(rules, jsPluginRuleIdsForOptions(options, matchPath));
1598
+ }
1398
1599
  const signature = configured ? stableConfigSignature({ rules, settings }) : "<native-defaults>";
1399
1600
  if (!groups.has(signature)) {
1400
1601
  groups.set(signature, { paths: [], rules, settings, configured });
@@ -1567,7 +1768,8 @@ function lintText(code, options = {}) {
1567
1768
  cwd: options.cwd,
1568
1769
  config: options.config ?? discoveredConfig,
1569
1770
  noConfig: options.noConfig,
1570
- deferDiagnosticConfigFiltering: true
1771
+ deferDiagnosticConfigFiltering: true,
1772
+ pluginTextFilePath: requestedPath
1571
1773
  });
1572
1774
  report.filePaths = (report.filePaths ?? []).map((filePath) => (filePath === tempFile ? requestedPath : filePath));
1573
1775
  report.outputs = (report.outputs ?? []).map((fixed) => ({
@@ -1609,11 +1811,12 @@ function buildNativeLintArgs(paths, options) {
1609
1811
  const configForRuleSelection = options.noConfig
1610
1812
  ? undefined
1611
1813
  : options.config ? readConfig(options.config, options.cwd, options.configCache) : undefined;
1814
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(configForRuleSelection, options.baseConfig, options.overrideConfig);
1612
1815
  const enabledRules = enabledRuleNamesFromConfigs(
1613
1816
  configForRuleSelection,
1614
1817
  options.baseConfig,
1615
1818
  options.overrideConfig
1616
- );
1819
+ ).filter((rule) => !jsPluginRuleIds.has(rule));
1617
1820
  if (enabledRules.length > 0) {
1618
1821
  cliArgs.push(`--rules=${enabledRules.join(",")}`);
1619
1822
  }
@@ -2091,9 +2294,11 @@ function withTemporaryConfig(options, callback) {
2091
2294
  ? readConfig(fileConfigPath, options.cwd, options.configCache)
2092
2295
  : undefined;
2093
2296
  const configs = [options.baseConfig, fileConfig, options.overrideConfig];
2094
- const rules = shouldMaterializeFileConfig
2095
- ? materializedRulesFromConfigs(...configs)
2096
- : runtimeRulesFromConfigs(...configs);
2297
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(...configs);
2298
+ const rules = withoutJsPluginRules(
2299
+ shouldMaterializeFileConfig ? materializedRulesFromConfigs(...configs) : runtimeRulesFromConfigs(...configs),
2300
+ jsPluginRuleIds
2301
+ );
2097
2302
  const settings = configs.reduce(
2098
2303
  (result, config) => ({
2099
2304
  ...result,
@@ -2112,7 +2317,7 @@ function withTemporaryConfig(options, callback) {
2112
2317
  }
2113
2318
  return callback(options);
2114
2319
  }
2115
- const enabledRules = enabledRuleNamesFromConfigs(...configs);
2320
+ const enabledRules = enabledRuleNamesFromConfigs(...configs).filter((rule) => !jsPluginRuleIds.has(rule));
2116
2321
  const hasExplicitOffRules = Object.values(rules).some((value) => ruleConfigSeverity(value) === 0);
2117
2322
  if (!shouldMaterializeFileConfig && !hasSettings && !hasRuleOptions(rules) && !hasExplicitOffRules && !options.forceMaterializedConfig) {
2118
2323
  return callback({
@@ -2241,14 +2446,14 @@ class Linter {
2241
2446
  const verifyOptions = typeof options === "string" ? { filename: options } : { ...options };
2242
2447
  const filePath = verifyOptions.filename ?? verifyOptions.filePath ?? "input.js";
2243
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);
2244
2452
  const sourceCode = createLinterSourceCode(
2245
2453
  code,
2246
2454
  parserForConfig(config, this.parsers, normalizedFilePath, verifyOptions.cwd),
2247
- parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd)
2455
+ { ...parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd), requireParser: customRules.length > 0 }
2248
2456
  );
2249
- const customRuleMap = customRuleMapForConfig(config, this.rules, normalizedFilePath, verifyOptions.cwd);
2250
- const customRuleFilterMap = customRuleMapForConfig(config, this.rules);
2251
- const customRules = customRuleEntriesForConfig(config, customRuleMap, normalizedFilePath, verifyOptions.cwd);
2252
2457
  const nativeConfig = configWithoutCustomRules(config, customRuleFilterMap);
2253
2458
  const lintOptions = {
2254
2459
  cwd: verifyOptions.cwd,
@@ -2278,10 +2483,10 @@ class Linter {
2278
2483
  ];
2279
2484
  this.times = { passes: [] };
2280
2485
  this.fixPassCount = 0;
2281
- return [
2486
+ return sortLintMessages([
2282
2487
  ...nativeRuleFilter.messages,
2283
2488
  ...customRuleFilter.messages
2284
- ];
2489
+ ]);
2285
2490
  }
2286
2491
 
2287
2492
  verifyAndFix(code, config = {}, options = {}) {
@@ -2290,7 +2495,7 @@ class Linter {
2290
2495
  let fixPassCount = 0;
2291
2496
  for (let pass = 0; pass < MAX_AUTOFIX_PASSES; pass += 1) {
2292
2497
  const messages = this.verify(output, config, options);
2293
- const fixedOutput = applyRuleFixes(output, messages.flatMap((message) => ruleFixItems(message.fix)));
2498
+ const fixedOutput = applyMessageFixes(output, messages);
2294
2499
  if (fixedOutput === output) {
2295
2500
  this.fixPassCount = fixPassCount;
2296
2501
  return {
@@ -2373,9 +2578,17 @@ function parserForConfig(config, parsers, filePath, cwd) {
2373
2578
 
2374
2579
  function parserOptionsForConfig(config, filePath, cwd) {
2375
2580
  const configData = configDataFromConfig(config, filePath, cwd);
2376
- return {
2581
+ const languageOptions = configData.languageOptions ?? {};
2582
+ const parserOptions = {
2377
2583
  ...(configData.parserOptions ?? {}),
2378
- ...(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 ?? {}) },
2379
2592
  filename: filePath,
2380
2593
  filePath,
2381
2594
  cwd
@@ -2421,13 +2634,176 @@ function pluginRulesForConfig(config, filePath, cwd) {
2421
2634
  continue;
2422
2635
  }
2423
2636
  for (const [ruleName, rule] of Object.entries(plugin.rules)) {
2424
- 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);
2425
2644
  }
2426
2645
  }
2427
2646
  }
2428
2647
  return rules;
2429
2648
  }
2430
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
+
2431
2807
  function matchingConfigEntries(config, filePath = undefined, cwd = undefined) {
2432
2808
  if (!config) {
2433
2809
  return [];
@@ -2479,7 +2855,7 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
2479
2855
  }
2480
2856
  let result = results.find((item) => item.filePath === filePath);
2481
2857
  const effectiveCode = typeof result?.output === "string" ? result.output : code;
2482
- const sourceCode = createLinterSourceCode(effectiveCode);
2858
+ const sourceCode = createLinterSourceCode(effectiveCode, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
2483
2859
  const messages = runCustomLinterRules(customRules, sourceCode, {
2484
2860
  cwd: options.cwd,
2485
2861
  filename: filePath,
@@ -2494,13 +2870,74 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
2494
2870
  result = emptyESLintResult(filePath, effectiveCode);
2495
2871
  results.push(result);
2496
2872
  }
2497
- 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]);
2498
2877
  result.suppressedMessages.push(...filtered.suppressedMessages);
2499
2878
  applyResultFixes(result, effectiveCode, options);
2500
2879
  finalizeESLintResult(result);
2501
2880
  return results;
2502
2881
  }
2503
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
+
2504
2941
  function appendCustomLintFileMessages(results, config, options) {
2505
2942
  for (const result of results) {
2506
2943
  const filePath = result.filePath;
@@ -2517,7 +2954,7 @@ function appendCustomLintFileMessages(results, config, options) {
2517
2954
  continue;
2518
2955
  }
2519
2956
  }
2520
- const sourceCode = createLinterSourceCode(code);
2957
+ const sourceCode = createLinterSourceCode(code, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
2521
2958
  const messages = runCustomLinterRules(customRules, sourceCode, {
2522
2959
  cwd: options.cwd,
2523
2960
  filename: filePath,
@@ -2527,7 +2964,10 @@ function appendCustomLintFileMessages(results, config, options) {
2527
2964
  if (filtered.messages.length === 0 && filtered.suppressedMessages.length === 0) {
2528
2965
  continue;
2529
2966
  }
2530
- 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]);
2531
2971
  result.suppressedMessages.push(...filtered.suppressedMessages);
2532
2972
  applyResultFixes(result, code, options);
2533
2973
  finalizeESLintResult(result);
@@ -2535,11 +2975,22 @@ function appendCustomLintFileMessages(results, config, options) {
2535
2975
  return results;
2536
2976
  }
2537
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
+
2538
2989
  function applyResultFixes(result, code, options) {
2539
2990
  if (!options.fix) {
2540
2991
  return;
2541
2992
  }
2542
- const output = applyRuleFixes(code, result.messages.flatMap((message) => ruleFixItems(message.fix)));
2993
+ const output = applyMessageFixes(code, result.messages);
2543
2994
  if (output !== code) {
2544
2995
  result.output = output;
2545
2996
  }
@@ -2552,14 +3003,39 @@ function runCustomLinterRules(ruleEntries, sourceCode, options) {
2552
3003
 
2553
3004
  const messages = [];
2554
3005
  const program = sourceCode.ast ?? { type: "Program", range: [0, sourceCode.text.length] };
3006
+ const hasAst = Array.isArray(program.body);
3007
+ const listenersBySelector = new Map();
2555
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
+ }
2556
3015
  const context = createCustomRuleContext(ruleEntry, sourceCode, options, messages);
2557
3016
  const listeners = customRuleListeners(ruleEntry.rule, context);
2558
- if (customRuleChildNodes(program, sourceCode.visitorKeys).length > 0) {
2559
- traverseCustomRuleAst(program, listeners, context, sourceCode.visitorKeys);
2560
- } else {
3017
+ if (!hasAst) {
2561
3018
  runCustomRuleTokenListeners(program, sourceCode.tokens, listeners, context);
3019
+ continue;
2562
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 });
2563
3039
  }
2564
3040
  return messages;
2565
3041
  }
@@ -2716,231 +3192,6 @@ function disableDirectiveMatchesRule(directive, ruleId) {
2716
3192
  return directive.ruleId == null || directive.ruleId === ruleId;
2717
3193
  }
2718
3194
 
2719
- function traverseCustomRuleAst(node, listeners, context, visitorKeys, seen = new Set(), ancestors = [], siblings = {}) {
2720
- if (!node || typeof node !== "object" || typeof node.type !== "string" || seen.has(node)) {
2721
- return;
2722
- }
2723
- seen.add(node);
2724
- for (const listener of customRuleMatchingListeners(listeners, node, false, ancestors, siblings, visitorKeys)) {
2725
- context.setCurrentNode(node);
2726
- listener(node);
2727
- }
2728
- const children = customRuleChildNodes(node, visitorKeys);
2729
- for (const [index, child] of children.entries()) {
2730
- traverseCustomRuleAst(child, listeners, context, visitorKeys, seen, [...ancestors, node], {
2731
- previous: children[index - 1] ?? null,
2732
- previousAll: children.slice(0, index)
2733
- });
2734
- }
2735
- for (const listener of customRuleMatchingListeners(listeners, node, true, ancestors, siblings, visitorKeys)) {
2736
- context.setCurrentNode(node);
2737
- listener(node);
2738
- }
2739
- }
2740
-
2741
- function customRuleMatchingListeners(listeners, node, exit, ancestors = [], siblings = {}, visitorKeys = null) {
2742
- const matches = [];
2743
- for (const [selector, listener] of Object.entries(listeners)) {
2744
- if (typeof listener !== "function" || !customRuleSelectorMatches(selector, node, exit, ancestors, siblings, visitorKeys)) {
2745
- continue;
2746
- }
2747
- matches.push(listener);
2748
- }
2749
- return matches;
2750
- }
2751
-
2752
- function customRuleSelectorMatches(selector, node, exit, ancestors = [], siblings = {}, visitorKeys = null) {
2753
- const suffix = ":exit";
2754
- const isExit = selector.endsWith(suffix);
2755
- if (isExit !== exit) {
2756
- return false;
2757
- }
2758
- const expression = isExit ? selector.slice(0, -suffix.length) : selector;
2759
- const childSelector = customRuleSplitTopLevelSelector(expression, ">");
2760
- if (childSelector) {
2761
- const parent = ancestors.at(-1);
2762
- return Boolean(parent)
2763
- && customRuleSimpleSelectorMatches(childSelector[0].trim(), parent, visitorKeys)
2764
- && customRuleSimpleSelectorMatches(childSelector[1].trim(), node, visitorKeys);
2765
- }
2766
- const adjacentSelector = customRuleSplitTopLevelSelector(expression, "+");
2767
- if (adjacentSelector) {
2768
- return Boolean(siblings.previous)
2769
- && customRuleSimpleSelectorMatches(adjacentSelector[0].trim(), siblings.previous, visitorKeys)
2770
- && customRuleSimpleSelectorMatches(adjacentSelector[1].trim(), node, visitorKeys);
2771
- }
2772
- const siblingSelector = customRuleSplitTopLevelSelector(expression, "~");
2773
- if (siblingSelector) {
2774
- return (siblings.previousAll ?? []).some((sibling) => customRuleSimpleSelectorMatches(siblingSelector[0].trim(), sibling, visitorKeys))
2775
- && customRuleSimpleSelectorMatches(siblingSelector[1].trim(), node, visitorKeys);
2776
- }
2777
- const descendantSelector = customRuleSplitTopLevelDescendantSelector(expression);
2778
- if (descendantSelector) {
2779
- return ancestors.some((ancestor) => customRuleSimpleSelectorMatches(descendantSelector[0].trim(), ancestor, visitorKeys))
2780
- && customRuleSimpleSelectorMatches(descendantSelector[1].trim(), node, visitorKeys);
2781
- }
2782
- return customRuleSimpleSelectorMatches(expression, node, visitorKeys);
2783
- }
2784
-
2785
- function customRuleSimpleSelectorMatches(expression, node, visitorKeys = null) {
2786
- const hasSelector = expression.match(/^(.+?):has\((.+)\)$/u);
2787
- if (hasSelector) {
2788
- return customRuleSimpleSelectorMatches(hasSelector[1].trim(), node, visitorKeys)
2789
- && customRuleDescendants(node, visitorKeys).some((descendant) => (
2790
- customRuleSplitSelectorList(hasSelector[2]).some((selector) => customRuleSimpleSelectorMatches(selector.trim(), descendant, visitorKeys))
2791
- ));
2792
- }
2793
- const notSelector = expression.match(/^(.+?):not\((.+)\)$/u);
2794
- if (notSelector) {
2795
- return customRuleSimpleSelectorMatches(notSelector[1].trim(), node, visitorKeys)
2796
- && !customRuleSimpleSelectorMatches(notSelector[2].trim(), node, visitorKeys);
2797
- }
2798
- const matchesSelector = expression.match(/^(.*?):matches\((.+)\)$/u);
2799
- if (matchesSelector) {
2800
- const prefix = matchesSelector[1].trim();
2801
- if (prefix && !customRuleSimpleSelectorMatches(prefix, node, visitorKeys)) {
2802
- return false;
2803
- }
2804
- return customRuleSplitSelectorList(matchesSelector[2]).some((selector) => (
2805
- customRuleSimpleSelectorMatches(selector.trim(), node, visitorKeys)
2806
- ));
2807
- }
2808
- if (expression === node.type) {
2809
- return true;
2810
- }
2811
- const match = expression.match(/^([A-Za-z_$][\w$-]*|\*)?(?:\[([^\]]+)\])?$/u);
2812
- if (!match) {
2813
- return false;
2814
- }
2815
- const [, type = "*", attribute] = match;
2816
- if (type !== "*" && type !== node.type) {
2817
- return false;
2818
- }
2819
- return attribute ? customRuleAttributeSelectorMatches(node, attribute.trim()) : type === "*" || type === node.type;
2820
- }
2821
-
2822
- function customRuleDescendants(node, visitorKeys, seen = new Set()) {
2823
- const descendants = [];
2824
- for (const child of customRuleChildNodes(node, visitorKeys)) {
2825
- if (seen.has(child)) {
2826
- continue;
2827
- }
2828
- seen.add(child);
2829
- descendants.push(child, ...customRuleDescendants(child, visitorKeys, seen));
2830
- }
2831
- return descendants;
2832
- }
2833
-
2834
- function customRuleSplitTopLevelSelector(expression, separator) {
2835
- let state = customRuleSelectorScanState();
2836
- for (let index = 0; index < expression.length; index += 1) {
2837
- state = customRuleUpdateSelectorScanState(state, expression[index]);
2838
- if (state.depth === 0 && !state.quote && expression[index] === separator) {
2839
- return [expression.slice(0, index), expression.slice(index + 1)];
2840
- }
2841
- }
2842
- return null;
2843
- }
2844
-
2845
- function customRuleSplitTopLevelDescendantSelector(expression) {
2846
- let state = customRuleSelectorScanState();
2847
- for (let index = 0; index < expression.length; index += 1) {
2848
- state = customRuleUpdateSelectorScanState(state, expression[index]);
2849
- if (state.depth === 0 && !state.quote && /\s/u.test(expression[index])) {
2850
- const left = expression.slice(0, index).trim();
2851
- const right = expression.slice(index).trim();
2852
- return left && right ? [left, right] : null;
2853
- }
2854
- }
2855
- return null;
2856
- }
2857
-
2858
- function customRuleSplitSelectorList(value) {
2859
- const selectors = [];
2860
- let state = customRuleSelectorScanState();
2861
- let start = 0;
2862
- for (let index = 0; index < value.length; index += 1) {
2863
- state = customRuleUpdateSelectorScanState(state, value[index]);
2864
- if (state.depth === 0 && !state.quote && value[index] === ",") {
2865
- selectors.push(value.slice(start, index));
2866
- start = index + 1;
2867
- }
2868
- }
2869
- selectors.push(value.slice(start));
2870
- return selectors.filter((selector) => selector.trim());
2871
- }
2872
-
2873
- function customRuleSelectorScanState() {
2874
- return { depth: 0, quote: null, escaped: false };
2875
- }
2876
-
2877
- function customRuleUpdateSelectorScanState(state, char) {
2878
- if (state.escaped) {
2879
- return { ...state, escaped: false };
2880
- }
2881
- if (char === "\\") {
2882
- return { ...state, escaped: true };
2883
- }
2884
- if (state.quote) {
2885
- return char === state.quote ? { ...state, quote: null } : state;
2886
- }
2887
- if (char === "\"" || char === "'") {
2888
- return { ...state, quote: char };
2889
- }
2890
- if (char === "[" || char === "(") {
2891
- return { ...state, depth: state.depth + 1 };
2892
- }
2893
- if ((char === "]" || char === ")") && state.depth > 0) {
2894
- return { ...state, depth: state.depth - 1 };
2895
- }
2896
- return state;
2897
- }
2898
-
2899
- function customRuleAttributeSelectorMatches(node, attribute) {
2900
- const match = attribute.match(/^([\w$.-]+)\s*(!=|=)\s*(.+)$/u);
2901
- if (!match) {
2902
- return customRuleValueByPath(node, attribute) != null;
2903
- }
2904
- const [, path, operator, rawExpected] = match;
2905
- const actual = customRuleValueByPath(node, path);
2906
- const expected = customRuleSelectorValue(rawExpected);
2907
- return operator === "="
2908
- ? String(actual) === expected
2909
- : String(actual) !== expected;
2910
- }
2911
-
2912
- function customRuleSelectorValue(raw) {
2913
- const value = raw.trim();
2914
- const quote = value[0];
2915
- if ((quote === "\"" || quote === "'") && value.at(-1) === quote) {
2916
- return value.slice(1, -1);
2917
- }
2918
- return value;
2919
- }
2920
-
2921
- function customRuleValueByPath(node, path) {
2922
- return path.split(".").reduce((value, key) => (
2923
- value && typeof value === "object" ? value[key] : undefined
2924
- ), node);
2925
- }
2926
-
2927
- function customRuleChildNodes(node, visitorKeys) {
2928
- const keys = Array.isArray(visitorKeys?.[node.type])
2929
- ? visitorKeys[node.type]
2930
- : Object.keys(node).filter((key) => !AST_TRAVERSAL_SKIP_KEYS.has(key));
2931
- const children = [];
2932
- for (const key of keys) {
2933
- const value = node[key];
2934
- const values = Array.isArray(value) ? value : [value];
2935
- for (const child of values) {
2936
- if (child && typeof child === "object" && typeof child.type === "string") {
2937
- children.push(child);
2938
- }
2939
- }
2940
- }
2941
- return children;
2942
- }
2943
-
2944
3195
  function runCustomRuleTokenListeners(program, tokens, listeners, context) {
2945
3196
  if (typeof listeners.Program === "function") {
2946
3197
  context.setCurrentNode(program);
@@ -2975,7 +3226,7 @@ function createCustomRuleContext(ruleEntry, sourceCode, options, messages) {
2975
3226
  let currentNode = sourceCode.ast ?? null;
2976
3227
  const context = {
2977
3228
  id: ruleEntry.ruleId,
2978
- options: ruleEntry.options,
3229
+ options: pluginRunner.applyRuleOptionDefaults(ruleEntry.rule, ruleEntry.options),
2979
3230
  filename,
2980
3231
  physicalFilename: filename,
2981
3232
  cwd,
@@ -3193,7 +3444,7 @@ class SourceCode {
3193
3444
  this.text = textOrConfig.text;
3194
3445
  this.ast = textOrConfig.ast ?? null;
3195
3446
  this.parserServices = textOrConfig.parserServices ?? {};
3196
- this.scopeManager = textOrConfig.scopeManager ?? null;
3447
+ defineScopeManager(this, textOrConfig.scopeManager ?? null);
3197
3448
  this.visitorKeys = textOrConfig.visitorKeys ?? null;
3198
3449
  this.hasBOM = Boolean(textOrConfig.hasBOM);
3199
3450
  } else {
@@ -3385,7 +3636,17 @@ class SourceCode {
3385
3636
  }
3386
3637
 
3387
3638
  getAncestors(node) {
3388
- 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) ?? [];
3389
3650
  }
3390
3651
 
3391
3652
  getDeclaredVariables(node) {
@@ -3393,10 +3654,20 @@ class SourceCode {
3393
3654
  }
3394
3655
 
3395
3656
  getScope(node) {
3396
- if (node && typeof this.scopeManager?.acquire === "function") {
3397
- 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
+ }
3398
3669
  }
3399
- return this.scopeManager?.globalScope ?? null;
3670
+ return scopeManager.scopes?.[0] ?? scopeManager.globalScope ?? null;
3400
3671
  }
3401
3672
 
3402
3673
  markVariableAsUsed(name, node) {
@@ -3432,6 +3703,31 @@ class SourceCode {
3432
3703
  }
3433
3704
  }
3434
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
+
3435
3731
  function sourceLineStartIndices(text) {
3436
3732
  const indices = [0];
3437
3733
  for (let index = 0; index < text.length; index += 1) {
@@ -3721,12 +4017,10 @@ class RuleTester {
3721
4017
  RuleTester[testCase.only ? "itOnly" : "it"](testCase.name ?? testCase.code, () => {
3722
4018
  const config = ruleTesterConfig(ruleName, this.config, testCase, "error");
3723
4019
  const options = ruleTesterOptions(testCase);
3724
- const result = Object.hasOwn(testCase, "output")
3725
- ? linter.verifyAndFix(testCase.code, config, options)
3726
- : { messages: linter.verify(testCase.code, config, options), output: testCase.code };
3727
- const messages = result.messages;
4020
+ const messages = linter.verify(testCase.code, config, options);
3728
4021
  assertRuleTesterErrors(testCase, messages, rule);
3729
- 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);
3730
4024
  });
3731
4025
  }
3732
4026
  });
@@ -3873,6 +4167,47 @@ function applyRuleFixes(code, fix) {
3873
4167
  .reduce((output, item) => output.slice(0, item.range[0]) + item.text + output.slice(item.range[1]), code);
3874
4168
  }
3875
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
+
3876
4211
  function ruleFixItems(fix) {
3877
4212
  if (!fix) {
3878
4213
  return [];
@@ -3925,10 +4260,29 @@ function createLinterSourceCode(text, parser = null, parserOptions = {}) {
3925
4260
  });
3926
4261
  }
3927
4262
 
3928
- function parseSourceCode(text, parser, parserOptions) {
3929
- 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
+ }
3930
4271
  return null;
3931
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
+ }
3932
4286
  if (typeof parser.parseForESLint === "function") {
3933
4287
  const result = parser.parseForESLint(text, parserOptions) ?? {};
3934
4288
  return new SourceCode({
@@ -4194,7 +4548,7 @@ function applyCompatibilityDisableDirectives(result, textOptions) {
4194
4548
  return;
4195
4549
  }
4196
4550
 
4197
- const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source));
4551
+ const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source, null, { tokensOnly: true }));
4198
4552
  result.messages = filtered.messages;
4199
4553
  result.suppressedMessages.push(...filtered.suppressedMessages);
4200
4554
  }
@@ -4928,6 +5282,12 @@ function diagnosticToESLintMessage(diagnostic, ruleSeverities) {
4928
5282
  column: diagnostic.column,
4929
5283
  nodeType: null
4930
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
+ }
4931
5291
  if (diagnostic.endLine != null) {
4932
5292
  message.endLine = diagnostic.endLine;
4933
5293
  }