@utoo/lint 0.3.9 → 0.4.1

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
@@ -6,11 +6,16 @@ const { dirname, extname, isAbsolute, join, relative, resolve: resolvePath } = r
6
6
 
7
7
  const { platformPackageName, resolveBinary } = require("./lib/binary.cjs");
8
8
  const { createLegacyConfigResolver } = require("./lib/legacy-config.cjs");
9
+ const { nativeFlatConfigEntries, nativeRules } = require("./lib/native-rule-options.cjs");
9
10
  const { formatESLintResults } = require("./lib/stylish-formatter.cjs");
10
11
  const {
11
12
  findConfigPath: findConfigPathFromDirectory,
13
+ isExecutableConfigPath,
12
14
  readConfig
13
15
  } = require("./lib/config-loader.cjs");
16
+ const configModule = require("./lib/config-module.cjs");
17
+ const estreeParser = require("./lib/estree-parser.cjs");
18
+ const pluginRunner = require("./lib/plugin-runner.cjs");
14
19
 
15
20
  const version = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")).version;
16
21
 
@@ -25,10 +30,6 @@ const JS_KEYWORDS = new Set([
25
30
  "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try", "typeof",
26
31
  "var", "void", "while", "with", "yield"
27
32
  ]);
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
33
  const RULE_TESTER_INITIAL_CONFIG = { rules: {} };
33
34
  let ruleTesterDefaultConfig = { rules: {} };
34
35
  let ruleTesterDescribe = null;
@@ -480,6 +481,7 @@ const NATIVE_ONLY_RULE_IDS = [
480
481
  "semantic-errors"
481
482
  ];
482
483
  const NATIVE_RULE_IDS = [...BUILTIN_RULE_IDS, ...NATIVE_ONLY_RULE_IDS];
