@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.js CHANGED
@@ -7,11 +7,16 @@ import { fileURLToPath } from "node:url";
7
7
 
8
8
  import { resolveBinary } from "./lib/binary.js";
9
9
  import { createLegacyConfigResolver } from "./lib/legacy-config.cjs";
10
+ import { nativeFlatConfigEntries, nativeRules } from "./lib/native-rule-options.cjs";
10
11
  import { formatESLintResults } from "./lib/stylish-formatter.cjs";
11
12
  import {
12
13
  findConfigPath as findConfigPathFromDirectory,
14
+ isExecutableConfigPath,
13
15
  readConfig
14
16
  } from "./lib/config-loader.js";
17
+ import configModule from "./lib/config-module.cjs";
18
+ import estreeParser from "./lib/estree-parser.cjs";
19
+ import pluginRunner from "./lib/plugin-runner.cjs";
15
20
 
16
21
  export { platformPackageName, resolveBinary } from "./lib/binary.js";
17
22
 
@@ -28,10 +33,6 @@ const JS_KEYWORDS = new Set([
28
33
  "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try", "typeof",
29
34
  "var", "void", "while", "with", "yield"
30
35
  ]);
31
- const AST_TRAVERSAL_SKIP_KEYS = new Set([
32
- "type", "parent", "loc", "range", "tokens", "comments", "leadingComments", "trailingComments",
33
- "innerComments", "raw", "value", "name", "regex", "bigint", "errors"
34
- ]);
35
36
  const RULE_TESTER_INITIAL_CONFIG = { rules: {} };
36
37
  let ruleTesterDefaultConfig = { rules: {} };
37
38
  let ruleTesterDescribe = null;
@@ -483,6 +484,7 @@ const NATIVE_ONLY_RULE_IDS = [
483
484
  "semantic-errors"
484
485
  ];
485
486
  const NATIVE_RULE_IDS = [...BUILTIN_RULE_IDS, ...NATIVE_ONLY_RULE_IDS];
