NEO.K / MSSP 開源專案考古006-eslint-plugin-import
專案eslint-plugin-import
授權MIT
檢視版本2.32.0
日期2026-08-06
來源upstream ↗

006 — eslint-plugin-import 2.32.0:46 條規則裡只有一個碰到兄弟,而那一個是改名

原專案 import-js/eslint-plugin-import,MIT。本篇考察 2.32.0。 所有數字都是對本站 node_modules 裡那一份量出來的。

node src/main.js          # 用舊名字啟用一條規則,看它解析到哪裡
node src/island-test.js   # 26 項檢查,其中 10 項直接量上游與本倉庫

為什麼選它

今天的範例 006 把依賴規則交給了 cargo:未宣告的兄弟引用編譯不過。寫完之後該問的問題是——沒有那種工具鏈的生態系,是怎麼過日子的?

答案在本站自己的 node_modules 裡,而且量得出來:

  declared in package.json    :  25
  installed packages          : 475
  requireable but undeclared  : 450

require.resolve('@alloc/quick-lru') 成功,而它在 dependenciesdevDependencies 裡都沒有。450 個套件這個專案碰得到卻從來沒宣告過,這不是失誤,是 npm 扁平化安裝的正常結果。

eslint-plugin-import 存在的理由就是把這個保證撿回來——它有一條規則叫 no-extraneous-dependencies一個 linter 規則,用來補回套件管理器讓掉的東西。 那讓它成為範例 006 的正確對照面。

原專案的結構地圖