484
+ const NATIVE_RULE_ID_SET = new Set(NATIVE_RULE_IDS);
483
485
  const FIXABLE_BUILTIN_RULE_IDS = new Set([
484
486
  "@typescript-eslint/no-explicit-any",
485
487
  "jest/no-deprecated-functions",
@@ -607,15 +609,15 @@ class UtooLint {
607
609
  throw new Error("'results' must be an array");
608
610
  }
609
611
  const options = eslintConstructorOptions(this.options);
610
- const customRuleConfig = [options.baseConfig, options.overrideConfig].filter(Boolean);
611
612
  const rulesByFilePath = new Map();
613
+ const cachedOptions = withConfigCache(options);
612
614
  return rulesMetaForResults(results, (ruleId, result) => {
613
- if (customRuleConfig.length === 0 || typeof result.filePath !== "string") {
615
+ if (typeof result.filePath !== "string") {
614
616
  return undefined;
615
617
  }
616
618
  const filePath = normalizeESLintFilePath(result.filePath, options.cwd);
617
619
  if (!rulesByFilePath.has(filePath)) {
618
- rulesByFilePath.set(filePath, customRuleMapForConfig(customRuleConfig, new Map(), filePath, options.cwd));
620
+ rulesByFilePath.set(filePath, jsPluginRuleMapForFile(cachedOptions, filePath));
619
621
  }
620
622
  return rulesByFilePath.get(filePath).get(ruleId)?.meta;
621
623
  });
@@ -1229,21 +1231,210 @@ function lintFiles(paths, options = {}) {
1229
1231
  }
1230
1232
 
1231
1233
  const nativeFlatOptions = nativeFlatConfigOptions(options);
1232
- if (nativeFlatOptions) {
1233
- const report = runNativeLintReport(lintPaths, nativeFlatOptions);
1234
- return finalizeLintReport(report, ignoredDiagnostics, options);
1235
- }
1234
+ const report = nativeFlatOptions
1235
+ ? runNativeLintReport(lintPaths, nativeFlatOptions)
1236
+ : runNativeConfigRunReports(lintPaths, options);
1237
+ // The flat-config path resolves one config for the whole run; only the
1238
+ // per-file path has already discovered (and cached) nested configs.
1239
+ appendPluginRuleDiagnostics(report, options, { perFileConfig: !nativeFlatOptions });
1240
+ return finalizeLintReport(report, ignoredDiagnostics, options);
1241
+ }
1236
1242
 
1243
+ function runNativeConfigRunReports(lintPaths, options) {
1237
1244
  const configRuns = nativeConfigRunsForFiles(expandNativeConfigRunPaths(lintPaths, options), options);
1238
1245
  if (configRuns) {
1239
- const report = mergeNativeReports(
1246
+ return mergeNativeReports(
1240
1247
  configRuns.map(({ paths: runPaths, options: runOptions }) => runNativeLintReport(runPaths, runOptions))
1241
1248
  );
1242
- return finalizeLintReport(report, ignoredDiagnostics, options);
1243
1249
  }
1244
1250
 
1245
- const report = runNativeLintReport(lintPaths, options);
1246
- return finalizeLintReport(report, ignoredDiagnostics, options);
1251
+ return runNativeLintReport(lintPaths, options);
1252
+ }
1253
+
1254
+ function pluginFixModeForOptions(options) {
1255
+ const extraArgs = Array.isArray(options.extraArgs) ? options.extraArgs : [];
1256
+ if (options.fix || extraArgs.includes("--fix-dry-run")) {
1257
+ return "dry-run";
1258
+ }
1259
+ return extraArgs.includes("--fix") ? "write" : null;
1260
+ }
1261
+
1262
+ function pluginMessageToDiagnostic(message, filePath) {
1263
+ const toFix = (fix) => ({ range: [fix.range[0], fix.range[1]], text: fix.text });
1264
+ const diagnostic = {
1265
+ filePath,
1266
+ line: message.line,
1267
+ column: message.column,
1268
+ severity: message.severity === 2 ? "error" : "warning",
1269
+ message: message.message,
1270
+ ruleId: message.ruleId,
1271
+ fixes: ruleFixItems(message.fix).map(toFix),
1272
+ suggestions: (message.suggestions ?? []).map((suggestion) => ({
1273
+ desc: suggestion.desc,
1274
+ fix: ruleFixItems(suggestion.fix).map(toFix)
1275
+ }))
1276
+ };
1277
+ if (typeof message.endLine === "number") {
1278
+ diagnostic.endLine = message.endLine;
1279
+ }
1280
+ if (typeof message.endColumn === "number") {
1281
+ diagnostic.endColumn = message.endColumn;
1282
+ }
1283
+ return diagnostic;
1284
+ }
1285
+
1286
+ function sortReportDiagnostics(report) {
1287
+ const order = new Map((report.filePaths ?? []).map((filePath, index) => [filePath, index]));
1288
+ const rank = (diagnostic) => order.get(diagnostic.filePath) ?? Number.MAX_SAFE_INTEGER;
1289
+ const compare = (left, right) => rank(left) - rank(right)
1290
+ || (left.line ?? 0) - (right.line ?? 0)
1291
+ || (left.column ?? 0) - (right.column ?? 0);
1292
+ report.diagnostics = [...(report.diagnostics ?? [])].sort(compare);
1293
+ report.suppressedDiagnostics = [...(report.suppressedDiagnostics ?? [])].sort(compare);
1294
+ }
1295
+
1296
+ // Runs the ESLint plugin rules a config mounts and merges their diagnostics
1297
+ // into the native report. Plugin rules never reach the native binary; see
1298
+ // `withoutJsPluginRules`.
1299
+ function appendPluginRuleDiagnostics(report, options, { perFileConfig = true } = {}) {
1300
+ if (options.noConfig && !options.baseConfig && !options.overrideConfig) {
1301
+ return report;
1302
+ }
1303
+ const filePaths = report.filePaths ?? [];
1304
+ if (filePaths.length === 0 || !anyConfigDeclaresPlugins(options)) {
1305
+ return report;
1306
+ }
1307
+ const cwd = options.cwd ?? process.cwd();
1308
+ const singleMatchPath = filePaths.length === 1
1309
+ ? options.filePath ?? options.filename ?? options.pluginTextFilePath
1310
+ : undefined;
1311
+ const fixMode = pluginFixModeForOptions(options);
1312
+ let appended = false;
1313
+
1314
+ for (const filePath of filePaths) {
1315
+ const matchPath = singleMatchPath ?? filePath;
1316
+ const ruleMap = jsPluginRuleMapForFile(options, matchPath, perFileConfig);
1317
+ if (ruleMap.size === 0) {
1318
+ continue;
1319
+ }
1320
+ const ruleEntries = pluginRuleEntriesForFile(options, matchPath, ruleMap, perFileConfig);
1321
+ if (ruleEntries.length === 0) {
1322
+ continue;
1323
+ }
1324
+
1325
+ // The native report echoes targets as given, so relative paths resolve
1326
+ // against the lint cwd rather than the process cwd.
1327
+ const absolutePath = normalizeESLintFilePath(filePath, cwd);
1328
+ const outputIndex = (report.outputs ?? []).findIndex((fixed) => fixed.filePath === filePath);
1329
+ let code = outputIndex === -1 ? undefined : report.outputs[outputIndex].output;
1330
+ if (typeof code !== "string") {
1331
+ try {
1332
+ code = readFileSync(absolutePath, "utf8");
1333
+ } catch {
1334
+ continue;
1335
+ }
1336
+ }
1337
+
1338
+ const config = pluginConfigSourcesForFile(options, matchPath, perFileConfig).map((source) => source.config);
1339
+ const parserOptions = { ...parserOptionsForConfig(config, matchPath, cwd), filePath: matchPath, requireParser: true };
1340
+ const lintOptions = {
1341
+ cwd,
1342
+ filename: matchPath,
1343
+ config: calculatedConfig({ cwd, noConfig: true, baseConfig: config }, matchPath)
1344
+ };
1345
+ const lintOnce = (text) => {
1346
+ const sourceCode = createLinterSourceCode(text, null, parserOptions);
1347
+ return applyDisableDirectives(runCustomLinterRules(ruleEntries, sourceCode, lintOptions), sourceCode);
1348
+ };
1349
+
1350
+ let filtered = lintOnce(code);
1351
+ let relintedFromReport = false;
1352
+ if (fixMode) {
1353
+ let changed = false;
1354
+ for (let pass = 0; pass < MAX_AUTOFIX_PASSES; pass += 1) {
1355
+ const output = applyMessageFixes(code, filtered.messages);
1356
+ if (output === code) {
1357
+ break;
1358
+ }
1359
+ changed = true;
1360
+ code = output;
1361
+ filtered = lintOnce(code);
1362
+ }
1363
+ if (changed) {
1364
+ // Plugin fixes changed the text, so the native diagnostics for this
1365
+ // file describe stale source. Re-lint the fixed text through the whole
1366
+ // pipeline (native rules, plugin rules, further fixes) and replace this
1367
+ // file's diagnostics with the result.
1368
+ const depth = options.pluginFixDepth ?? 0;
1369
+ if (depth < MAX_AUTOFIX_PASSES) {
1370
+ const relinted = lintText(code, {
1371
+ ...options,
1372
+ filePath: matchPath,
1373
+ filename: undefined,
1374
+ config: pluginConfigPathForFile(options, matchPath, perFileConfig) ?? options.config,
1375
+ fix: true,
1376
+ extraArgs: (options.extraArgs ?? []).filter((arg) => arg !== "--fix" && arg !== "--fix-dry-run"),
1377
+ useDefaultConfigTargets: undefined,
1378
+ pluginTextFilePath: undefined,
1379
+ pluginFixDepth: depth + 1
1380
+ });
1381
+ code = relinted.outputs?.find((fixed) => fixed.filePath === matchPath)?.output ?? code;
1382
+ filtered = {
1383
+ messages: relinted.diagnostics ?? [],
1384
+ suppressedMessages: relinted.suppressedDiagnostics ?? []
1385
+ };
1386
+ report.diagnostics = (report.diagnostics ?? []).filter((diagnostic) => diagnostic.filePath !== filePath);
1387
+ report.suppressedDiagnostics = (report.suppressedDiagnostics ?? []).filter((diagnostic) => diagnostic.filePath !== filePath);
1388
+ relintedFromReport = true;
1389
+ }
1390
+ if (fixMode === "write") {
1391
+ writeFileSync(absolutePath, code);
1392
+ } else if (outputIndex === -1) {
1393
+ report.outputs = [...(report.outputs ?? []), { filePath, output: code }];
1394
+ } else {
1395
+ report.outputs[outputIndex] = { ...report.outputs[outputIndex], output: code };
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ if (relintedFromReport) {
1401
+ // Already native-shaped diagnostics from the nested lint; only the
1402
+ // file path needs to match this report.
1403
+ report.diagnostics = [
1404
+ ...(report.diagnostics ?? []),
1405
+ ...filtered.messages.map((diagnostic) => ({ ...diagnostic, filePath }))
1406
+ ];
1407
+ report.suppressedDiagnostics = [
1408
+ ...(report.suppressedDiagnostics ?? []),
1409
+ ...filtered.suppressedMessages.map((diagnostic) => ({ ...diagnostic, filePath }))
1410
+ ];
1411
+ appended = true;
1412
+ continue;
1413
+ }
1414
+ if (filtered.messages.length === 0 && filtered.suppressedMessages.length === 0) {
1415
+ continue;
1416
+ }
1417
+ report.diagnostics = [
1418
+ ...(report.diagnostics ?? []),
1419
+ ...filtered.messages.map((message) => pluginMessageToDiagnostic(message, filePath))
1420
+ ];
1421
+ report.suppressedDiagnostics = [
1422
+ ...(report.suppressedDiagnostics ?? []),
1423
+ ...filtered.suppressedMessages.map((message) => ({
1424
+ ...pluginMessageToDiagnostic(message, filePath),
1425
+ suppression: {
1426
+ kind: message.suppressions?.[0]?.kind ?? "directive",
1427
+ justification: message.suppressions?.[0]?.justification ?? ""
1428
+ }
1429
+ }))
1430
+ ];
1431
+ appended = true;
1432
+ }
1433
+
1434
+ if (appended) {
1435
+ sortReportDiagnostics(report);
1436
+ }
1437
+ return report;
1247
1438
  }
1248
1439
 
1249
1440
  function nativeFlatConfigOptions(options) {
@@ -1273,6 +1464,10 @@ function nativeFlatConfigOptions(options) {
1273
1464
  return undefined;
1274
1465
  }
1275
1466
 
1467
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(config);
1468
+ if (jsPluginRuleIds.size > 0) {
1469
+ config = configWithoutCustomRules(config, jsPluginRuleIds);
1470
+ }
1276
1471
  if (configCanUseNativeRulesFastPath(config)) {
1277
1472
  return undefined;
1278
1473
  }
@@ -1286,11 +1481,28 @@ function nativeFlatConfigOptions(options) {
1286
1481
  };
1287
1482
  }
1288
1483
 
1484
+ // Globals declared by a config entry, merged the way ESLint does: flat
1485
+ // `languageOptions.globals` wins over eslintrc-style `globals`.
1486
+ function nativeConfigGlobals(config) {
1487
+ if (!config || typeof config !== "object") {
1488
+ return undefined;
1489
+ }
1490
+ const globals = {
1491
+ ...(isGlobalsObject(config.globals) ? config.globals : {}),
1492
+ ...(isGlobalsObject(config.languageOptions?.globals) ? config.languageOptions.globals : {})
1493
+ };
1494
+ return Object.keys(globals).length > 0 ? globals : undefined;
1495
+ }
1496
+
1497
+ function isGlobalsObject(value) {
1498
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1499
+ }
1500
+
1289
1501
  function configCanUseNativeRulesFastPath(config) {
1290
1502
  const entries = Array.isArray(config) ? config : [config];
1291
1503
  let hasEnabledRule = false;
1292
1504
  for (const entry of entries) {
1293
- if (!entry || typeof entry !== "object" || Object.keys(entry.settings ?? {}).length > 0) {
1505
+ if (!entry || typeof entry !== "object" || Object.keys(entry.settings ?? {}).length > 0 || nativeConfigGlobals(entry)) {
1294
1506
  return false;
1295
1507
  }
1296
1508
  for (const ruleConfig of Object.values(entry.rules ?? {})) {
@@ -1313,11 +1525,16 @@ function nativeSerializableConfig(config) {
1313
1525
  if (!config || typeof config !== "object") {
1314
1526
  return config;
1315
1527
  }
1316
- return Object.fromEntries(
1528
+ const serialized = Object.fromEntries(
1317
1529
  ["$schema", "name", "files", "ignores", "rules", "settings"]
1318
1530
  .filter((key) => config[key] !== undefined)
1319
1531
  .map((key) => [key, config[key]])
1320
1532
  );
1533
+ const globals = nativeConfigGlobals(config);
1534
+ if (globals) {
1535
+ serialized.languageOptions = { globals };
1536
+ }
1537
+ return serialized;
1321
1538
  }
1322
1539
 
1323
1540
  function expandNativeConfigRunPaths(paths, options) {
@@ -1396,12 +1613,16 @@ function nativeConfigRunsForFiles(paths, options) {
1396
1613
  const calculated = configured ? calculatedConfig({ ...options, rules: undefined }, matchPath) : {};
1397
1614
  let rules = calculated.rules;
1398
1615
  const settings = calculated.settings;
1616
+ const globals = configured ? nativeConfigGlobals(calculated) : undefined;
1399
1617
  if (configured && options.rules) {
1400
1618
  rules = selectedRulesWithConfigOptions(rules, options.rules);
1401
1619
  }
1402
- const signature = configured ? stableConfigSignature({ rules, settings }) : "<native-defaults>";
1620
+ if (configured) {
1621
+ rules = withoutJsPluginRules(rules, jsPluginRuleIdsForOptions(options, matchPath));
1622
+ }
1623
+ const signature = configured ? stableConfigSignature({ rules, settings, globals }) : "<native-defaults>";
1403
1624
  if (!groups.has(signature)) {
1404
- groups.set(signature, { paths: [], rules, settings, configured });
1625
+ groups.set(signature, { paths: [], rules, settings, globals, configured });
1405
1626
  }
1406
1627
  groups.get(signature).paths.push(filePath);
1407
1628
  }
@@ -1424,7 +1645,7 @@ function nativeConfigRunsForFiles(paths, options) {
1424
1645
  };
1425
1646
  }
1426
1647
 
1427
- const selectedRules = severityOnlyNativeRuleNames(group.rules, group.settings);
1648
+ const selectedRules = severityOnlyNativeRuleNames(group.rules, group.settings, group.globals);
1428
1649
  if (selectedRules) {
1429
1650
  return {
1430
1651
  paths: group.paths,
@@ -1449,7 +1670,8 @@ function nativeConfigRunsForFiles(paths, options) {
1449
1670
  baseConfig: undefined,
1450
1671
  overrideConfig: {
1451
1672
  rules: allDisabledNativeRules(group.rules),
1452
- ...(group.settings ? { settings: group.settings } : {})
1673
+ ...(group.settings ? { settings: group.settings } : {}),
1674
+ ...(group.globals ? { languageOptions: { globals: group.globals } } : {})
1453
1675
  },
1454
1676
  forceMaterializedConfig: true
1455
1677
  }
@@ -1457,8 +1679,8 @@ function nativeConfigRunsForFiles(paths, options) {
1457
1679
  });
1458
1680
  }
1459
1681
 
1460
- function severityOnlyNativeRuleNames(rules, settings) {
1461
- if (Object.keys(settings ?? {}).length > 0) {
1682
+ function severityOnlyNativeRuleNames(rules, settings, globals) {
1683
+ if (globals || Object.keys(settings ?? {}).length > 0) {
1462
1684
  return undefined;
1463
1685
  }
1464
1686
 
@@ -1571,7 +1793,8 @@ function lintText(code, options = {}) {
1571
1793
  cwd: options.cwd,
1572
1794
  config: options.config ?? discoveredConfig,
1573
1795
  noConfig: options.noConfig,
1574
- deferDiagnosticConfigFiltering: true
1796
+ deferDiagnosticConfigFiltering: true,
1797
+ pluginTextFilePath: requestedPath
1575
1798
  });
1576
1799
  report.filePaths = (report.filePaths ?? []).map((filePath) => (filePath === tempFile ? requestedPath : filePath));
1577
1800
  report.outputs = (report.outputs ?? []).map((fixed) => ({
@@ -1613,11 +1836,12 @@ function buildNativeLintArgs(paths, options) {
1613
1836
  const configForRuleSelection = options.noConfig
1614
1837
  ? undefined
1615
1838
  : options.config ? readConfig(options.config, options.cwd, options.configCache) : undefined;
1839
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(configForRuleSelection, options.baseConfig, options.overrideConfig);
1616
1840
  const enabledRules = enabledRuleNamesFromConfigs(
1617
1841
  configForRuleSelection,
1618
1842
  options.baseConfig,
1619
1843
  options.overrideConfig
1620
- );
1844
+ ).filter((rule) => !jsPluginRuleIds.has(rule));
1621
1845
  if (enabledRules.length > 0) {
1622
1846
  cliArgs.push(`--rules=${enabledRules.join(",")}`);
1623
1847
  }
@@ -1780,6 +2004,10 @@ function mergeConfigData(base, override) {
1780
2004
  }
1781
2005
  if (base.languageOptions || override.languageOptions) {
1782
2006
  result.languageOptions = { ...(base.languageOptions ?? {}), ...(override.languageOptions ?? {}) };
2007
+ // ESLint merges `languageOptions.globals` per name across config objects.
2008
+ if (isGlobalsObject(base.languageOptions?.globals) && isGlobalsObject(override.languageOptions?.globals)) {
2009
+ result.languageOptions.globals = { ...base.languageOptions.globals, ...override.languageOptions.globals };
2010
+ }
1783
2011
  }
1784
2012
  if (base.parserOptions || override.parserOptions) {
1785
2013
  result.parserOptions = { ...(base.parserOptions ?? {}), ...(override.parserOptions ?? {}) };
@@ -2075,7 +2303,7 @@ function withTemporaryConfig(options, callback) {
2075
2303
  const tmp = mkdtempSync(join(tmpdir(), "utoo-lint-flat-config-"));
2076
2304
  const configPath = join(tmp, "utlint.config.json");
2077
2305
  try {
2078
- writeFileSync(configPath, JSON.stringify(options.nativeFlatConfigData));
2306
+ writeFileSync(configPath, JSON.stringify(nativeFlatConfigEntries(options.nativeFlatConfigData)));
2079
2307
  return callback({
2080
2308
  ...options,
2081
2309
  config: configPath,
@@ -2095,9 +2323,11 @@ function withTemporaryConfig(options, callback) {
2095
2323
  ? readConfig(fileConfigPath, options.cwd, options.configCache)
2096
2324
  : undefined;
2097
2325
  const configs = [options.baseConfig, fileConfig, options.overrideConfig];
2098
- const rules = shouldMaterializeFileConfig
2099
- ? materializedRulesFromConfigs(...configs)
2100
- : runtimeRulesFromConfigs(...configs);
2326
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(...configs);
2327
+ const rules = withoutJsPluginRules(
2328
+ shouldMaterializeFileConfig ? materializedRulesFromConfigs(...configs) : runtimeRulesFromConfigs(...configs),
2329
+ jsPluginRuleIds
2330
+ );
2101
2331
  const settings = configs.reduce(
2102
2332
  (result, config) => ({
2103
2333
  ...result,
@@ -2106,7 +2336,15 @@ function withTemporaryConfig(options, callback) {
2106
2336
  {}
2107
2337
  );
2108
2338
  const hasSettings = Object.keys(settings).length > 0;
2109
- if (Object.keys(rules).length === 0 && !hasSettings) {
2339
+ const globals = configs.reduce(
2340
+ (result, config) => ({
2341
+ ...result,
2342
+ ...(nativeConfigGlobals(configDataFromConfig(config, options.filePath ?? options.filename, options.cwd)) ?? {})
2343
+ }),
2344
+ {}
2345
+ );
2346
+ const hasGlobals = Object.keys(globals).length > 0;
2347
+ if (Object.keys(rules).length === 0 && !hasSettings && !hasGlobals) {
2110
2348
  if (shouldMaterializeFileConfig) {
2111
2349
  return callback({
2112
2350
  ...options,
@@ -2116,9 +2354,9 @@ function withTemporaryConfig(options, callback) {
2116
2354
  }
2117
2355
  return callback(options);
2118
2356
  }
2119
- const enabledRules = enabledRuleNamesFromConfigs(...configs);
2357
+ const enabledRules = enabledRuleNamesFromConfigs(...configs).filter((rule) => !jsPluginRuleIds.has(rule));
2120
2358
  const hasExplicitOffRules = Object.values(rules).some((value) => ruleConfigSeverity(value) === 0);
2121
- if (!shouldMaterializeFileConfig && !hasSettings && !hasRuleOptions(rules) && !hasExplicitOffRules && !options.forceMaterializedConfig) {
2359
+ if (!shouldMaterializeFileConfig && !hasSettings && !hasGlobals && !hasRuleOptions(rules) && !hasExplicitOffRules && !options.forceMaterializedConfig) {
2122
2360
  return callback({
2123
2361
  ...options,
2124
2362
  config: shouldMaterializeFileConfig ? undefined : options.config,
@@ -2130,7 +2368,11 @@ function withTemporaryConfig(options, callback) {
2130
2368
  const tmp = mkdtempSync(join(tmpdir(), "utoo-lint-config-"));
2131
2369
  const configPath = join(tmp, "utlint.config.json");
2132
2370
  try {
2133
- writeFileSync(configPath, JSON.stringify({ rules, ...(hasSettings ? { settings } : {}) }));
2371
+ writeFileSync(configPath, JSON.stringify({
2372
+ rules: nativeRules(rules),
2373
+ ...(hasSettings ? { settings } : {}),
2374
+ ...(hasGlobals ? { languageOptions: { globals } } : {})
2375
+ }));
2134
2376
  return callback({
2135
2377
  ...options,
2136
2378
  config: shouldMaterializeFileConfig ? configPath : options.config ?? configPath,
@@ -2245,14 +2487,14 @@ class Linter {
2245
2487
  const verifyOptions = typeof options === "string" ? { filename: options } : { ...options };
2246
2488
  const filePath = verifyOptions.filename ?? verifyOptions.filePath ?? "input.js";
2247
2489
  const normalizedFilePath = normalizeESLintFilePath(filePath, verifyOptions.cwd);
2490
+ const customRuleMap = customRuleMapForConfig(config, this.rules, normalizedFilePath, verifyOptions.cwd);
2491
+ const customRuleFilterMap = customRuleMapForConfig(config, this.rules);
2492
+ const customRules = customRuleEntriesForConfig(config, customRuleMap, normalizedFilePath, verifyOptions.cwd);
2248
2493
  const sourceCode = createLinterSourceCode(
2249
2494
  code,
2250
2495
  parserForConfig(config, this.parsers, normalizedFilePath, verifyOptions.cwd),
2251
- parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd)
2496
+ { ...parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd), requireParser: customRules.length > 0 }
2252
2497
  );
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
2498
  const nativeConfig = configWithoutCustomRules(config, customRuleFilterMap);
2257
2499
  const lintOptions = {
2258
2500
  cwd: verifyOptions.cwd,
@@ -2282,10 +2524,10 @@ class Linter {
2282
2524
  ];
2283
2525
  this.times = { passes: [] };
2284
2526
  this.fixPassCount = 0;
2285
- return [
2527
+ return sortLintMessages([
2286
2528
  ...nativeRuleFilter.messages,
2287
2529
  ...customRuleFilter.messages
2288
- ];
2530
+ ]);
2289
2531
  }
2290
2532
 
2291
2533
  verifyAndFix(code, config = {}, options = {}) {
@@ -2294,7 +2536,7 @@ class Linter {
2294
2536
  let fixPassCount = 0;
2295
2537
  for (let pass = 0; pass < MAX_AUTOFIX_PASSES; pass += 1) {
2296
2538
  const messages = this.verify(output, config, options);
2297
- const fixedOutput = applyRuleFixes(output, messages.flatMap((message) => ruleFixItems(message.fix)));
2539
+ const fixedOutput = applyMessageFixes(output, messages);
2298
2540
  if (fixedOutput === output) {
2299
2541
  this.fixPassCount = fixPassCount;
2300
2542
  return {
@@ -2377,9 +2619,17 @@ function parserForConfig(config, parsers, filePath, cwd) {
2377
2619
 
2378
2620
  function parserOptionsForConfig(config, filePath, cwd) {
2379
2621
  const configData = configDataFromConfig(config, filePath, cwd);
2380
- return {
2622
+ const languageOptions = configData.languageOptions ?? {};
2623
+ const parserOptions = {
2381
2624
  ...(configData.parserOptions ?? {}),
2382
- ...(configData.languageOptions?.parserOptions ?? {}),
2625
+ ...(languageOptions.parserOptions ?? {})
2626
+ };
2627
+ return {
2628
+ ...parserOptions,
2629
+ sourceType: languageOptions.sourceType ?? parserOptions.sourceType,
2630
+ ecmaVersion: languageOptions.ecmaVersion ?? parserOptions.ecmaVersion,
2631
+ ecmaFeatures: parserOptions.ecmaFeatures ?? {},
2632
+ globals: { ...(configData.globals ?? {}), ...(languageOptions.globals ?? {}) },
2383
2633
  filename: filePath,
2384
2634
  filePath,
2385
2635
  cwd
@@ -2425,13 +2675,176 @@ function pluginRulesForConfig(config, filePath, cwd) {
2425
2675
  continue;
2426
2676
  }
2427
2677
  for (const [ruleName, rule] of Object.entries(plugin.rules)) {
2428
- rules.set(`${pluginName}/${ruleName}`, rule);
2678
+ const ruleId = `${pluginName}/${ruleName}`;
2679
+ // A native rule takes precedence over a plugin rule mounted under the
2680
+ // same id, so a config copied from ESLint keeps its native speed.
2681
+ if (NATIVE_RULE_ID_SET.has(ruleId)) {
2682
+ continue;
2683
+ }
2684
+ rules.set(ruleId, rule);
2429
2685
  }
2430
2686
  }
2431
2687
  }
2432
2688
  return rules;
2433
2689
  }
2434
2690
 
2691
+ const pluginRuleIdsByConfig = new WeakMap();
2692
+
2693
+ function jsPluginRuleIdsFromConfigs(...configs) {
2694
+ const ids = new Set();
2695
+ for (const config of configs) {
2696
+ if (!config || typeof config !== "object") {
2697
+ continue;
2698
+ }
2699
+ let ruleIds = pluginRuleIdsByConfig.get(config);
2700
+ if (!ruleIds) {
2701
+ ruleIds = [...pluginRulesForConfig(config).keys()];
2702
+ pluginRuleIdsByConfig.set(config, ruleIds);
2703
+ }
2704
+ for (const ruleId of ruleIds) {
2705
+ ids.add(ruleId);
2706
+ }
2707
+ }
2708
+ return ids;
2709
+ }
2710
+
2711
+ function withoutJsPluginRules(rules, jsPluginRuleIds) {
2712
+ if (!rules || typeof rules !== "object" || jsPluginRuleIds.size === 0) {
2713
+ return rules;
2714
+ }
2715
+ const filtered = {};
2716
+ for (const [ruleId, ruleConfig] of Object.entries(rules)) {
2717
+ if (!jsPluginRuleIds.has(ruleId)) {
2718
+ filtered[ruleId] = ruleConfig;
2719
+ }
2720
+ }
2721
+ return filtered;
2722
+ }
2723
+
2724
+ function configDeclaresPlugins(config) {
2725
+ if (Array.isArray(config)) {
2726
+ return config.some((entry) => configDeclaresPlugins(entry));
2727
+ }
2728
+ return Boolean(
2729
+ config
2730
+ && typeof config === "object"
2731
+ && config.plugins
2732
+ && typeof config.plugins === "object"
2733
+ && !Array.isArray(config.plugins)
2734
+ && Object.keys(config.plugins).length > 0
2735
+ );
2736
+ }
2737
+
2738
+ // Cheap pre-check so runs without plugins skip the per-file plugin work. Every
2739
+ // config the native run consulted was read through `options.configCache`.
2740
+ function anyConfigDeclaresPlugins(options) {
2741
+ if (configDeclaresPlugins(options.baseConfig) || configDeclaresPlugins(options.overrideConfig)) {
2742
+ return true;
2743
+ }
2744
+ if (options.noConfig) {
2745
+ return false;
2746
+ }
2747
+ let sawConfig = false;
2748
+ for (const config of options.configCache?.values() ?? []) {
2749
+ if (config && typeof config === "object") {
2750
+ sawConfig = true;
2751
+ if (configDeclaresPlugins(config)) {
2752
+ return true;
2753
+ }
2754
+ }
2755
+ }
2756
+ if (sawConfig) {
2757
+ return false;
2758
+ }
2759
+ const configPath = configPathForOptions(options);
2760
+ return Boolean(configPath) && configDeclaresPlugins(readConfig(configPath, options.cwd, options.configCache));
2761
+ }
2762
+
2763
+ // The native engine receives the JSON form of a config module. Plugin objects
2764
+ // (and their `create()` functions) only survive an in-process import, so a
2765
+ // config module that mounts plugins is loaded again in this process.
2766
+ function liveConfigForPath(configPath, options) {
2767
+ const config = readConfig(configPath, options.cwd, options.configCache);
2768
+ if (!isExecutableConfigPath(configPath) || !configDeclaresPlugins(config)) {
2769
+ return config;
2770
+ }
2771
+ return configModule.readConfigModule(configPath, options.cwd);
2772
+ }
2773
+
2774
+ function pluginConfigPathForFile(options, matchPath, perFileConfig = true) {
2775
+ if (options.noConfig) {
2776
+ return undefined;
2777
+ }
2778
+ return perFileConfig && matchPath ? configPathForFile(options, matchPath) : configPathForOptions(options);
2779
+ }
2780
+
2781
+ function pluginConfigSourcesForFile(options, matchPath, perFileConfig = true) {
2782
+ const cwd = options.cwd ?? process.cwd();
2783
+ const sources = [];
2784
+ if (options.baseConfig) {
2785
+ sources.push({ config: options.baseConfig, cwd });
2786
+ }
2787
+ const configPath = pluginConfigPathForFile(options, matchPath, perFileConfig);
2788
+ if (configPath) {
2789
+ sources.push({ config: liveConfigForPath(configPath, options), cwd: dirname(configPath) });
2790
+ }
2791
+ if (options.overrideConfig) {
2792
+ sources.push({ config: options.overrideConfig, cwd });
2793
+ }
2794
+ return sources;
2795
+ }
2796
+
2797
+ function jsPluginRuleIdsForOptions(options, matchPath) {
2798
+ const configs = [options.baseConfig, options.overrideConfig];
2799
+ if (!options.noConfig) {
2800
+ const configPath = matchPath ? configPathForFile(options, matchPath) : configPathForOptions(options);
2801
+ if (configPath) {
2802
+ configs.push(readConfig(configPath, options.cwd, options.configCache));
2803
+ }
2804
+ }
2805
+ return jsPluginRuleIdsFromConfigs(...configs);
2806
+ }
2807
+
2808
+ function jsPluginRuleMapForFile(options, matchPath, perFileConfig = true) {
2809
+ const rules = new Map();
2810
+ for (const { config, cwd } of pluginConfigSourcesForFile(options, matchPath, perFileConfig)) {
2811
+ for (const [ruleId, rule] of pluginRulesForConfig(config, matchPath, cwd)) {
2812
+ rules.set(ruleId, rule);
2813
+ }
2814
+ }
2815
+ return rules;
2816
+ }
2817
+
2818
+ function pluginRuleEntriesForFile(options, matchPath, ruleMap, perFileConfig = true) {
2819
+ const entries = new Map();
2820
+ for (const { config, cwd } of pluginConfigSourcesForFile(options, matchPath, perFileConfig)) {
2821
+ for (const [ruleId, ruleConfig] of Object.entries(rulesFromConfig(config, matchPath, cwd))) {
2822
+ if (!ruleMap.has(ruleId)) {
2823
+ continue;
2824
+ }
2825
+ const severity = ruleConfigSeverity(ruleConfig);
2826
+ if (severity === 0) {
2827
+ entries.delete(ruleId);
2828
+ continue;
2829
+ }
2830
+ entries.set(ruleId, {
2831
+ ruleId,
2832
+ rule: ruleMap.get(ruleId),
2833
+ severity,
2834
+ options: Array.isArray(ruleConfig) ? ruleConfig.slice(1) : []
2835
+ });
2836
+ }
2837
+ }
2838
+ if (options.rules) {
2839
+ // `--rules` disables everything first and then enables only the listed
2840
+ // rules, keeping configured severities and options like the native path.
2841
+ return Object.keys(rulesFromNativeRuleList(options.rules))
2842
+ .filter((ruleId) => ruleMap.has(ruleId))
2843
+ .map((ruleId) => entries.get(ruleId) ?? { ruleId, rule: ruleMap.get(ruleId), severity: 1, options: [] });
2844
+ }
2845
+ return [...entries.values()];
2846
+ }
2847
+
2435
2848
  function matchingConfigEntries(config, filePath = undefined, cwd = undefined) {
2436
2849
  if (!config) {
2437
2850
  return [];
@@ -2483,7 +2896,7 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
2483
2896
  }
2484
2897
  let result = results.find((item) => item.filePath === filePath);
2485
2898
  const effectiveCode = typeof result?.output === "string" ? result.output : code;
2486
- const sourceCode = createLinterSourceCode(effectiveCode);
2899
+ const sourceCode = createLinterSourceCode(effectiveCode, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
2487
2900
  const messages = runCustomLinterRules(customRules, sourceCode, {
2488
2901
  cwd: options.cwd,
2489
2902
  filename: filePath,
@@ -2498,13 +2911,74 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
2498
2911
  result = emptyESLintResult(filePath, effectiveCode);
2499
2912
  results.push(result);
2500
2913
  }
2501
- result.messages.push(...filtered.messages);
2914
+ if (relintAfterCustomFixes(result, effectiveCode, filePath, customRules, config, rules, options)) {
2915
+ return results;
2916
+ }
2917
+ result.messages = sortLintMessages([...result.messages, ...filtered.messages]);
2502
2918
  result.suppressedMessages.push(...filtered.suppressedMessages);
2503
2919
  applyResultFixes(result, effectiveCode, options);
2504
2920
  finalizeESLintResult(result);
2505
2921
  return results;
2506
2922
  }
2507
2923
 
2924
+ // When custom fixes change a file, the native messages computed earlier are
2925
+ // stale. Re-lint the fixed text with native rules and custom rules together
2926
+ // until neither changes it, then rebuild the result from that final pass.
2927
+ function relintAfterCustomFixes(result, code, filePath, customRules, config, rules, options) {
2928
+ if (!options.fix) {
2929
+ return false;
2930
+ }
2931
+ const nativeOptions = {
2932
+ ...lintOptionsWithoutCustomRules(options, rules),
2933
+ filePath,
2934
+ filename: undefined,
2935
+ fix: true,
2936
+ useDefaultConfigTargets: undefined,
2937
+ pluginTextFilePath: undefined
2938
+ };
2939
+ const parserOptions = { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true };
2940
+ const customLintOptions = {
2941
+ cwd: options.cwd,
2942
+ filename: filePath,
2943
+ config: calculatedConfig({ cwd: options.cwd, noConfig: true, baseConfig: options.baseConfig, overrideConfig: options.overrideConfig }, filePath)
2944
+ };
2945
+ const lintCustom = (text) => {
2946
+ const sourceCode = createLinterSourceCode(text, null, parserOptions);
2947
+ return applyDisableDirectives(runCustomLinterRules(customRules, sourceCode, customLintOptions), sourceCode);
2948
+ };
2949
+
2950
+ let output = code;
2951
+ let custom = lintCustom(output);
2952
+ let fixedOutput = applyMessageFixes(output, custom.messages);
2953
+ if (fixedOutput === output) {
2954
+ return false;
2955
+ }
2956
+ let nativeReport;
2957
+ for (let pass = 0; pass < MAX_AUTOFIX_PASSES && fixedOutput !== output; pass += 1) {
2958
+ nativeReport = lintText(fixedOutput, nativeOptions);
2959
+ output = nativeReport.outputs?.find((fixed) => fixed.filePath === filePath)?.output ?? fixedOutput;
2960
+ custom = lintCustom(output);
2961
+ fixedOutput = applyMessageFixes(output, custom.messages);
2962
+ }
2963
+ if (output !== fixedOutput) {
2964
+ output = fixedOutput;
2965
+ nativeReport = lintText(output, nativeOptions);
2966
+ custom = lintCustom(output);
2967
+ }
2968
+
2969
+ const nativeResult = reportToESLintResults(nativeReport, {
2970
+ source: result.source,
2971
+ filePath,
2972
+ includeEmptyTextResult: true,
2973
+ ruleSeverityForFile: (path) => ruleSeverityMapForOptions(nativeOptions, path)
2974
+ })[0];
2975
+ result.messages = sortLintMessages([...(nativeResult?.messages ?? []), ...custom.messages]);
2976
+ result.suppressedMessages = [...(nativeResult?.suppressedMessages ?? []), ...custom.suppressedMessages];
2977
+ result.output = output;
2978
+ finalizeESLintResult(result);
2979
+ return true;
2980
+ }
2981
+
2508
2982
  function appendCustomLintFileMessages(results, config, options) {
2509
2983
  for (const result of results) {
2510
2984
  const filePath = result.filePath;
@@ -2521,7 +2995,7 @@ function appendCustomLintFileMessages(results, config, options) {
2521
2995
  continue;
2522
2996
  }
2523
2997
  }
2524
- const sourceCode = createLinterSourceCode(code);
2998
+ const sourceCode = createLinterSourceCode(code, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
2525
2999
  const messages = runCustomLinterRules(customRules, sourceCode, {
2526
3000
  cwd: options.cwd,
2527
3001
  filename: filePath,
@@ -2531,7 +3005,10 @@ function appendCustomLintFileMessages(results, config, options) {
2531
3005
  if (filtered.messages.length === 0 && filtered.suppressedMessages.length === 0) {
2532
3006
  continue;
2533
3007
  }
2534
- result.messages.push(...filtered.messages);
3008
+ if (relintAfterCustomFixes(result, code, filePath, customRules, config, rules, options)) {
3009
+ continue;
3010
+ }
3011
+ result.messages = sortLintMessages([...result.messages, ...filtered.messages]);
2535
3012
  result.suppressedMessages.push(...filtered.suppressedMessages);
2536
3013
  applyResultFixes(result, code, options);
2537
3014
  finalizeESLintResult(result);
@@ -2539,11 +3016,22 @@ function appendCustomLintFileMessages(results, config, options) {
2539
3016
  return results;
2540
3017
  }
2541
3018
 
3019
+ // ESLint orders a file's messages by position; native and plugin messages
3020
+ // arrive as two separate batches.
3021
+ function sortLintMessages(messages) {
3022
+ return messages
3023
+ .map((message, index) => ({ message, index }))
3024
+ .sort((left, right) => (left.message.line ?? 0) - (right.message.line ?? 0)
3025
+ || (left.message.column ?? 0) - (right.message.column ?? 0)
3026
+ || left.index - right.index)
3027
+ .map(({ message }) => message);
3028
+ }
3029
+
2542
3030
  function applyResultFixes(result, code, options) {
2543
3031
  if (!options.fix) {
2544
3032
  return;
2545
3033
  }
2546
- const output = applyRuleFixes(code, result.messages.flatMap((message) => ruleFixItems(message.fix)));
3034
+ const output = applyMessageFixes(code, result.messages);
2547
3035
  if (output !== code) {
2548
3036
  result.output = output;
2549
3037
  }
@@ -2556,14 +3044,39 @@ function runCustomLinterRules(ruleEntries, sourceCode, options) {
2556
3044
 
2557
3045
  const messages = [];
2558
3046
  const program = sourceCode.ast ?? { type: "Program", range: [0, sourceCode.text.length] };
3047
+ const hasAst = Array.isArray(program.body);
3048
+ const listenersBySelector = new Map();
2559
3049
  for (const ruleEntry of ruleEntries) {
3050
+ if (typeof ruleEntry.rule !== "function" && typeof ruleEntry.rule?.create !== "function") {
3051
+ throw new Error(
3052
+ `utoo-lint: rule "${ruleEntry.ruleId}" has no create() function. ` +
3053
+ "ESLint plugins must be mounted from a utlint.config.ts (or .js) module so their rule objects are available at lint time."
3054
+ );
3055
+ }
2560
3056
  const context = createCustomRuleContext(ruleEntry, sourceCode, options, messages);
2561
3057
  const listeners = customRuleListeners(ruleEntry.rule, context);
2562
- if (customRuleChildNodes(program, sourceCode.visitorKeys).length > 0) {
2563
- traverseCustomRuleAst(program, listeners, context, sourceCode.visitorKeys);
2564
- } else {
3058
+ if (!hasAst) {
2565
3059
  runCustomRuleTokenListeners(program, sourceCode.tokens, listeners, context);
3060
+ continue;
2566
3061
  }
3062
+ for (const [selector, listener] of Object.entries(listeners)) {
3063
+ if (typeof listener !== "function") {
3064
+ continue;
3065
+ }
3066
+ if (!listenersBySelector.has(selector)) {
3067
+ listenersBySelector.set(selector, []);
3068
+ }
3069
+ listenersBySelector.get(selector).push({
3070
+ ruleId: ruleEntry.ruleId,
3071
+ listener(node) {
3072
+ context.setCurrentNode(node);
3073
+ listener(node);
3074
+ }
3075
+ });
3076
+ }
3077
+ }
3078
+ if (hasAst && listenersBySelector.size > 0) {
3079
+ pluginRunner.runRuleListeners(program, listenersBySelector, { visitorKeys: sourceCode.visitorKeys });
2567
3080
  }
2568
3081
  return messages;
2569
3082
  }
@@ -2720,231 +3233,6 @@ function disableDirectiveMatchesRule(directive, ruleId) {
2720
3233
  return directive.ruleId == null || directive.ruleId === ruleId;
2721
3234
  }
2722
3235
 
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
3236
  function runCustomRuleTokenListeners(program, tokens, listeners, context) {
2949
3237
  if (typeof listeners.Program === "function") {
2950
3238
  context.setCurrentNode(program);
@@ -2979,7 +3267,7 @@ function createCustomRuleContext(ruleEntry, sourceCode, options, messages) {
2979
3267
  let currentNode = sourceCode.ast ?? null;
2980
3268
  const context = {
2981
3269
  id: ruleEntry.ruleId,
2982
- options: ruleEntry.options,
3270
+ options: pluginRunner.applyRuleOptionDefaults(ruleEntry.rule, ruleEntry.options),
2983
3271
  filename,
2984
3272
  physicalFilename: filename,
2985
3273
  cwd,
@@ -3197,7 +3485,7 @@ class SourceCode {
3197
3485
  this.text = textOrConfig.text;
3198
3486
  this.ast = textOrConfig.ast ?? null;
3199
3487
  this.parserServices = textOrConfig.parserServices ?? {};
3200
- this.scopeManager = textOrConfig.scopeManager ?? null;
3488
+ defineScopeManager(this, textOrConfig.scopeManager ?? null);
3201
3489
  this.visitorKeys = textOrConfig.visitorKeys ?? null;
3202
3490
  this.hasBOM = Boolean(textOrConfig.hasBOM);
3203
3491
  } else {
@@ -3389,7 +3677,17 @@ class SourceCode {
3389
3677
  }
3390
3678
 
3391
3679
  getAncestors(node) {
3392
- return node ? sourceAncestorsForNode(this.ast, node) ?? [] : [];
3680
+ if (!node) {
3681
+ return [];
3682
+ }
3683
+ if (node.parent !== undefined) {
3684
+ const ancestors = [];
3685
+ for (let current = node.parent; current; current = current.parent) {
3686
+ ancestors.unshift(current);
3687
+ }
3688
+ return ancestors;
3689
+ }
3690
+ return sourceAncestorsForNode(this.ast, node) ?? [];
3393
3691
  }
3394
3692
 
3395
3693
  getDeclaredVariables(node) {
@@ -3397,10 +3695,20 @@ class SourceCode {
3397
3695
  }
3398
3696
 
3399
3697
  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;
3698
+ const scopeManager = this.scopeManager;
3699
+ if (!scopeManager || typeof scopeManager.acquire !== "function") {
3700
+ return null;
3701
+ }
3702
+ if (node) {
3703
+ const inner = node.type !== "Program";
3704
+ for (let current = node; current; current = current.parent) {
3705
+ const scope = scopeManager.acquire(current, inner);
3706
+ if (scope) {
3707
+ return scope.type === "function-expression-name" ? scope.childScopes[0] : scope;
3708
+ }
3709
+ }
3402
3710
  }
3403
- return this.scopeManager?.globalScope ?? null;
3711
+ return scopeManager.scopes?.[0] ?? scopeManager.globalScope ?? null;
3404
3712
  }
3405
3713
 
3406
3714
  markVariableAsUsed(name, node) {
@@ -3436,6 +3744,31 @@ class SourceCode {
3436
3744
  }
3437
3745
  }
3438
3746
 
3747
+ function defineScopeManager(sourceCode, scopeManager) {
3748
+ if (typeof scopeManager !== "function") {
3749
+ sourceCode.scopeManager = scopeManager;
3750
+ return;
3751
+ }
3752
+ // Scope analysis is comparatively expensive; run it only when a rule asks.
3753
+ let computed = false;
3754
+ let value = null;
3755
+ Object.defineProperty(sourceCode, "scopeManager", {
3756
+ configurable: true,
3757
+ enumerable: true,
3758
+ get() {
3759
+ if (!computed) {
3760
+ computed = true;
3761
+ value = scopeManager() ?? null;
3762
+ }
3763
+ return value;
3764
+ },
3765
+ set(next) {
3766
+ computed = true;
3767
+ value = next;
3768
+ }
3769
+ });
3770
+ }
3771
+
3439
3772
  function sourceLineStartIndices(text) {
3440
3773
  const indices = [0];
3441
3774
  for (let index = 0; index < text.length; index += 1) {
@@ -3725,12 +4058,10 @@ class RuleTester {
3725
4058
  RuleTester[testCase.only ? "itOnly" : "it"](testCase.name ?? testCase.code, () => {
3726
4059
  const config = ruleTesterConfig(ruleName, this.config, testCase, "error");
3727
4060
  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;
4061
+ const messages = linter.verify(testCase.code, config, options);
3732
4062
  assertRuleTesterErrors(testCase, messages, rule);
3733
- assertRuleTesterOutput(testCase, result.output);
4063
+ // Like ESLint's RuleTester, `output` is the result of one autofix pass.
4064
+ assertRuleTesterOutput(testCase, Object.hasOwn(testCase, "output") ? applyMessageFixes(testCase.code, messages) : testCase.code);
3734
4065
  });
3735
4066
  }
3736
4067
  });
@@ -3877,6 +4208,47 @@ function applyRuleFixes(code, fix) {
3877
4208
  .reduce((output, item) => output.slice(0, item.range[0]) + item.text + output.slice(item.range[1]), code);
3878
4209
  }
3879
4210
 
4211
+ // Applies one non-overlapping fix per message, like ESLint's SourceCodeFixer.
4212
+ // Fixes that overlap an earlier one are left for a later pass.
4213
+ function applyMessageFixes(code, messages) {
4214
+ const fixes = [];
4215
+ for (const message of messages) {
4216
+ const items = ruleFixItems(message?.fix)
4217
+ .slice()
4218
+ .sort((left, right) => left.range[0] - right.range[0] || left.range[1] - right.range[1]);
4219
+ if (items.length === 0) {
4220
+ continue;
4221
+ }
4222
+ let text = "";
4223
+ let cursor = items[0].range[0];
4224
+ let valid = true;
4225
+ for (const item of items) {
4226
+ if (item.range[0] < cursor) {
4227
+ valid = false;
4228
+ break;
4229
+ }
4230
+ text += code.slice(cursor, item.range[0]) + item.text;
4231
+ cursor = item.range[1];
4232
+ }
4233
+ if (valid) {
4234
+ fixes.push({ range: [items[0].range[0], cursor], text });
4235
+ }
4236
+ }
4237
+ fixes.sort((left, right) => left.range[0] - right.range[0] || left.range[1] - right.range[1]);
4238
+ let output = "";
4239
+ let position = 0;
4240
+ let lastEnd = -Infinity;
4241
+ for (const fix of fixes) {
4242
+ if (lastEnd >= fix.range[0]) {
4243
+ continue;
4244
+ }
4245
+ output += code.slice(position, fix.range[0]) + fix.text;
4246
+ position = fix.range[1];
4247
+ lastEnd = fix.range[1];
4248
+ }
4249
+ return output + code.slice(position);
4250
+ }
4251
+
3880
4252
  function ruleFixItems(fix) {
3881
4253
  if (!fix) {
3882
4254
  return [];
@@ -3929,10 +4301,29 @@ function createLinterSourceCode(text, parser = null, parserOptions = {}) {
3929
4301
  });
3930
4302
  }
3931
4303
 
3932
- function parseSourceCode(text, parser, parserOptions) {
3933
- if (!parser) {
4304
+ function parseSourceCodeWithDefaultParser(text, parserOptions) {
4305
+ let parsed;
4306
+ try {
4307
+ parsed = estreeParser.parseForESLint(text, parserOptions);
4308
+ } catch (error) {
4309
+ if (parserOptions.requireParser) {
4310
+ throw error;
4311
+ }
3934
4312
  return null;
3935
4313
  }
4314
+ return new SourceCode({
4315
+ text,
4316
+ ast: parsed.ast,
4317
+ parserServices: parsed.services ?? {},
4318
+ scopeManager: parsed.scopeManager ?? null,
4319
+ visitorKeys: parsed.visitorKeys ?? null
4320
+ });
4321
+ }
4322
+
4323
+ function parseSourceCode(text, parser, parserOptions = {}) {
4324
+ if (!parser) {
4325
+ return parserOptions.tokensOnly ? null : parseSourceCodeWithDefaultParser(text, parserOptions);
4326
+ }
3936
4327
  if (typeof parser.parseForESLint === "function") {
3937
4328
  const result = parser.parseForESLint(text, parserOptions) ?? {};
3938
4329
  return new SourceCode({
@@ -4198,7 +4589,7 @@ function applyCompatibilityDisableDirectives(result, textOptions) {
4198
4589
  return;
4199
4590
  }
4200
4591
 
4201
- const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source));
4592
+ const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source, null, { tokensOnly: true }));
4202
4593
  result.messages = filtered.messages;
4203
4594
  result.suppressedMessages.push(...filtered.suppressedMessages);
4204
4595
  }
@@ -4932,6 +5323,12 @@ function diagnosticToESLintMessage(diagnostic, ruleSeverities) {
4932
5323
  column: diagnostic.column,
4933
5324
  nodeType: null
4934
5325
  };
5326
+ if (typeof diagnostic.endLine === "number") {
5327
+ message.endLine = diagnostic.endLine;
5328
+ }
5329
+ if (typeof diagnostic.endColumn === "number") {
5330
+ message.endColumn = diagnostic.endColumn;
5331
+ }
4935
5332
  if (diagnostic.endLine != null) {
4936
5333
  message.endLine = diagnostic.endLine;
4937
5334
  }