487
+ const NATIVE_RULE_ID_SET = new Set(NATIVE_RULE_IDS);
486
488
  const FIXABLE_BUILTIN_RULE_IDS = new Set([
487
489
  "@typescript-eslint/no-explicit-any",
488
490
  "jest/no-deprecated-functions",
@@ -610,15 +612,15 @@ export class UtooLint {
610
612
  throw new Error("'results' must be an array");
611
613
  }
612
614
  const options = eslintConstructorOptions(this.options);
613
- const customRuleConfig = [options.baseConfig, options.overrideConfig].filter(Boolean);
614
615
  const rulesByFilePath = new Map();
616
+ const cachedOptions = withConfigCache(options);
615
617
  return rulesMetaForResults(results, (ruleId, result) => {
616
- if (customRuleConfig.length === 0 || typeof result.filePath !== "string") {
618
+ if (typeof result.filePath !== "string") {
617
619
  return undefined;
618
620
  }
619
621
  const filePath = normalizeESLintFilePath(result.filePath, options.cwd);
620
622
  if (!rulesByFilePath.has(filePath)) {
621
- rulesByFilePath.set(filePath, customRuleMapForConfig(customRuleConfig, new Map(), filePath, options.cwd));
623
+ rulesByFilePath.set(filePath, jsPluginRuleMapForFile(cachedOptions, filePath));
622
624
  }
623
625
  return rulesByFilePath.get(filePath).get(ruleId)?.meta;
624
626
  });
@@ -669,14 +671,14 @@ export class Linter {
669
671
  const verifyOptions = typeof options === "string" ? { filename: options } : { ...options };
670
672
  const filePath = verifyOptions.filename ?? verifyOptions.filePath ?? "input.js";
671
673
  const normalizedFilePath = normalizeESLintFilePath(filePath, verifyOptions.cwd);
674
+ const customRuleMap = customRuleMapForConfig(config, this.rules, normalizedFilePath, verifyOptions.cwd);
675
+ const customRuleFilterMap = customRuleMapForConfig(config, this.rules);
676
+ const customRules = customRuleEntriesForConfig(config, customRuleMap, normalizedFilePath, verifyOptions.cwd);
672
677
  const sourceCode = createLinterSourceCode(
673
678
  code,
674
679
  parserForConfig(config, this.parsers, normalizedFilePath, verifyOptions.cwd),
675
- parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd)
680
+ { ...parserOptionsForConfig(config, normalizedFilePath, verifyOptions.cwd), requireParser: customRules.length > 0 }
676
681
  );
677
- const customRuleMap = customRuleMapForConfig(config, this.rules, normalizedFilePath, verifyOptions.cwd);
678
- const customRuleFilterMap = customRuleMapForConfig(config, this.rules);
679
- const customRules = customRuleEntriesForConfig(config, customRuleMap, normalizedFilePath, verifyOptions.cwd);
680
682
  const nativeConfig = configWithoutCustomRules(config, customRuleFilterMap);
681
683
  const lintOptions = {
682
684
  cwd: verifyOptions.cwd,
@@ -706,10 +708,10 @@ export class Linter {
706
708
  ];
707
709
  this.times = { passes: [] };
708
710
  this.fixPassCount = 0;
709
- return [
711
+ return sortLintMessages([
710
712
  ...nativeRuleFilter.messages,
711
713
  ...customRuleFilter.messages
712
- ];
714
+ ]);
713
715
  }
714
716
 
715
717
  verifyAndFix(code, config = {}, options = {}) {
@@ -718,7 +720,7 @@ export class Linter {
718
720
  let fixPassCount = 0;
719
721
  for (let pass = 0; pass < MAX_AUTOFIX_PASSES; pass += 1) {
720
722
  const messages = this.verify(output, config, options);
721
- const fixedOutput = applyRuleFixes(output, messages.flatMap((message) => ruleFixItems(message.fix)));
723
+ const fixedOutput = applyMessageFixes(output, messages);
722
724
  if (fixedOutput === output) {
723
725
  this.fixPassCount = fixPassCount;
724
726
  return {
@@ -801,9 +803,17 @@ function parserForConfig(config, parsers, filePath, cwd) {
801
803
 
802
804
  function parserOptionsForConfig(config, filePath, cwd) {
803
805
  const configData = configDataFromConfig(config, filePath, cwd);
804
- return {
806
+ const languageOptions = configData.languageOptions ?? {};
807
+ const parserOptions = {
805
808
  ...(configData.parserOptions ?? {}),
806
- ...(configData.languageOptions?.parserOptions ?? {}),
809
+ ...(languageOptions.parserOptions ?? {})
810
+ };
811
+ return {
812
+ ...parserOptions,
813
+ sourceType: languageOptions.sourceType ?? parserOptions.sourceType,
814
+ ecmaVersion: languageOptions.ecmaVersion ?? parserOptions.ecmaVersion,
815
+ ecmaFeatures: parserOptions.ecmaFeatures ?? {},
816
+ globals: { ...(configData.globals ?? {}), ...(languageOptions.globals ?? {}) },
807
817
  filename: filePath,
808
818
  filePath,
809
819
  cwd
@@ -849,13 +859,176 @@ function pluginRulesForConfig(config, filePath, cwd) {
849
859
  continue;
850
860
  }
851
861
  for (const [ruleName, rule] of Object.entries(plugin.rules)) {
852
- rules.set(`${pluginName}/${ruleName}`, rule);
862
+ const ruleId = `${pluginName}/${ruleName}`;
863
+ // A native rule takes precedence over a plugin rule mounted under the
864
+ // same id, so a config copied from ESLint keeps its native speed.
865
+ if (NATIVE_RULE_ID_SET.has(ruleId)) {
866
+ continue;
867
+ }
868
+ rules.set(ruleId, rule);
869
+ }
870
+ }
871
+ }
872
+ return rules;
873
+ }
874
+
875
+ const pluginRuleIdsByConfig = new WeakMap();
876
+
877
+ function jsPluginRuleIdsFromConfigs(...configs) {
878
+ const ids = new Set();
879
+ for (const config of configs) {
880
+ if (!config || typeof config !== "object") {
881
+ continue;
882
+ }
883
+ let ruleIds = pluginRuleIdsByConfig.get(config);
884
+ if (!ruleIds) {
885
+ ruleIds = [...pluginRulesForConfig(config).keys()];
886
+ pluginRuleIdsByConfig.set(config, ruleIds);
887
+ }
888
+ for (const ruleId of ruleIds) {
889
+ ids.add(ruleId);
890
+ }
891
+ }
892
+ return ids;
893
+ }
894
+
895
+ function withoutJsPluginRules(rules, jsPluginRuleIds) {
896
+ if (!rules || typeof rules !== "object" || jsPluginRuleIds.size === 0) {
897
+ return rules;
898
+ }
899
+ const filtered = {};
900
+ for (const [ruleId, ruleConfig] of Object.entries(rules)) {
901
+ if (!jsPluginRuleIds.has(ruleId)) {
902
+ filtered[ruleId] = ruleConfig;
903
+ }
904
+ }
905
+ return filtered;
906
+ }
907
+
908
+ function configDeclaresPlugins(config) {
909
+ if (Array.isArray(config)) {
910
+ return config.some((entry) => configDeclaresPlugins(entry));
911
+ }
912
+ return Boolean(
913
+ config
914
+ && typeof config === "object"
915
+ && config.plugins
916
+ && typeof config.plugins === "object"
917
+ && !Array.isArray(config.plugins)
918
+ && Object.keys(config.plugins).length > 0
919
+ );
920
+ }
921
+
922
+ // Cheap pre-check so runs without plugins skip the per-file plugin work. Every
923
+ // config the native run consulted was read through `options.configCache`.
924
+ function anyConfigDeclaresPlugins(options) {
925
+ if (configDeclaresPlugins(options.baseConfig) || configDeclaresPlugins(options.overrideConfig)) {
926
+ return true;
927
+ }
928
+ if (options.noConfig) {
929
+ return false;
930
+ }
931
+ let sawConfig = false;
932
+ for (const config of options.configCache?.values() ?? []) {
933
+ if (config && typeof config === "object") {
934
+ sawConfig = true;
935
+ if (configDeclaresPlugins(config)) {
936
+ return true;
853
937
  }
854
938
  }
855
939
  }
940
+ if (sawConfig) {
941
+ return false;
942
+ }
943
+ const configPath = configPathForOptions(options);
944
+ return Boolean(configPath) && configDeclaresPlugins(readConfig(configPath, options.cwd, options.configCache));
945
+ }
946
+
947
+ // The native engine receives the JSON form of a config module. Plugin objects
948
+ // (and their `create()` functions) only survive an in-process import, so a
949
+ // config module that mounts plugins is loaded again in this process.
950
+ function liveConfigForPath(configPath, options) {
951
+ const config = readConfig(configPath, options.cwd, options.configCache);
952
+ if (!isExecutableConfigPath(configPath) || !configDeclaresPlugins(config)) {
953
+ return config;
954
+ }
955
+ return configModule.readConfigModule(configPath, options.cwd);
956
+ }
957
+
958
+ function pluginConfigPathForFile(options, matchPath, perFileConfig = true) {
959
+ if (options.noConfig) {
960
+ return undefined;
961
+ }
962
+ return perFileConfig && matchPath ? configPathForFile(options, matchPath) : configPathForOptions(options);
963
+ }
964
+
965
+ function pluginConfigSourcesForFile(options, matchPath, perFileConfig = true) {
966
+ const cwd = options.cwd ?? process.cwd();
967
+ const sources = [];
968
+ if (options.baseConfig) {
969
+ sources.push({ config: options.baseConfig, cwd });
970
+ }
971
+ const configPath = pluginConfigPathForFile(options, matchPath, perFileConfig);
972
+ if (configPath) {
973
+ sources.push({ config: liveConfigForPath(configPath, options), cwd: dirname(configPath) });
974
+ }
975
+ if (options.overrideConfig) {
976
+ sources.push({ config: options.overrideConfig, cwd });
977
+ }
978
+ return sources;
979
+ }
980
+
981
+ function jsPluginRuleIdsForOptions(options, matchPath) {
982
+ const configs = [options.baseConfig, options.overrideConfig];
983
+ if (!options.noConfig) {
984
+ const configPath = matchPath ? configPathForFile(options, matchPath) : configPathForOptions(options);
985
+ if (configPath) {
986
+ configs.push(readConfig(configPath, options.cwd, options.configCache));
987
+ }
988
+ }
989
+ return jsPluginRuleIdsFromConfigs(...configs);
990
+ }
991
+
992
+ function jsPluginRuleMapForFile(options, matchPath, perFileConfig = true) {
993
+ const rules = new Map();
994
+ for (const { config, cwd } of pluginConfigSourcesForFile(options, matchPath, perFileConfig)) {
995
+ for (const [ruleId, rule] of pluginRulesForConfig(config, matchPath, cwd)) {
996
+ rules.set(ruleId, rule);
997
+ }
998
+ }
856
999
  return rules;
857
1000
  }
858
1001
 
1002
+ function pluginRuleEntriesForFile(options, matchPath, ruleMap, perFileConfig = true) {
1003
+ const entries = new Map();
1004
+ for (const { config, cwd } of pluginConfigSourcesForFile(options, matchPath, perFileConfig)) {
1005
+ for (const [ruleId, ruleConfig] of Object.entries(rulesFromConfig(config, matchPath, cwd))) {
1006
+ if (!ruleMap.has(ruleId)) {
1007
+ continue;
1008
+ }
1009
+ const severity = ruleConfigSeverity(ruleConfig);
1010
+ if (severity === 0) {
1011
+ entries.delete(ruleId);
1012
+ continue;
1013
+ }
1014
+ entries.set(ruleId, {
1015
+ ruleId,
1016
+ rule: ruleMap.get(ruleId),
1017
+ severity,
1018
+ options: Array.isArray(ruleConfig) ? ruleConfig.slice(1) : []
1019
+ });
1020
+ }
1021
+ }
1022
+ if (options.rules) {
1023
+ // `--rules` disables everything first and then enables only the listed
1024
+ // rules, keeping configured severities and options like the native path.
1025
+ return Object.keys(rulesFromNativeRuleList(options.rules))
1026
+ .filter((ruleId) => ruleMap.has(ruleId))
1027
+ .map((ruleId) => entries.get(ruleId) ?? { ruleId, rule: ruleMap.get(ruleId), severity: 1, options: [] });
1028
+ }
1029
+ return [...entries.values()];
1030
+ }
1031
+
859
1032
  function matchingConfigEntries(config, filePath = undefined, cwd = undefined) {
860
1033
  if (!config) {
861
1034
  return [];
@@ -907,7 +1080,7 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
907
1080
  }
908
1081
  let result = results.find((item) => item.filePath === filePath);
909
1082
  const effectiveCode = typeof result?.output === "string" ? result.output : code;
910
- const sourceCode = createLinterSourceCode(effectiveCode);
1083
+ const sourceCode = createLinterSourceCode(effectiveCode, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
911
1084
  const messages = runCustomLinterRules(customRules, sourceCode, {
912
1085
  cwd: options.cwd,
913
1086
  filename: filePath,
@@ -922,13 +1095,74 @@ function appendCustomLintTextMessages(results, code, filePath, config, rules, op
922
1095
  result = emptyESLintResult(filePath, effectiveCode);
923
1096
  results.push(result);
924
1097
  }
925
- result.messages.push(...filtered.messages);
1098
+ if (relintAfterCustomFixes(result, effectiveCode, filePath, customRules, config, rules, options)) {
1099
+ return results;
1100
+ }
1101
+ result.messages = sortLintMessages([...result.messages, ...filtered.messages]);
926
1102
  result.suppressedMessages.push(...filtered.suppressedMessages);
927
1103
  applyResultFixes(result, effectiveCode, options);
928
1104
  finalizeESLintResult(result);
929
1105
  return results;
930
1106
  }
931
1107
 
1108
+ // When custom fixes change a file, the native messages computed earlier are
1109
+ // stale. Re-lint the fixed text with native rules and custom rules together
1110
+ // until neither changes it, then rebuild the result from that final pass.
1111
+ function relintAfterCustomFixes(result, code, filePath, customRules, config, rules, options) {
1112
+ if (!options.fix) {
1113
+ return false;
1114
+ }
1115
+ const nativeOptions = {
1116
+ ...lintOptionsWithoutCustomRules(options, rules),
1117
+ filePath,
1118
+ filename: undefined,
1119
+ fix: true,
1120
+ useDefaultConfigTargets: undefined,
1121
+ pluginTextFilePath: undefined
1122
+ };
1123
+ const parserOptions = { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true };
1124
+ const customLintOptions = {
1125
+ cwd: options.cwd,
1126
+ filename: filePath,
1127
+ config: calculatedConfig({ cwd: options.cwd, noConfig: true, baseConfig: options.baseConfig, overrideConfig: options.overrideConfig }, filePath)
1128
+ };
1129
+ const lintCustom = (text) => {
1130
+ const sourceCode = createLinterSourceCode(text, null, parserOptions);
1131
+ return applyDisableDirectives(runCustomLinterRules(customRules, sourceCode, customLintOptions), sourceCode);
1132
+ };
1133
+
1134
+ let output = code;
1135
+ let custom = lintCustom(output);
1136
+ let fixedOutput = applyMessageFixes(output, custom.messages);
1137
+ if (fixedOutput === output) {
1138
+ return false;
1139
+ }
1140
+ let nativeReport;
1141
+ for (let pass = 0; pass < MAX_AUTOFIX_PASSES && fixedOutput !== output; pass += 1) {
1142
+ nativeReport = lintText(fixedOutput, nativeOptions);
1143
+ output = nativeReport.outputs?.find((fixed) => fixed.filePath === filePath)?.output ?? fixedOutput;
1144
+ custom = lintCustom(output);
1145
+ fixedOutput = applyMessageFixes(output, custom.messages);
1146
+ }
1147
+ if (output !== fixedOutput) {
1148
+ output = fixedOutput;
1149
+ nativeReport = lintText(output, nativeOptions);
1150
+ custom = lintCustom(output);
1151
+ }
1152
+
1153
+ const nativeResult = reportToESLintResults(nativeReport, {
1154
+ source: result.source,
1155
+ filePath,
1156
+ includeEmptyTextResult: true,
1157
+ ruleSeverityForFile: (path) => ruleSeverityMapForOptions(nativeOptions, path)
1158
+ })[0];
1159
+ result.messages = sortLintMessages([...(nativeResult?.messages ?? []), ...custom.messages]);
1160
+ result.suppressedMessages = [...(nativeResult?.suppressedMessages ?? []), ...custom.suppressedMessages];
1161
+ result.output = output;
1162
+ finalizeESLintResult(result);
1163
+ return true;
1164
+ }
1165
+
932
1166
  function appendCustomLintFileMessages(results, config, options) {
933
1167
  for (const result of results) {
934
1168
  const filePath = result.filePath;
@@ -945,7 +1179,7 @@ function appendCustomLintFileMessages(results, config, options) {
945
1179
  continue;
946
1180
  }
947
1181
  }
948
- const sourceCode = createLinterSourceCode(code);
1182
+ const sourceCode = createLinterSourceCode(code, null, { ...parserOptionsForConfig(config, filePath, options.cwd), requireParser: true });
949
1183
  const messages = runCustomLinterRules(customRules, sourceCode, {
950
1184
  cwd: options.cwd,
951
1185
  filename: filePath,
@@ -955,7 +1189,10 @@ function appendCustomLintFileMessages(results, config, options) {
955
1189
  if (filtered.messages.length === 0 && filtered.suppressedMessages.length === 0) {
956
1190
  continue;
957
1191
  }
958
- result.messages.push(...filtered.messages);
1192
+ if (relintAfterCustomFixes(result, code, filePath, customRules, config, rules, options)) {
1193
+ continue;
1194
+ }
1195
+ result.messages = sortLintMessages([...result.messages, ...filtered.messages]);
959
1196
  result.suppressedMessages.push(...filtered.suppressedMessages);
960
1197
  applyResultFixes(result, code, options);
961
1198
  finalizeESLintResult(result);
@@ -963,11 +1200,22 @@ function appendCustomLintFileMessages(results, config, options) {
963
1200
  return results;
964
1201
  }
965
1202
 
1203
+ // ESLint orders a file's messages by position; native and plugin messages
1204
+ // arrive as two separate batches.
1205
+ function sortLintMessages(messages) {
1206
+ return messages
1207
+ .map((message, index) => ({ message, index }))
1208
+ .sort((left, right) => (left.message.line ?? 0) - (right.message.line ?? 0)
1209
+ || (left.message.column ?? 0) - (right.message.column ?? 0)
1210
+ || left.index - right.index)
1211
+ .map(({ message }) => message);
1212
+ }
1213
+
966
1214
  function applyResultFixes(result, code, options) {
967
1215
  if (!options.fix) {
968
1216
  return;
969
1217
  }
970
- const output = applyRuleFixes(code, result.messages.flatMap((message) => ruleFixItems(message.fix)));
1218
+ const output = applyMessageFixes(code, result.messages);
971
1219
  if (output !== code) {
972
1220
  result.output = output;
973
1221
  }
@@ -980,14 +1228,39 @@ function runCustomLinterRules(ruleEntries, sourceCode, options) {
980
1228
 
981
1229
  const messages = [];
982
1230
  const program = sourceCode.ast ?? { type: "Program", range: [0, sourceCode.text.length] };
1231
+ const hasAst = Array.isArray(program.body);
1232
+ const listenersBySelector = new Map();
983
1233
  for (const ruleEntry of ruleEntries) {
1234
+ if (typeof ruleEntry.rule !== "function" && typeof ruleEntry.rule?.create !== "function") {
1235
+ throw new Error(
1236
+ `utoo-lint: rule "${ruleEntry.ruleId}" has no create() function. ` +
1237
+ "ESLint plugins must be mounted from a utlint.config.ts (or .js) module so their rule objects are available at lint time."
1238
+ );
1239
+ }
984
1240
  const context = createCustomRuleContext(ruleEntry, sourceCode, options, messages);
985
1241
  const listeners = customRuleListeners(ruleEntry.rule, context);
986
- if (customRuleChildNodes(program, sourceCode.visitorKeys).length > 0) {
987
- traverseCustomRuleAst(program, listeners, context, sourceCode.visitorKeys);
988
- } else {
1242
+ if (!hasAst) {
989
1243
  runCustomRuleTokenListeners(program, sourceCode.tokens, listeners, context);
1244
+ continue;
990
1245
  }
1246
+ for (const [selector, listener] of Object.entries(listeners)) {
1247
+ if (typeof listener !== "function") {
1248
+ continue;
1249
+ }
1250
+ if (!listenersBySelector.has(selector)) {
1251
+ listenersBySelector.set(selector, []);
1252
+ }
1253
+ listenersBySelector.get(selector).push({
1254
+ ruleId: ruleEntry.ruleId,
1255
+ listener(node) {
1256
+ context.setCurrentNode(node);
1257
+ listener(node);
1258
+ }
1259
+ });
1260
+ }
1261
+ }
1262
+ if (hasAst && listenersBySelector.size > 0) {
1263
+ pluginRunner.runRuleListeners(program, listenersBySelector, { visitorKeys: sourceCode.visitorKeys });
991
1264
  }
992
1265
  return messages;
993
1266
  }
@@ -1144,231 +1417,6 @@ function disableDirectiveMatchesRule(directive, ruleId) {
1144
1417
  return directive.ruleId == null || directive.ruleId === ruleId;
1145
1418
  }
1146
1419
 
1147
- function traverseCustomRuleAst(node, listeners, context, visitorKeys, seen = new Set(), ancestors = [], siblings = {}) {
1148
- if (!node || typeof node !== "object" || typeof node.type !== "string" || seen.has(node)) {
1149
- return;
1150
- }
1151
- seen.add(node);
1152
- for (const listener of customRuleMatchingListeners(listeners, node, false, ancestors, siblings, visitorKeys)) {
1153
- context.setCurrentNode(node);
1154
- listener(node);
1155
- }
1156
- const children = customRuleChildNodes(node, visitorKeys);
1157
- for (const [index, child] of children.entries()) {
1158
- traverseCustomRuleAst(child, listeners, context, visitorKeys, seen, [...ancestors, node], {
1159
- previous: children[index - 1] ?? null,
1160
- previousAll: children.slice(0, index)
1161
- });
1162
- }
1163
- for (const listener of customRuleMatchingListeners(listeners, node, true, ancestors, siblings, visitorKeys)) {
1164
- context.setCurrentNode(node);
1165
- listener(node);
1166
- }
1167
- }
1168
-
1169
- function customRuleMatchingListeners(listeners, node, exit, ancestors = [], siblings = {}, visitorKeys = null) {
1170
- const matches = [];
1171
- for (const [selector, listener] of Object.entries(listeners)) {
1172
- if (typeof listener !== "function" || !customRuleSelectorMatches(selector, node, exit, ancestors, siblings, visitorKeys)) {
1173
- continue;
1174
- }
1175
- matches.push(listener);
1176
- }
1177
- return matches;
1178
- }
1179
-
1180
- function customRuleSelectorMatches(selector, node, exit, ancestors = [], siblings = {}, visitorKeys = null) {
1181
- const suffix = ":exit";
1182
- const isExit = selector.endsWith(suffix);
1183
- if (isExit !== exit) {
1184
- return false;
1185
- }
1186
- const expression = isExit ? selector.slice(0, -suffix.length) : selector;
1187
- const childSelector = customRuleSplitTopLevelSelector(expression, ">");
1188
- if (childSelector) {
1189
- const parent = ancestors.at(-1);
1190
- return Boolean(parent)
1191
- && customRuleSimpleSelectorMatches(childSelector[0].trim(), parent, visitorKeys)
1192
- && customRuleSimpleSelectorMatches(childSelector[1].trim(), node, visitorKeys);
1193
- }
1194
- const adjacentSelector = customRuleSplitTopLevelSelector(expression, "+");
1195
- if (adjacentSelector) {
1196
- return Boolean(siblings.previous)
1197
- && customRuleSimpleSelectorMatches(adjacentSelector[0].trim(), siblings.previous, visitorKeys)
1198
- && customRuleSimpleSelectorMatches(adjacentSelector[1].trim(), node, visitorKeys);
1199
- }
1200
- const siblingSelector = customRuleSplitTopLevelSelector(expression, "~");
1201
- if (siblingSelector) {
1202
- return (siblings.previousAll ?? []).some((sibling) => customRuleSimpleSelectorMatches(siblingSelector[0].trim(), sibling, visitorKeys))
1203
- && customRuleSimpleSelectorMatches(siblingSelector[1].trim(), node, visitorKeys);
1204
- }
1205
- const descendantSelector = customRuleSplitTopLevelDescendantSelector(expression);
1206
- if (descendantSelector) {
1207
- return ancestors.some((ancestor) => customRuleSimpleSelectorMatches(descendantSelector[0].trim(), ancestor, visitorKeys))
1208
- && customRuleSimpleSelectorMatches(descendantSelector[1].trim(), node, visitorKeys);
1209
- }
1210
- return customRuleSimpleSelectorMatches(expression, node, visitorKeys);
1211
- }
1212
-
1213
- function customRuleSimpleSelectorMatches(expression, node, visitorKeys = null) {
1214
- const hasSelector = expression.match(/^(.+?):has\((.+)\)$/u);
1215
- if (hasSelector) {
1216
- return customRuleSimpleSelectorMatches(hasSelector[1].trim(), node, visitorKeys)
1217
- && customRuleDescendants(node, visitorKeys).some((descendant) => (
1218
- customRuleSplitSelectorList(hasSelector[2]).some((selector) => customRuleSimpleSelectorMatches(selector.trim(), descendant, visitorKeys))
1219
- ));
1220
- }
1221
- const notSelector = expression.match(/^(.+?):not\((.+)\)$/u);
1222
- if (notSelector) {
1223
- return customRuleSimpleSelectorMatches(notSelector[1].trim(), node, visitorKeys)
1224
- && !customRuleSimpleSelectorMatches(notSelector[2].trim(), node, visitorKeys);
1225
- }
1226
- const matchesSelector = expression.match(/^(.*?):matches\((.+)\)$/u);
1227
- if (matchesSelector) {
1228
- const prefix = matchesSelector[1].trim();
1229
- if (prefix && !customRuleSimpleSelectorMatches(prefix, node, visitorKeys)) {
1230
- return false;
1231
- }
1232
- return customRuleSplitSelectorList(matchesSelector[2]).some((selector) => (
1233
- customRuleSimpleSelectorMatches(selector.trim(), node, visitorKeys)
1234
- ));
1235
- }
1236
- if (expression === node.type) {
1237
- return true;
1238
- }
1239
- const match = expression.match(/^([A-Za-z_$][\w$-]*|\*)?(?:\[([^\]]+)\])?$/u);
1240
- if (!match) {
1241
- return false;
1242
- }
1243
- const [, type = "*", attribute] = match;
1244
- if (type !== "*" && type !== node.type) {
1245
- return false;
1246
- }
1247
- return attribute ? customRuleAttributeSelectorMatches(node, attribute.trim()) : type === "*" || type === node.type;
1248
- }
1249
-
1250
- function customRuleDescendants(node, visitorKeys, seen = new Set()) {
1251
- const descendants = [];
1252
- for (const child of customRuleChildNodes(node, visitorKeys)) {
1253
- if (seen.has(child)) {
1254
- continue;
1255
- }
1256
- seen.add(child);
1257
- descendants.push(child, ...customRuleDescendants(child, visitorKeys, seen));
1258
- }
1259
- return descendants;
1260
- }
1261
-
1262
- function customRuleSplitTopLevelSelector(expression, separator) {
1263
- let state = customRuleSelectorScanState();
1264
- for (let index = 0; index < expression.length; index += 1) {
1265
- state = customRuleUpdateSelectorScanState(state, expression[index]);
1266
- if (state.depth === 0 && !state.quote && expression[index] === separator) {
1267
- return [expression.slice(0, index), expression.slice(index + 1)];
1268
- }
1269
- }
1270
- return null;
1271
- }
1272
-
1273
- function customRuleSplitTopLevelDescendantSelector(expression) {
1274
- let state = customRuleSelectorScanState();
1275
- for (let index = 0; index < expression.length; index += 1) {
1276
- state = customRuleUpdateSelectorScanState(state, expression[index]);
1277
- if (state.depth === 0 && !state.quote && /\s/u.test(expression[index])) {
1278
- const left = expression.slice(0, index).trim();
1279
- const right = expression.slice(index).trim();
1280
- return left && right ? [left, right] : null;
1281
- }
1282
- }
1283
- return null;
1284
- }
1285
-
1286
- function customRuleSplitSelectorList(value) {
1287
- const selectors = [];
1288
- let state = customRuleSelectorScanState();
1289
- let start = 0;
1290
- for (let index = 0; index < value.length; index += 1) {
1291
- state = customRuleUpdateSelectorScanState(state, value[index]);
1292
- if (state.depth === 0 && !state.quote && value[index] === ",") {
1293
- selectors.push(value.slice(start, index));
1294
- start = index + 1;
1295
- }
1296
- }
1297
- selectors.push(value.slice(start));
1298
- return selectors.filter((selector) => selector.trim());
1299
- }
1300
-
1301
- function customRuleSelectorScanState() {
1302
- return { depth: 0, quote: null, escaped: false };
1303
- }
1304
-
1305
- function customRuleUpdateSelectorScanState(state, char) {
1306
- if (state.escaped) {
1307
- return { ...state, escaped: false };
1308
- }
1309
- if (char === "\\") {
1310
- return { ...state, escaped: true };
1311
- }
1312
- if (state.quote) {
1313
- return char === state.quote ? { ...state, quote: null } : state;
1314
- }
1315
- if (char === "\"" || char === "'") {
1316
- return { ...state, quote: char };
1317
- }
1318
- if (char === "[" || char === "(") {
1319
- return { ...state, depth: state.depth + 1 };
1320
- }
1321
- if ((char === "]" || char === ")") && state.depth > 0) {
1322
- return { ...state, depth: state.depth - 1 };
1323
- }
1324
- return state;
1325
- }
1326
-
1327
- function customRuleAttributeSelectorMatches(node, attribute) {
1328
- const match = attribute.match(/^([\w$.-]+)\s*(!=|=)\s*(.+)$/u);
1329
- if (!match) {
1330
- return customRuleValueByPath(node, attribute) != null;
1331
- }
1332
- const [, path, operator, rawExpected] = match;
1333
- const actual = customRuleValueByPath(node, path);
1334
- const expected = customRuleSelectorValue(rawExpected);
1335
- return operator === "="
1336
- ? String(actual) === expected
1337
- : String(actual) !== expected;
1338
- }
1339
-
1340
- function customRuleSelectorValue(raw) {
1341
- const value = raw.trim();
1342
- const quote = value[0];
1343
- if ((quote === "\"" || quote === "'") && value.at(-1) === quote) {
1344
- return value.slice(1, -1);
1345
- }
1346
- return value;
1347
- }
1348
-
1349
- function customRuleValueByPath(node, path) {
1350
- return path.split(".").reduce((value, key) => (
1351
- value && typeof value === "object" ? value[key] : undefined
1352
- ), node);
1353
- }
1354
-
1355
- function customRuleChildNodes(node, visitorKeys) {
1356
- const keys = Array.isArray(visitorKeys?.[node.type])
1357
- ? visitorKeys[node.type]
1358
- : Object.keys(node).filter((key) => !AST_TRAVERSAL_SKIP_KEYS.has(key));
1359
- const children = [];
1360
- for (const key of keys) {
1361
- const value = node[key];
1362
- const values = Array.isArray(value) ? value : [value];
1363
- for (const child of values) {
1364
- if (child && typeof child === "object" && typeof child.type === "string") {
1365
- children.push(child);
1366
- }
1367
- }
1368
- }
1369
- return children;
1370
- }
1371
-
1372
1420
  function runCustomRuleTokenListeners(program, tokens, listeners, context) {
1373
1421
  if (typeof listeners.Program === "function") {
1374
1422
  context.setCurrentNode(program);
@@ -1403,7 +1451,7 @@ function createCustomRuleContext(ruleEntry, sourceCode, options, messages) {
1403
1451
  let currentNode = sourceCode.ast ?? null;
1404
1452
  const context = {
1405
1453
  id: ruleEntry.ruleId,
1406
- options: ruleEntry.options,
1454
+ options: pluginRunner.applyRuleOptionDefaults(ruleEntry.rule, ruleEntry.options),
1407
1455
  filename,
1408
1456
  physicalFilename: filename,
1409
1457
  cwd,
@@ -1621,7 +1669,7 @@ export class SourceCode {
1621
1669
  this.text = textOrConfig.text;
1622
1670
  this.ast = textOrConfig.ast ?? null;
1623
1671
  this.parserServices = textOrConfig.parserServices ?? {};
1624
- this.scopeManager = textOrConfig.scopeManager ?? null;
1672
+ defineScopeManager(this, textOrConfig.scopeManager ?? null);
1625
1673
  this.visitorKeys = textOrConfig.visitorKeys ?? null;
1626
1674
  this.hasBOM = Boolean(textOrConfig.hasBOM);
1627
1675
  } else {
@@ -1813,7 +1861,17 @@ export class SourceCode {
1813
1861
  }
1814
1862
 
1815
1863
  getAncestors(node) {
1816
- return node ? sourceAncestorsForNode(this.ast, node) ?? [] : [];
1864
+ if (!node) {
1865
+ return [];
1866
+ }
1867
+ if (node.parent !== undefined) {
1868
+ const ancestors = [];
1869
+ for (let current = node.parent; current; current = current.parent) {
1870
+ ancestors.unshift(current);
1871
+ }
1872
+ return ancestors;
1873
+ }
1874
+ return sourceAncestorsForNode(this.ast, node) ?? [];
1817
1875
  }
1818
1876
 
1819
1877
  getDeclaredVariables(node) {
@@ -1821,10 +1879,20 @@ export class SourceCode {
1821
1879
  }
1822
1880
 
1823
1881
  getScope(node) {
1824
- if (node && typeof this.scopeManager?.acquire === "function") {
1825
- return this.scopeManager.acquire(node, true) ?? this.scopeManager.acquire(node, false) ?? this.scopeManager.globalScope ?? null;
1882
+ const scopeManager = this.scopeManager;
1883
+ if (!scopeManager || typeof scopeManager.acquire !== "function") {
1884
+ return null;
1885
+ }
1886
+ if (node) {
1887
+ const inner = node.type !== "Program";
1888
+ for (let current = node; current; current = current.parent) {
1889
+ const scope = scopeManager.acquire(current, inner);
1890
+ if (scope) {
1891
+ return scope.type === "function-expression-name" ? scope.childScopes[0] : scope;
1892
+ }
1893
+ }
1826
1894
  }
1827
- return this.scopeManager?.globalScope ?? null;
1895
+ return scopeManager.scopes?.[0] ?? scopeManager.globalScope ?? null;
1828
1896
  }
1829
1897
 
1830
1898
  markVariableAsUsed(name, node) {
@@ -1860,6 +1928,31 @@ export class SourceCode {
1860
1928
  }
1861
1929
  }
1862
1930
 
1931
+ function defineScopeManager(sourceCode, scopeManager) {
1932
+ if (typeof scopeManager !== "function") {
1933
+ sourceCode.scopeManager = scopeManager;
1934
+ return;
1935
+ }
1936
+ // Scope analysis is comparatively expensive; run it only when a rule asks.
1937
+ let computed = false;
1938
+ let value = null;
1939
+ Object.defineProperty(sourceCode, "scopeManager", {
1940
+ configurable: true,
1941
+ enumerable: true,
1942
+ get() {
1943
+ if (!computed) {
1944
+ computed = true;
1945
+ value = scopeManager() ?? null;
1946
+ }
1947
+ return value;
1948
+ },
1949
+ set(next) {
1950
+ computed = true;
1951
+ value = next;
1952
+ }
1953
+ });
1954
+ }
1955
+
1863
1956
  function sourceLineStartIndices(text) {
1864
1957
  const indices = [0];
1865
1958
  for (let index = 0; index < text.length; index += 1) {
@@ -2149,12 +2242,10 @@ export class RuleTester {
2149
2242
  RuleTester[testCase.only ? "itOnly" : "it"](testCase.name ?? testCase.code, () => {
2150
2243
  const config = ruleTesterConfig(ruleName, this.config, testCase, "error");
2151
2244
  const options = ruleTesterOptions(testCase);
2152
- const result = Object.hasOwn(testCase, "output")
2153
- ? linter.verifyAndFix(testCase.code, config, options)
2154
- : { messages: linter.verify(testCase.code, config, options), output: testCase.code };
2155
- const messages = result.messages;
2245
+ const messages = linter.verify(testCase.code, config, options);
2156
2246
  assertRuleTesterErrors(testCase, messages, rule);
2157
- assertRuleTesterOutput(testCase, result.output);
2247
+ // Like ESLint's RuleTester, `output` is the result of one autofix pass.
2248
+ assertRuleTesterOutput(testCase, Object.hasOwn(testCase, "output") ? applyMessageFixes(testCase.code, messages) : testCase.code);
2158
2249
  });
2159
2250
  }
2160
2251
  });
@@ -2301,6 +2392,47 @@ function applyRuleFixes(code, fix) {
2301
2392
  .reduce((output, item) => output.slice(0, item.range[0]) + item.text + output.slice(item.range[1]), code);
2302
2393
  }
2303
2394
 
2395
+ // Applies one non-overlapping fix per message, like ESLint's SourceCodeFixer.
2396
+ // Fixes that overlap an earlier one are left for a later pass.
2397
+ function applyMessageFixes(code, messages) {
2398
+ const fixes = [];
2399
+ for (const message of messages) {
2400
+ const items = ruleFixItems(message?.fix)
2401
+ .slice()
2402
+ .sort((left, right) => left.range[0] - right.range[0] || left.range[1] - right.range[1]);
2403
+ if (items.length === 0) {
2404
+ continue;
2405
+ }
2406
+ let text = "";
2407
+ let cursor = items[0].range[0];
2408
+ let valid = true;
2409
+ for (const item of items) {
2410
+ if (item.range[0] < cursor) {
2411
+ valid = false;
2412
+ break;
2413
+ }
2414
+ text += code.slice(cursor, item.range[0]) + item.text;
2415
+ cursor = item.range[1];
2416
+ }
2417
+ if (valid) {
2418
+ fixes.push({ range: [items[0].range[0], cursor], text });
2419
+ }
2420
+ }
2421
+ fixes.sort((left, right) => left.range[0] - right.range[0] || left.range[1] - right.range[1]);
2422
+ let output = "";
2423
+ let position = 0;
2424
+ let lastEnd = -Infinity;
2425
+ for (const fix of fixes) {
2426
+ if (lastEnd >= fix.range[0]) {
2427
+ continue;
2428
+ }
2429
+ output += code.slice(position, fix.range[0]) + fix.text;
2430
+ position = fix.range[1];
2431
+ lastEnd = fix.range[1];
2432
+ }
2433
+ return output + code.slice(position);
2434
+ }
2435
+
2304
2436
  function ruleFixItems(fix) {
2305
2437
  if (!fix) {
2306
2438
  return [];
@@ -2353,10 +2485,29 @@ function createLinterSourceCode(text, parser = null, parserOptions = {}) {
2353
2485
  });
2354
2486
  }
2355
2487
 
2356
- function parseSourceCode(text, parser, parserOptions) {
2357
- if (!parser) {
2488
+ function parseSourceCodeWithDefaultParser(text, parserOptions) {
2489
+ let parsed;
2490
+ try {
2491
+ parsed = estreeParser.parseForESLint(text, parserOptions);
2492
+ } catch (error) {
2493
+ if (parserOptions.requireParser) {
2494
+ throw error;
2495
+ }
2358
2496
  return null;
2359
2497
  }
2498
+ return new SourceCode({
2499
+ text,
2500
+ ast: parsed.ast,
2501
+ parserServices: parsed.services ?? {},
2502
+ scopeManager: parsed.scopeManager ?? null,
2503
+ visitorKeys: parsed.visitorKeys ?? null
2504
+ });
2505
+ }
2506
+
2507
+ function parseSourceCode(text, parser, parserOptions = {}) {
2508
+ if (!parser) {
2509
+ return parserOptions.tokensOnly ? null : parseSourceCodeWithDefaultParser(text, parserOptions);
2510
+ }
2360
2511
  if (typeof parser.parseForESLint === "function") {
2361
2512
  const result = parser.parseForESLint(text, parserOptions) ?? {};
2362
2513
  return new SourceCode({
@@ -3128,21 +3279,210 @@ export function lintFiles(paths, options = {}) {
3128
3279
  }
3129
3280
 
3130
3281
  const nativeFlatOptions = nativeFlatConfigOptions(options);
3131
- if (nativeFlatOptions) {
3132
- const report = runNativeLintReport(lintPaths, nativeFlatOptions);
3133
- return finalizeLintReport(report, ignoredDiagnostics, options);
3134
- }
3282
+ const report = nativeFlatOptions
3283
+ ? runNativeLintReport(lintPaths, nativeFlatOptions)
3284
+ : runNativeConfigRunReports(lintPaths, options);
3285
+ // The flat-config path resolves one config for the whole run; only the
3286
+ // per-file path has already discovered (and cached) nested configs.
3287
+ appendPluginRuleDiagnostics(report, options, { perFileConfig: !nativeFlatOptions });
3288
+ return finalizeLintReport(report, ignoredDiagnostics, options);
3289
+ }
3135
3290
 
3291
+ function runNativeConfigRunReports(lintPaths, options) {
3136
3292
  const configRuns = nativeConfigRunsForFiles(expandNativeConfigRunPaths(lintPaths, options), options);
3137
3293
  if (configRuns) {
3138
- const report = mergeNativeReports(
3294
+ return mergeNativeReports(
3139
3295
  configRuns.map(({ paths: runPaths, options: runOptions }) => runNativeLintReport(runPaths, runOptions))
3140
3296
  );
3141
- return finalizeLintReport(report, ignoredDiagnostics, options);
3142
3297
  }
3143
3298
 
3144
- const report = runNativeLintReport(lintPaths, options);
3145
- return finalizeLintReport(report, ignoredDiagnostics, options);
3299
+ return runNativeLintReport(lintPaths, options);
3300
+ }
3301
+
3302
+ function pluginFixModeForOptions(options) {
3303
+ const extraArgs = Array.isArray(options.extraArgs) ? options.extraArgs : [];
3304
+ if (options.fix || extraArgs.includes("--fix-dry-run")) {
3305
+ return "dry-run";
3306
+ }
3307
+ return extraArgs.includes("--fix") ? "write" : null;
3308
+ }
3309
+
3310
+ function pluginMessageToDiagnostic(message, filePath) {
3311
+ const toFix = (fix) => ({ range: [fix.range[0], fix.range[1]], text: fix.text });
3312
+ const diagnostic = {
3313
+ filePath,
3314
+ line: message.line,
3315
+ column: message.column,
3316
+ severity: message.severity === 2 ? "error" : "warning",
3317
+ message: message.message,
3318
+ ruleId: message.ruleId,
3319
+ fixes: ruleFixItems(message.fix).map(toFix),
3320
+ suggestions: (message.suggestions ?? []).map((suggestion) => ({
3321
+ desc: suggestion.desc,
3322
+ fix: ruleFixItems(suggestion.fix).map(toFix)
3323
+ }))
3324
+ };
3325
+ if (typeof message.endLine === "number") {
3326
+ diagnostic.endLine = message.endLine;
3327
+ }
3328
+ if (typeof message.endColumn === "number") {
3329
+ diagnostic.endColumn = message.endColumn;
3330
+ }
3331
+ return diagnostic;
3332
+ }
3333
+
3334
+ function sortReportDiagnostics(report) {
3335
+ const order = new Map((report.filePaths ?? []).map((filePath, index) => [filePath, index]));
3336
+ const rank = (diagnostic) => order.get(diagnostic.filePath) ?? Number.MAX_SAFE_INTEGER;
3337
+ const compare = (left, right) => rank(left) - rank(right)
3338
+ || (left.line ?? 0) - (right.line ?? 0)
3339
+ || (left.column ?? 0) - (right.column ?? 0);
3340
+ report.diagnostics = [...(report.diagnostics ?? [])].sort(compare);
3341
+ report.suppressedDiagnostics = [...(report.suppressedDiagnostics ?? [])].sort(compare);
3342
+ }
3343
+
3344
+ // Runs the ESLint plugin rules a config mounts and merges their diagnostics
3345
+ // into the native report. Plugin rules never reach the native binary; see
3346
+ // `withoutJsPluginRules`.
3347
+ function appendPluginRuleDiagnostics(report, options, { perFileConfig = true } = {}) {
3348
+ if (options.noConfig && !options.baseConfig && !options.overrideConfig) {
3349
+ return report;
3350
+ }
3351
+ const filePaths = report.filePaths ?? [];
3352
+ if (filePaths.length === 0 || !anyConfigDeclaresPlugins(options)) {
3353
+ return report;
3354
+ }
3355
+ const cwd = options.cwd ?? process.cwd();
3356
+ const singleMatchPath = filePaths.length === 1
3357
+ ? options.filePath ?? options.filename ?? options.pluginTextFilePath
3358
+ : undefined;
3359
+ const fixMode = pluginFixModeForOptions(options);
3360
+ let appended = false;
3361
+
3362
+ for (const filePath of filePaths) {
3363
+ const matchPath = singleMatchPath ?? filePath;
3364
+ const ruleMap = jsPluginRuleMapForFile(options, matchPath, perFileConfig);
3365
+ if (ruleMap.size === 0) {
3366
+ continue;
3367
+ }
3368
+ const ruleEntries = pluginRuleEntriesForFile(options, matchPath, ruleMap, perFileConfig);
3369
+ if (ruleEntries.length === 0) {
3370
+ continue;
3371
+ }
3372
+
3373
+ // The native report echoes targets as given, so relative paths resolve
3374
+ // against the lint cwd rather than the process cwd.
3375
+ const absolutePath = normalizeESLintFilePath(filePath, cwd);
3376
+ const outputIndex = (report.outputs ?? []).findIndex((fixed) => fixed.filePath === filePath);
3377
+ let code = outputIndex === -1 ? undefined : report.outputs[outputIndex].output;
3378
+ if (typeof code !== "string") {
3379
+ try {
3380
+ code = readFileSync(absolutePath, "utf8");
3381
+ } catch {
3382
+ continue;
3383
+ }
3384
+ }
3385
+
3386
+ const config = pluginConfigSourcesForFile(options, matchPath, perFileConfig).map((source) => source.config);
3387
+ const parserOptions = { ...parserOptionsForConfig(config, matchPath, cwd), filePath: matchPath, requireParser: true };
3388
+ const lintOptions = {
3389
+ cwd,
3390
+ filename: matchPath,
3391
+ config: calculatedConfig({ cwd, noConfig: true, baseConfig: config }, matchPath)
3392
+ };
3393
+ const lintOnce = (text) => {
3394
+ const sourceCode = createLinterSourceCode(text, null, parserOptions);
3395
+ return applyDisableDirectives(runCustomLinterRules(ruleEntries, sourceCode, lintOptions), sourceCode);
3396
+ };
3397
+
3398
+ let filtered = lintOnce(code);
3399
+ let relintedFromReport = false;
3400
+ if (fixMode) {
3401
+ let changed = false;
3402
+ for (let pass = 0; pass < MAX_AUTOFIX_PASSES; pass += 1) {
3403
+ const output = applyMessageFixes(code, filtered.messages);
3404
+ if (output === code) {
3405
+ break;
3406
+ }
3407
+ changed = true;
3408
+ code = output;
3409
+ filtered = lintOnce(code);
3410
+ }
3411
+ if (changed) {
3412
+ // Plugin fixes changed the text, so the native diagnostics for this
3413
+ // file describe stale source. Re-lint the fixed text through the whole
3414
+ // pipeline (native rules, plugin rules, further fixes) and replace this
3415
+ // file's diagnostics with the result.
3416
+ const depth = options.pluginFixDepth ?? 0;
3417
+ if (depth < MAX_AUTOFIX_PASSES) {
3418
+ const relinted = lintText(code, {
3419
+ ...options,
3420
+ filePath: matchPath,
3421
+ filename: undefined,
3422
+ config: pluginConfigPathForFile(options, matchPath, perFileConfig) ?? options.config,
3423
+ fix: true,
3424
+ extraArgs: (options.extraArgs ?? []).filter((arg) => arg !== "--fix" && arg !== "--fix-dry-run"),
3425
+ useDefaultConfigTargets: undefined,
3426
+ pluginTextFilePath: undefined,
3427
+ pluginFixDepth: depth + 1
3428
+ });
3429
+ code = relinted.outputs?.find((fixed) => fixed.filePath === matchPath)?.output ?? code;
3430
+ filtered = {
3431
+ messages: relinted.diagnostics ?? [],
3432
+ suppressedMessages: relinted.suppressedDiagnostics ?? []
3433
+ };
3434
+ report.diagnostics = (report.diagnostics ?? []).filter((diagnostic) => diagnostic.filePath !== filePath);
3435
+ report.suppressedDiagnostics = (report.suppressedDiagnostics ?? []).filter((diagnostic) => diagnostic.filePath !== filePath);
3436
+ relintedFromReport = true;
3437
+ }
3438
+ if (fixMode === "write") {
3439
+ writeFileSync(absolutePath, code);
3440
+ } else if (outputIndex === -1) {
3441
+ report.outputs = [...(report.outputs ?? []), { filePath, output: code }];
3442
+ } else {
3443
+ report.outputs[outputIndex] = { ...report.outputs[outputIndex], output: code };
3444
+ }
3445
+ }
3446
+ }
3447
+
3448
+ if (relintedFromReport) {
3449
+ // Already native-shaped diagnostics from the nested lint; only the
3450
+ // file path needs to match this report.
3451
+ report.diagnostics = [
3452
+ ...(report.diagnostics ?? []),
3453
+ ...filtered.messages.map((diagnostic) => ({ ...diagnostic, filePath }))
3454
+ ];
3455
+ report.suppressedDiagnostics = [
3456
+ ...(report.suppressedDiagnostics ?? []),
3457
+ ...filtered.suppressedMessages.map((diagnostic) => ({ ...diagnostic, filePath }))
3458
+ ];
3459
+ appended = true;
3460
+ continue;
3461
+ }
3462
+ if (filtered.messages.length === 0 && filtered.suppressedMessages.length === 0) {
3463
+ continue;
3464
+ }
3465
+ report.diagnostics = [
3466
+ ...(report.diagnostics ?? []),
3467
+ ...filtered.messages.map((message) => pluginMessageToDiagnostic(message, filePath))
3468
+ ];
3469
+ report.suppressedDiagnostics = [
3470
+ ...(report.suppressedDiagnostics ?? []),
3471
+ ...filtered.suppressedMessages.map((message) => ({
3472
+ ...pluginMessageToDiagnostic(message, filePath),
3473
+ suppression: {
3474
+ kind: message.suppressions?.[0]?.kind ?? "directive",
3475
+ justification: message.suppressions?.[0]?.justification ?? ""
3476
+ }
3477
+ }))
3478
+ ];
3479
+ appended = true;
3480
+ }
3481
+
3482
+ if (appended) {
3483
+ sortReportDiagnostics(report);
3484
+ }
3485
+ return report;
3146
3486
  }
3147
3487
 
3148
3488
  function nativeFlatConfigOptions(options) {
@@ -3172,6 +3512,10 @@ function nativeFlatConfigOptions(options) {
3172
3512
  return undefined;
3173
3513
  }
3174
3514
 
3515
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(config);
3516
+ if (jsPluginRuleIds.size > 0) {
3517
+ config = configWithoutCustomRules(config, jsPluginRuleIds);
3518
+ }
3175
3519
  if (configCanUseNativeRulesFastPath(config)) {
3176
3520
  return undefined;
3177
3521
  }
@@ -3185,11 +3529,28 @@ function nativeFlatConfigOptions(options) {
3185
3529
  };
3186
3530
  }
3187
3531
 
3532
+ // Globals declared by a config entry, merged the way ESLint does: flat
3533
+ // `languageOptions.globals` wins over eslintrc-style `globals`.
3534
+ function nativeConfigGlobals(config) {
3535
+ if (!config || typeof config !== "object") {
3536
+ return undefined;
3537
+ }
3538
+ const globals = {
3539
+ ...(isGlobalsObject(config.globals) ? config.globals : {}),
3540
+ ...(isGlobalsObject(config.languageOptions?.globals) ? config.languageOptions.globals : {})
3541
+ };
3542
+ return Object.keys(globals).length > 0 ? globals : undefined;
3543
+ }
3544
+
3545
+ function isGlobalsObject(value) {
3546
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3547
+ }
3548
+
3188
3549
  function configCanUseNativeRulesFastPath(config) {
3189
3550
  const entries = Array.isArray(config) ? config : [config];
3190
3551
  let hasEnabledRule = false;
3191
3552
  for (const entry of entries) {
3192
- if (!entry || typeof entry !== "object" || Object.keys(entry.settings ?? {}).length > 0) {
3553
+ if (!entry || typeof entry !== "object" || Object.keys(entry.settings ?? {}).length > 0 || nativeConfigGlobals(entry)) {
3193
3554
  return false;
3194
3555
  }
3195
3556
  for (const ruleConfig of Object.values(entry.rules ?? {})) {
@@ -3212,11 +3573,16 @@ function nativeSerializableConfig(config) {
3212
3573
  if (!config || typeof config !== "object") {
3213
3574
  return config;
3214
3575
  }
3215
- return Object.fromEntries(
3576
+ const serialized = Object.fromEntries(
3216
3577
  ["$schema", "name", "files", "ignores", "rules", "settings"]
3217
3578
  .filter((key) => config[key] !== undefined)
3218
3579
  .map((key) => [key, config[key]])
3219
3580
  );
3581
+ const globals = nativeConfigGlobals(config);
3582
+ if (globals) {
3583
+ serialized.languageOptions = { globals };
3584
+ }
3585
+ return serialized;
3220
3586
  }
3221
3587
 
3222
3588
  function expandNativeConfigRunPaths(paths, options) {
@@ -3295,12 +3661,16 @@ function nativeConfigRunsForFiles(paths, options) {
3295
3661
  const calculated = configured ? calculatedConfig({ ...options, rules: undefined }, matchPath) : {};
3296
3662
  let rules = calculated.rules;
3297
3663
  const settings = calculated.settings;
3664
+ const globals = configured ? nativeConfigGlobals(calculated) : undefined;
3298
3665
  if (configured && options.rules) {
3299
3666
  rules = selectedRulesWithConfigOptions(rules, options.rules);
3300
3667
  }
3301
- const signature = configured ? stableConfigSignature({ rules, settings }) : "<native-defaults>";
3668
+ if (configured) {
3669
+ rules = withoutJsPluginRules(rules, jsPluginRuleIdsForOptions(options, matchPath));
3670
+ }
3671
+ const signature = configured ? stableConfigSignature({ rules, settings, globals }) : "<native-defaults>";
3302
3672
  if (!groups.has(signature)) {
3303
- groups.set(signature, { paths: [], rules, settings, configured });
3673
+ groups.set(signature, { paths: [], rules, settings, globals, configured });
3304
3674
  }
3305
3675
  groups.get(signature).paths.push(filePath);
3306
3676
  }
@@ -3323,7 +3693,7 @@ function nativeConfigRunsForFiles(paths, options) {
3323
3693
  };
3324
3694
  }
3325
3695
 
3326
- const selectedRules = severityOnlyNativeRuleNames(group.rules, group.settings);
3696
+ const selectedRules = severityOnlyNativeRuleNames(group.rules, group.settings, group.globals);
3327
3697
  if (selectedRules) {
3328
3698
  return {
3329
3699
  paths: group.paths,
@@ -3348,7 +3718,8 @@ function nativeConfigRunsForFiles(paths, options) {
3348
3718
  baseConfig: undefined,
3349
3719
  overrideConfig: {
3350
3720
  rules: allDisabledNativeRules(group.rules),
3351
- ...(group.settings ? { settings: group.settings } : {})
3721
+ ...(group.settings ? { settings: group.settings } : {}),
3722
+ ...(group.globals ? { languageOptions: { globals: group.globals } } : {})
3352
3723
  },
3353
3724
  forceMaterializedConfig: true
3354
3725
  }
@@ -3356,8 +3727,8 @@ function nativeConfigRunsForFiles(paths, options) {
3356
3727
  });
3357
3728
  }
3358
3729
 
3359
- function severityOnlyNativeRuleNames(rules, settings) {
3360
- if (Object.keys(settings ?? {}).length > 0) {
3730
+ function severityOnlyNativeRuleNames(rules, settings, globals) {
3731
+ if (globals || Object.keys(settings ?? {}).length > 0) {
3361
3732
  return undefined;
3362
3733
  }
3363
3734
 
@@ -3470,7 +3841,8 @@ export function lintText(code, options = {}) {
3470
3841
  cwd: options.cwd,
3471
3842
  config: options.config ?? discoveredConfig,
3472
3843
  noConfig: options.noConfig,
3473
- deferDiagnosticConfigFiltering: true
3844
+ deferDiagnosticConfigFiltering: true,
3845
+ pluginTextFilePath: requestedPath
3474
3846
  });
3475
3847
  report.filePaths = (report.filePaths ?? []).map((filePath) => (filePath === tempFile ? requestedPath : filePath));
3476
3848
  report.outputs = (report.outputs ?? []).map((fixed) => ({
@@ -3512,11 +3884,12 @@ function buildNativeLintArgs(paths, options) {
3512
3884
  const configForRuleSelection = options.noConfig
3513
3885
  ? undefined
3514
3886
  : options.config ? readConfig(options.config, options.cwd, options.configCache) : undefined;
3887
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(configForRuleSelection, options.baseConfig, options.overrideConfig);
3515
3888
  const enabledRules = enabledRuleNamesFromConfigs(
3516
3889
  configForRuleSelection,
3517
3890
  options.baseConfig,
3518
3891
  options.overrideConfig
3519
- );
3892
+ ).filter((rule) => !jsPluginRuleIds.has(rule));
3520
3893
  if (enabledRules.length > 0) {
3521
3894
  cliArgs.push(`--rules=${enabledRules.join(",")}`);
3522
3895
  }
@@ -3679,6 +4052,10 @@ function mergeConfigData(base, override) {
3679
4052
  }
3680
4053
  if (base.languageOptions || override.languageOptions) {
3681
4054
  result.languageOptions = { ...(base.languageOptions ?? {}), ...(override.languageOptions ?? {}) };
4055
+ // ESLint merges `languageOptions.globals` per name across config objects.
4056
+ if (isGlobalsObject(base.languageOptions?.globals) && isGlobalsObject(override.languageOptions?.globals)) {
4057
+ result.languageOptions.globals = { ...base.languageOptions.globals, ...override.languageOptions.globals };
4058
+ }
3682
4059
  }
3683
4060
  if (base.parserOptions || override.parserOptions) {
3684
4061
  result.parserOptions = { ...(base.parserOptions ?? {}), ...(override.parserOptions ?? {}) };
@@ -3974,7 +4351,7 @@ function withTemporaryConfig(options, callback) {
3974
4351
  const tmp = mkdtempSync(join(tmpdir(), "utoo-lint-flat-config-"));
3975
4352
  const configPath = join(tmp, "utlint.config.json");
3976
4353
  try {
3977
- writeFileSync(configPath, JSON.stringify(options.nativeFlatConfigData));
4354
+ writeFileSync(configPath, JSON.stringify(nativeFlatConfigEntries(options.nativeFlatConfigData)));
3978
4355
  return callback({
3979
4356
  ...options,
3980
4357
  config: configPath,
@@ -3994,9 +4371,11 @@ function withTemporaryConfig(options, callback) {
3994
4371
  ? readConfig(fileConfigPath, options.cwd, options.configCache)
3995
4372
  : undefined;
3996
4373
  const configs = [options.baseConfig, fileConfig, options.overrideConfig];
3997
- const rules = shouldMaterializeFileConfig
3998
- ? materializedRulesFromConfigs(...configs)
3999
- : runtimeRulesFromConfigs(...configs);
4374
+ const jsPluginRuleIds = jsPluginRuleIdsFromConfigs(...configs);
4375
+ const rules = withoutJsPluginRules(
4376
+ shouldMaterializeFileConfig ? materializedRulesFromConfigs(...configs) : runtimeRulesFromConfigs(...configs),
4377
+ jsPluginRuleIds
4378
+ );
4000
4379
  const settings = configs.reduce(
4001
4380
  (result, config) => ({
4002
4381
  ...result,
@@ -4005,7 +4384,15 @@ function withTemporaryConfig(options, callback) {
4005
4384
  {}
4006
4385
  );
4007
4386
  const hasSettings = Object.keys(settings).length > 0;
4008
- if (Object.keys(rules).length === 0 && !hasSettings) {
4387
+ const globals = configs.reduce(
4388
+ (result, config) => ({
4389
+ ...result,
4390
+ ...(nativeConfigGlobals(configDataFromConfig(config, options.filePath ?? options.filename, options.cwd)) ?? {})
4391
+ }),
4392
+ {}
4393
+ );
4394
+ const hasGlobals = Object.keys(globals).length > 0;
4395
+ if (Object.keys(rules).length === 0 && !hasSettings && !hasGlobals) {
4009
4396
  if (shouldMaterializeFileConfig) {
4010
4397
  return callback({
4011
4398
  ...options,
@@ -4015,9 +4402,9 @@ function withTemporaryConfig(options, callback) {
4015
4402
  }
4016
4403
  return callback(options);
4017
4404
  }
4018
- const enabledRules = enabledRuleNamesFromConfigs(...configs);
4405
+ const enabledRules = enabledRuleNamesFromConfigs(...configs).filter((rule) => !jsPluginRuleIds.has(rule));
4019
4406
  const hasExplicitOffRules = Object.values(rules).some((value) => ruleConfigSeverity(value) === 0);
4020
- if (!shouldMaterializeFileConfig && !hasSettings && !hasRuleOptions(rules) && !hasExplicitOffRules && !options.forceMaterializedConfig) {
4407
+ if (!shouldMaterializeFileConfig && !hasSettings && !hasGlobals && !hasRuleOptions(rules) && !hasExplicitOffRules && !options.forceMaterializedConfig) {
4021
4408
  return callback({
4022
4409
  ...options,
4023
4410
  config: shouldMaterializeFileConfig ? undefined : options.config,
@@ -4029,7 +4416,11 @@ function withTemporaryConfig(options, callback) {
4029
4416
  const tmp = mkdtempSync(join(tmpdir(), "utoo-lint-config-"));
4030
4417
  const configPath = join(tmp, "utlint.config.json");
4031
4418
  try {
4032
- writeFileSync(configPath, JSON.stringify({ rules, ...(hasSettings ? { settings } : {}) }));
4419
+ writeFileSync(configPath, JSON.stringify({
4420
+ rules: nativeRules(rules),
4421
+ ...(hasSettings ? { settings } : {}),
4422
+ ...(hasGlobals ? { languageOptions: { globals } } : {})
4423
+ }));
4033
4424
  return callback({
4034
4425
  ...options,
4035
4426
  config: shouldMaterializeFileConfig ? configPath : options.config ?? configPath,
@@ -4201,7 +4592,7 @@ function applyCompatibilityDisableDirectives(result, textOptions) {
4201
4592
  return;
4202
4593
  }
4203
4594
 
4204
- const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source));
4595
+ const filtered = applyDisableDirectives(result.messages, createLinterSourceCode(source, null, { tokensOnly: true }));
4205
4596
  result.messages = filtered.messages;
4206
4597
  result.suppressedMessages.push(...filtered.suppressedMessages);
4207
4598
  }
@@ -4935,6 +5326,12 @@ function diagnosticToESLintMessage(diagnostic, ruleSeverities) {
4935
5326
  column: diagnostic.column,
4936
5327
  nodeType: null
4937
5328
  };
5329
+ if (typeof diagnostic.endLine === "number") {
5330
+ message.endLine = diagnostic.endLine;
5331
+ }
5332
+ if (typeof diagnostic.endColumn === "number") {
5333
+ message.endColumn = diagnostic.endColumn;
5334
+ }
4938
5335
  if (diagnostic.endLine != null) {
4939
5336
  message.endLine = diagnostic.endLine;
4940
5337
  }