規則檔 46
預設 config 8
向上取用共用核心(../ 79
取用套件 108
引用兄弟規則(./ 1

共用核心是一個獨立的套件eslint-module-utils。也就是說 SMS 不只是上層目錄,它連發佈邊界都分開了。

規則之間幾乎完全不相識——45 / 46 是孤島。這個比例比我到目前為止考察過的任何一個上游都乾淨。

MSSP 重切

那唯一的一次是什麼,值得整段引出來:

// lib/rules/imports-first.js
var first = require('./first');

var newMeta = Object.assign({}, first.meta, {
  deprecated: true,
  docs: { description: 'Replaced by `import/first`.' } });

module.exports = Object.assign({}, first, { meta: newMeta });

那不是一條規則去拿兄弟的能力。那是改名時把舊門留著。 imports-firstfirst 的舊名字,這個檔案唯一做的事就是把新規則原封不動轉出去,並在 meta 上蓋一個 deprecated: true

問題是 MSSP 自己

imports-first.js 同時滿足其中一條、違反另一條。而方法沒有任何地方說過這兩條會撞在一起

重切的修法是:改名是關於目錄的事實,不是關於檔案的事實。

$ node src/main.js

== rules
  ok  rules/first               <- requested as rules/imports-first, renamed in 2.0.0
  ok  rules/no-self-import

== what this run does not say
  lines scanned                    4
  enabled, loaded, found nothing   none
  deprecated names resolved        rules/imports-first -> rules/first

FMS/catalogue.json 裡有一筆 renames,SMS 的 registry 在載入前解析它。結果是:沒有任何規則檔提到另一個規則檔(孤島測試第 1 節逐檔驗過),舊名字照常運作,而且解析這件事出現在執行報告裡,就在它產生的結果旁邊——不是在 eslint 自己的 deprecation 通道裡等人去看。

這次順手抓到自己的第三個洞

要驗「我的建置會不會把別名當違規」,就得先種一個進去。種下去之後:建置全綠。

原因是那條規則的 pattern 是 import ... from,而別名是用 export ... from 寫的。順著查下去,import "./x"(純副作用)也一樣看不見。

寫法 修之前 修之後
import { x } from "./y" 抓到 抓到
export { x } from "./y" 看不見 抓到
export * from "./y" 看不見 抓到
import "./y" 看不見 抓到

本週第三個同一條檢查的洞:08-03 是語言(Python 沒跑)、今天早上是未知語言不出聲、這個是語法(同一個語言裡的另一種寫法)。三次都是同一句話——檢查覆蓋的是它列舉到的東西,而沒列到的那些,沉默起來跟通過一模一樣。

什麼不適合拆

eslint-module-utils 不該被拆進規則裡。 79 次向上取用不是耦合,是共用核心被正確地共用。把 resolvemoduleVisitorExportMap 複製 46 份才是災難,而且那正是一個誤讀「TMS 不能有依賴」會做出來的事——規則不能依賴兄弟,向上依賴 SMS 是它應該做的事。

46 條規則不該合併成幾個大規則。 它們共用的是機制不是決定:no-cycleorder 都走 import 圖,但「什麼算問題」完全無關。合併會讓一個使用者為了關掉一條而失去另一條。

別名檔在 eslint 的 API 下是自然的形狀,不是錯誤。 eslint 的 plugin 介面收的是一個 { ruleName: ruleObject } 物件——沒有地方可以放「這個名字是那個名字的舊稱」。上游沒有目錄可以記錄改名,所以改名只能變成一個檔案。我的重切能做得不一樣,是因為我有一個 registry;那是介面差異,不是判斷差異。

這次沒有解決什麼

改良點 8,每一項要說出把它變成量測需要多少。

重切原始碼

FMS

FMS/catalogue.json
{
  "name": "rule-registry-recut",
  "what_it_is": "A rule registry where a deprecated rule name is a declared rename, not a module that imports the module it was renamed to.",
  "examined": {
    "project": "eslint-plugin-import",
    "version": "2.32.0",
    "license": "MIT",
    "measured": {
      "rule_files": 46,
      "configs": 8,
      "imports_of_a_sibling_rule": 1,
      "imports_reaching_up_to_shared_core": 79,
      "imports_of_a_package": 108,
      "the_one_sibling": "lib/rules/imports-first.js requires ./first, spreads it, and overrides meta with deprecated:true"
    },
    "context_measured_in_this_repo": {
      "packages_declared_in_package_json": 25,
      "packages_installed_in_node_modules": 475,
      "requireable_but_undeclared": 450,
      "proof": "require.resolve('@alloc/quick-lru') succeeds and it appears in neither dependencies nor devDependencies"
    }
  },
  "the_finding": "45 of 46 rules are islands. The one exception is not a rule reaching a sibling for a capability — it is a rename that kept the old door open. MSSP's own modules disagree about it: module 02 forbids a TMS importing a sibling TMS, and module 06 requires replacement before removal. An alias unit satisfies one by violating the other.",
  "the_repair": "A rename is a fact about the catalogue, so it is recorded here and resolved by the loader. No rule file mentions another rule file, the old name still works, and the resolution is reported rather than silent.",
  "renames": [
    {
      "from": "rules/imports-first",
      "to": "rules/first",
      "since": "2.0.0",
      "reason": "renamed for consistency with the other rule names"
    }
  ],
  "sets": {
    "FMS": "this file: the catalogue, including which names are renames of which",
    "SCL": "which rules this deployment enables",
    "SMS": "the registry that resolves a name to a rule, and the walk that applies rules to a file",
    "TMS": "one file per rule, each importing nothing",
    "DMS": "what ran, what was skipped, and every deprecated name that was resolved"
  },
  "non_goals": [
    "Being a linter. Two rules, a hand-rolled line scanner, no AST.",
    "Claiming eslint-plugin-import should have done this. A rename record needs a registry that owns names; eslint's plugin API hands over an object keyed by rule name, and the alias-as-module is the natural shape under that API."
  ]
}

SCL

SCL/policy.js
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(fs.readFileSync(path.join(here, "policy.json"), "utf8"));

export const enabled = () => [...config.enabled];
export const allowsDeprecatedNames = () => config.deprecated_names === "allow";
SCL/policy.json
{
  "policy_version": "1.0",
  "_comment": [
    "A deployment may enable a rule by its deprecated name. That is the case",
    "the whole entry is about: it has to keep working, and it has to be",
    "visible that it happened."
  ],
  "enabled": ["rules/imports-first", "rules/no-self-import"],
  "deprecated_names": "allow"
}

SMS

SMS/registry.js
// Resolving a rule name to a rule, including names that are renames.
//
// The repair lives here. Upstream, `imports-first` is a module that requires
// `./first` and re-exports it with deprecated:true — which makes the rename a
// property of a file, and makes that file the only one of 46 that reaches a
// sibling. Here the rename is a property of the catalogue, so no rule file
// mentions another rule file and the old name still works.

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const catalogue = JSON.parse(
  fs.readFileSync(path.join(here, "..", "FMS", "catalogue.json"), "utf8"),
);

/** Follow renames to the name that has a file, reporting the hops taken. */
export function resolveName(requested) {
  const hops = [];
  let name = requested;
  // Bounded: a rename cycle is a catalogue error, not something to loop on.
  for (let i = 0; i <= catalogue.renames.length; i += 1) {
    const rename = catalogue.renames.find((r) => r.from === name);
    if (!rename) return { name, hops, deprecated: hops.length > 0 };
    hops.push({ from: rename.from, to: rename.to, since: rename.since });
    name = rename.to;
  }
  throw new Error(`rename cycle starting at ${requested}`);
}

export async function load(requested) {
  const resolved = resolveName(requested);
  const file = path.join(here, "..", "TMS", `${resolved.name}.js`);
  if (!fs.existsSync(file)) {
    return { ...resolved, rule: null, why: `no file at TMS/${resolved.name}.js` };
  }
  const module = await import(`../TMS/${resolved.name}.js`);
  return { ...resolved, rule: module.rule, why: null };
}

/** Apply one rule to one file's lines. The walk knows no rule by name. */
export function apply(rule, lines) {
  const findings = [];
  lines.forEach((text, index) => {
    const problem = rule.check(text, index + 1);
    if (problem) findings.push(problem);
  });
  return findings;
}

TMS

TMS/rules/first.js
// import statements must come before other statements.
//
// Imports nothing — not even the registry that loads it.

// Per-run state, cleared by reset(). A rule that carries state between files
// and does not say so is the shape archaeology 003 found in logging.
let sawStatement = false;

export const rule = {
  id: "rules/first",
  description: "imports must come first",
  reset() {
    sawStatement = false;
  },
  check(text, line) {
    const trimmed = text.trim();
    if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*")) return null;
    if (!trimmed.startsWith("import ")) {
      sawStatement = true;
      return null;
    }
    if (!sawStatement) return null;
    return { line, id: "rules/first", message: "import appears after a statement" };
  },
};
TMS/rules/no-self-import.js
// A module must not import itself.
//
// Chosen as the second rule because it needs the file's own name, which the
// walk supplies — a rule that reached for it would have to know about the
// runner, and then it would not be an island.

export const rule = {
  id: "rules/no-self-import",
  description: "a module must not import itself",
  filename: null,
  reset(filename) {
    this.filename = filename ?? null;
  },
  check(text, line) {
    const match = text.match(/^\s*import\s[^;]*?from\s+["']([^"']+)["']/);
    if (!match || !this.filename) return null;
    const target = match[1].replace(/^\.\//, "").replace(/\.js$/, "");
    const self = this.filename.replace(/\.js$/, "");
    return target === self
      ? { line, id: "rules/no-self-import", message: `imports itself (${match[1]})` }
      : null;
  },
};

DMS

DMS/report.js
// What ran, under what name, and what did not run.

export function render(run) {
  const out = [];
  out.push("\n== rules");
  for (const entry of run.rules) {
    const via = entry.deprecated
      ? `  <- requested as ${entry.requested}, renamed in ${entry.hops.at(-1).since}`
      : "";
    out.push(`  ${entry.loaded ? "ok " : "?? "} ${entry.name.padEnd(24)}${via}`);
    if (!entry.loaded) out.push(`      ${entry.why}`);
  }

  out.push("\n== findings");
  if (run.findings.length === 0) {
    out.push("  none — and the next section says how much that is worth");
  }
  for (const f of run.findings) {
    out.push(`  ${String(f.line).padStart(3)}  ${f.id.padEnd(24)} ${f.message}`);
  }

  // Upstream reports a deprecated rule through eslint's own deprecation
  // channel, which the caller has to be looking at. Here it is in the run
  // report, beside the result it produced.
  out.push("\n== what this run does not say");
  out.push(`  lines scanned                    ${run.linesScanned}`);
  const idle = run.rules.filter((r) => r.loaded && !run.findings.some((f) => f.id === r.name));
  out.push(
    `  enabled, loaded, found nothing   ${idle.length ? idle.map((r) => r.name).join(", ") : "none"}`,
  );
  out.push(
    `  deprecated names resolved        ${
      run.rules.filter((r) => r.deprecated).map((r) => `${r.requested} -> ${r.name}`).join(", ") || "none"
    }`,
  );
  return `${out.join("\n")}\n`;
}

root

island-test.js
// The island test, and the measurement of eslint-plugin-import that produced
// the finding.
//
//   node src/island-test.js

import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";

import { resolveName, load, apply } from "./SMS/registry.js";
import { enabled, allowsDeprecatedNames } from "./SCL/policy.js";

const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const failures = [];
const report = (label, ok, detail = "") => {
  console.log(`  ${ok ? "PASS" : "FAIL"}  ${label}${detail ? ` - ${detail}` : ""}`);
  if (!ok) failures.push(label);
};

console.log("\n== 1. each rule is an island, including the deprecated one");
{
  const rulesDir = path.join(here, "TMS", "rules");
  const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith(".js"));
  report("there are two rule files", files.length === 2, files.join(", "));

  for (const file of files) {
    const source = fs.readFileSync(path.join(rulesDir, file), "utf8");
    // Every form that reaches another module, not only `import … from`. The
    // site build was blind to `export … from` and to a bare `import "./x"`
    // until this morning, and a rename alias is written with exactly the first
    // of those.
    const reaches = [
      /^\s*import\s[^;]*?\sfrom\s+["']([^"']+)["']/gm,
      /^\s*export\s[^;]*?\sfrom\s+["']([^"']+)["']/gm,
      /^\s*import\s+["']([^"']+)["']/gm,
    ].flatMap((re) => [...source.matchAll(re)].map((m) => m[1]));
    report(`rules/${file} reaches nothing`, reaches.length === 0, reaches.join(", ") || "no module specifiers at all");
  }

  const { rule } = await import("./TMS/rules/first.js");
  rule.reset();
  const out = apply(rule, ['import { a } from "./a.js";', "const x = 1;", 'import { b } from "./b.js";']);
  report("rules/first works with no sibling imported", out.length === 1, JSON.stringify(out[0]));
}

console.log("\n== 2. the rename is a fact about the catalogue, not about a file");
{
  const resolved = resolveName("rules/imports-first");
  report("the deprecated name resolves", resolved.name === "rules/first", `-> ${resolved.name}`);
  report("and says it was deprecated", resolved.deprecated === true, `${resolved.hops.length} hop(s)`);
  report("and names the version it changed in", resolved.hops[0]?.since === "2.0.0", resolved.hops[0]?.since);

  const aliasFile = path.join(here, "TMS", "rules", "imports-first.js");
  report("there is no file for the old name at all", !fs.existsSync(aliasFile),
    "upstream has one, and it is the only file of 46 that requires a sibling");

  const current = resolveName("rules/first");
  report("a current name resolves to itself with no hops", current.name === "rules/first" && !current.deprecated);
}

console.log("\n== 3. SCL can refuse a deprecated name, and the refusal is visible");
{
  report("this deployment allows deprecated names", allowsDeprecatedNames());
  report("and policy enables the OLD name on purpose", enabled().includes("rules/imports-first"),
    enabled().join(", "));
  const loaded = await load("rules/imports-first");
  report("loading the old name yields the current rule", loaded.rule?.id === "rules/first", loaded.rule?.id);
  const missing = await load("rules/never-written");
  report("an enabled rule with no file is reported, not thrown", missing.rule === null, missing.why);
}

console.log("\n== 4. the checks can fail");
{
  // A rule file that re-exports a sibling — the exact upstream shape — must be
  // caught by the section 1 check. Evaluated, not asserted.
  const planted = 'export { rule } from "./first.js";\n';
  const caught = [
    /^\s*import\s[^;]*?\sfrom\s+["']([^"']+)["']/gm,
    /^\s*export\s[^;]*?\sfrom\s+["']([^"']+)["']/gm,
    /^\s*import\s+["']([^"']+)["']/gm,
  ].flatMap((re) => [...planted.matchAll(re)].map((m) => m[1]));
  report("a re-export of a sibling is detected", caught.includes("./first.js"), caught.join(", "));

  const bare = 'import "./first.js";\n';
  const caughtBare = [...bare.matchAll(/^\s*import\s+["']([^"']+)["']/gm)].map((m) => m[1]);
  report("a bare side-effect import is detected", caughtBare.includes("./first.js"), caughtBare.join(", "));

  // And a rename cycle must throw rather than loop.
  let threw = false;
  try {
    resolveName("rules/first");
  } catch {
    threw = true;
  }
  report("a well-formed catalogue does not throw", !threw);
}

console.log("\n== 5. measured against eslint-plugin-import 2.32.0 itself");
{
  let pkg = null;
  let plugin = null;
  try {
    pkg = require("eslint-plugin-import/package.json");
    plugin = require("eslint-plugin-import");
  } catch {
    // fall through
  }
  if (!plugin) {
    report("eslint-plugin-import is installed for the live measurement", false, "run npm i in the site root");
  } else {
    report("examined version is the installed version", pkg.version === "2.32.0", pkg.version);
    report("rule count as recorded", Object.keys(plugin.rules).length === 46,
      `${Object.keys(plugin.rules).length} rules`);

    const dir = path.dirname(require.resolve("eslint-plugin-import"));
    const rulesDir = path.join(dir, "rules");
    const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith(".js"));
    let siblings = [];
    let upward = 0;
    for (const f of files) {
      const src = fs.readFileSync(path.join(rulesDir, f), "utf8");
      for (const m of src.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
        if (m[1].startsWith("./")) siblings.push(`${f} -> ${m[1]}`);
        else if (m[1].startsWith("../")) upward += 1;
      }
    }
    report("exactly one rule requires a sibling rule", siblings.length === 1, siblings.join(", "));
    report("and that one is the deprecation alias",
      siblings[0] === "imports-first.js -> ./first",
      "a rename that kept the old door open, not a capability reaching a sibling");
    report("the alias is registered under the old name",
      typeof plugin.rules["imports-first"] === "object");
    report("and marks itself deprecated", plugin.rules["imports-first"].meta.deprecated === true);
    report("while the rule it aliases does not", !plugin.rules.first.meta.deprecated);
    report("the shared core is reached upward, not sideways", upward > 40, `${upward} upward requires`);
  }
}

console.log("\n== 6. the context this entry is about, measured in this repo");
{
  const root = path.join(here, "..", "..", "..", "..");
  const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
  const declared = new Set([
    ...Object.keys(manifest.dependencies ?? {}),
    ...Object.keys(manifest.devDependencies ?? {}),
  ]);
  const modules = path.join(root, "node_modules");
  const names = [];
  for (const entry of fs.readdirSync(modules)) {
    if (entry.startsWith(".")) continue;
    if (entry.startsWith("@")) {
      for (const scoped of fs.readdirSync(path.join(modules, entry))) names.push(`${entry}/${scoped}`);
    } else names.push(entry);
  }
  const installed = names.filter((n) => fs.existsSync(path.join(modules, n, "package.json")));
  const undeclared = installed.filter((n) => !declared.has(n));
  report("this repo declares far fewer packages than it can reach",
    undeclared.length > declared.size * 10,
    `declared ${declared.size}, installed ${installed.length}, requireable but undeclared ${undeclared.length}`);

  const reachable = undeclared.find((n) => {
    try {
      require.resolve(n, { paths: [root] });
      return true;
    } catch {
      return false;
    }
  });
  report("and an undeclared one really does resolve", Boolean(reachable),
    `${reachable} — proven by resolution, not by reading a directory listing`);
}

console.log("");
if (failures.length) {
  console.log(`  ${failures.length} check(s) failed: ${failures.join(", ")}`);
  process.exit(1);
}
console.log("  island test passed");
main.js
// Run the enabled rules over a small sample file.
//
//   node src/main.js

import { load, apply } from "./SMS/registry.js";
import { enabled, allowsDeprecatedNames } from "./SCL/policy.js";
import { render } from "./DMS/report.js";

const SAMPLE = [
  'import { a } from "./a.js";',
  "const x = 1;",
  'import { b } from "./b.js";',   // rules/first should object
  'import { self } from "./sample.js";', // rules/no-self-import should object
].join("\n");

const rules = [];
const findings = [];
const lines = SAMPLE.split("\n");

for (const requested of enabled()) {
  const loaded = await load(requested);
  if (loaded.deprecated && !allowsDeprecatedNames()) {
    rules.push({ ...loaded, requested, loaded: false, why: "SCL refuses deprecated names" });
    continue;
  }
  rules.push({ ...loaded, requested, loaded: Boolean(loaded.rule) });
  if (!loaded.rule) continue;
  loaded.rule.reset?.("sample.js");
  findings.push(...apply(loaded.rule, lines));
}

process.stdout.write(
  render({ rules, findings, linesScanned: lines.length }),
);