怎么用脚本统计 Effect 里每个模块的稳定性?

Effect 4.0.5 里一共有几百个模块,每个导出上都挂着 @stability 标签,靠人眼翻文档没法知道哪个模块整体是 unstable。我写了一个脚本,直接扫 node_modules/effect/dist 里的 .d.ts。

在装有 effect@4.0.5 的项目根目录执行 npx tsx scan.ts,脚本会读取每个导出声明前紧邻的 JSDoc 里的 @stability,按模块分成 stable、unstable、experimental、mixed、untagged 五类。

稳定性标签是什么、为什么 v4 不再用 effect/unstable 路径,见 Effect v4 怎么标注 API 的稳定性?。这里只讲脚本本身:它怎么分类,以及哪些地方是简化。

脚本长什么样?

import { readdirSync, readFileSync, statSync } from "node:fs"
import { join, relative } from "node:path"

type Tag = "stable" | "unstable" | "experimental"
const tags: ReadonlyArray<Tag> = ["stable", "unstable", "experimental"]

const dist = join(process.cwd(), "node_modules/effect/dist")

const walk = (dir: string): Array<string> =>
  readdirSync(dir).flatMap((name) => {
    const path = join(dir, name)
    if (name === "internal") return []
    if (statSync(path).isDirectory()) return walk(path)
    return name.endsWith(".d.ts") && name !== "index.d.ts" ? [path] : []
  })

// 每个导出声明前紧邻的 JSDoc 里的 @stability 标签,按声明名去重
const declRe =
  /\/\*\*((?:(?!\*\/)[^])*)\*\/\s*\n\s*export (?:declare )?(?:const|function|interface|class|type|namespace) (\w+)/g

interface Row {
  readonly module: string
  readonly counts: Record<Tag, number>
  readonly nonStable: ReadonlyArray<string>
}

const rows: Array<Row> = walk(dist).sort().map((file) => {
  const src = readFileSync(file, "utf8")
  const bySymbol = new Map<string, Tag>()
  for (const m of src.matchAll(declRe)) {
    const tag = /@stability (\w+)/.exec(m[1] ?? "")?.[1]
    if (tag !== undefined && (tags as ReadonlyArray<string>).includes(tag)) {
      bySymbol.set(m[2] ?? "", tag as Tag)
    }
  }
  const counts: Record<Tag, number> = { stable: 0, unstable: 0, experimental: 0 }
  for (const tag of bySymbol.values()) counts[tag]++
  return {
    module: "effect/" + relative(dist, file).replace(/\.d\.ts$/, "").replace(/^(.*)\/\1$/, "$1"),
    counts,
    nonStable: [...bySymbol].filter(([, t]) => t !== "stable").map(([name]) => name)
  }
})

const classify = ({ counts: c }: Row): string => {
  const total = c.stable + c.unstable + c.experimental
  if (total === 0) return "untagged"
  for (const tag of tags) if (c[tag] === total) return tag
  return "mixed"
}

const groups = new Map<string, Array<Row>>()
for (const row of rows) {
  const key = classify(row)
  groups.set(key, [...(groups.get(key) ?? []), row])
}

for (const [kind, list] of groups) console.log(`${kind}: ${list.length} modules`)

const bucket = (rowsIn: ReadonlyArray<Row>) => {
  const out = new Map<string, number>()
  for (const r of rowsIn) {
    const parts = r.module.split("/")
    const key = parts.length > 2 ? `effect/${parts[1]}` : r.module
    out.set(key, (out.get(key) ?? 0) + 1)
  }
  return [...out].map(([k, n]) => (k.split("/").length === 2 && n > 1 ? `${k}/* (${n})` : k))
}

console.log("\nunstable:", bucket(groups.get("unstable") ?? []).join(", "))
console.log("\nmixed (module: non-stable declarations):")
for (const r of groups.get("mixed") ?? []) {
  const names = r.nonStable.slice(0, 6).join(", ")
  console.log(`  ${r.module}: ${r.nonStable.length} (${names}${r.nonStable.length > 6 ? ", ..." : ""})`)
}

它是怎么分类的?

脚本遍历 dist 下的 .d.ts,跳过 internal 目录和 index.d.ts。对每个文件,用正则取出每个导出声明前紧邻的 JSDoc,读出里面的 @stability,按声明名去重。

然后按模块分类:整个模块的声明都是同一个标签,就归为该标签;标签混杂就是 mixed;一个标签都没有就是 untagged。

输出是什么样的?

我在 effect@4.0.5 上运行得到:

unstable: 230 modules
stable: 113 modules
mixed: 12 modules
untagged: 3 modules
experimental: 11 modules

每个模块具体是什么,见 Effect 4.0.5 里哪些模块仍是 unstable?。

这个统计有哪些简化?

脚本按声明统计,不看 re-export;模块内同一名字的多个重载若标签不同,以最后一次出现为准,因为它用声明名做了去重。

另外它的正则依赖「JSDoc 紧邻 export 声明」这种排版,换个 effect 版本可能需要调整,我只在 4.0.5 上运行过。

本文基于 effect 4.0.5,脚本和输出都是我在该版本上实际运行的结果。其他版本没有运行过,正则能否适用未知。