bivium

中文文档
0.3.0发布于 2026/11/03MITTypeScriptES Module零依赖构建来源已验证

Run only the tests a change can affect, and never miss one.

原始使用文档
文档目录12 节
  1. What it reads
  2. Config
  3. A change no test reaches
  4. Recipes
  5. Lockfiles
  6. Annotations
  7. CLI
  8. Dead globs
  9. CI
  10. Narrowers
  11. From code
  12. Compared with

bivium

Run only the tests a change can affect, and never miss one.

bivium builds a static, file-level dependency graph of a repository and walks it backwards from the files a change touches to the tests that reach them. It fails closed: anything it can't read (a non-literal import(), an unresolved specifier, a disk read with no declared inputs) counts as depending on everything, and a change it can't place runs the whole suite, with a reason. It picks the tests; your runner runs them.

It has no dependencies and needs no install of the repository it plans: it reads the git index and the working tree, never node_modules. CI can plan in a job that skips the install, and skip the test jobs whose selection is none. It runs on Node ≥ 22 and Bun. It was extracted from carbon-components-svelte's test:changed.

bun add -d bivium
// bivium.config.ts
import { defineConfig } from "bivium";

export default defineConfig({
  suites: {
    unit: { tests: "tests/**/*.test.ts", run: "vitest run {files}", root: "tests" },
    e2e: {
      tests: "e2e/**/*.test.ts",
      run: "playwright test {files}",
      root: "e2e",
      urls: { "/*": "e2e/fixtures/*" }, // page.goto("/x.html") → e2e/fixtures/x.html
    },
  },
  roots: ["src", "tests", "e2e"], // a changed file outside them runs everything
  ignore: ["**/*.md", "docs/**"],
});
$ bunx bivium plan
unit tests: 3 of 12 files
  tests/Button.test.ts
  tests/Modal.test.ts
  tests/css/unknown.test.ts
e2e tests: none
$ bunx bivium run --suite unit     # vitest run tests/Button.test.ts …

What it reads

A one-pass lexer reads JavaScript and TypeScript (.js, .ts, .jsx, .tsx, and their m/c variants), the scripts and markup expressions of .svelte, the scripts of .vue, .astro and .html, and the script[src], link[href] and iframe[src] of .html. Only code counts: an import spelled in a string or a comment adds no edge.

It followsAs
Static, side-effect and dynamic imports, export … from, require()Edges to every file the specifier can load: each extension that exists, each exports condition. A missing x.js (.jsx, .mjs, .cjs) is its TypeScript source, as TypeScript reads it: x.ts, x.tsx or x.d.ts, through any path, alias or exports target. A value import that finds only x.d.ts is unknown: what runs isn't in the graph
vi.mock(), vi.importActual() and the rest of the familyEdges
import.meta.glob()Every matching file, now and later
JSDoc @type {import("x")} and @importEdges (TypeScript reads them)
%sveltekit.assets%/x in a SvelteKit app.htmlAn edge to static/x in the project, the nearest directory with a svelte.config.* or a package.json. %sveltekit.head%, %sveltekit.body% and the rest aren't references
page.goto("/x.html") in a suite with urlsAn edge to the page
compilerOptions.paths and baseUrl of the nearest tsconfig.json, with its relative extendsEdges to every target that exists, tried after alias and before package.json exports. A tsconfig.json that doesn't parse maps nothing, and check lists it
#name subpath imports, through the imports of the importing file's own package.jsonEdges to every condition's target that exists; an unmatched #name is unknown
Named imports from a module made only of re-exportsAn edge to the one file that exports each name, when the package declares sideEffects
A file's own package and its ancestors' package.json dependencies, and builtinspkg: nodes
Anything elseUnknown: the file depends on every change

A root bun.lock, package-lock.json or pnpm-lock.yaml isn't a source file: a change to one seeds the packages it changed (see Lockfiles).

A file it can't lex cleanly is read by a regex extractor instead, which over-approximates. CSS isn't scanned: generated stylesheets reach their inputs through edges, and a narrower can do better.

Config

bivium.config.ts (or .mts, .mjs, .js, .json) in the repository root, or --config <file>. A .ts config needs Bun or Node ≥ 22.18. Every glob key takes one glob or a list. An unknown key is an error, since a misspelled one would quietly select less.

KeyMeaning
suites.<name>.testsThe suite's test files
suites.<name>.runThe command bivium run runs. {files} is replaced by the selection (nothing for all); without it, the files are appended
suites.<name>.rootWhere the orphan rule applies: a changed file here that no test of the suite reaches is wired some other way (a setup file, a runner config), so the whole suite runs. Paths, not globs (e2e, not e2e/**): a glob is an error. Default: the whole repository
suites.<name>.urlsURL pattern → file pattern, for page.goto() calls under root
suites.<name>.pagesPages only a goto() opens; files only they load are wired to no test
suites.<name>.setupFiles the runner loads before every test (Vitest and Jest setupFiles, Mocha --require, Playwright globalSetup): every test of the suite depends on them and what they import. bivium doesn't read the runner's config, so name them here
suites.<name>.wiringFiles whose change runs the whole suite: another harness, a runner config
suites.<name>.nonInputsFiles under root the runner never reads, such as type-check inputs
suites.<name>.typesfalse when the suite runs code and can't observe types (Vitest, Jest, Playwright, bun test): a change that reaches its tests only through import type, export type … from, JSDoc types or a declaration file selects none of them. For the orphan rule a type-only reach counts only for a declaration file, since the runner may load any other file by name: list a types-only module under root in nonInputs. Default: true, which a type-checking suite (tsc, vitest --typecheck) needs. An inline import { type A } is always an edge: under verbatimModuleSyntax it still loads the module
suites.<name>.outputThe step output plan --github writes. Default: <name>-tests
rootsPaths (not globs) the graph reads. A changed file outside them that isn't ignored runs everything. Default: the whole repository
ignoreFiles no test can observe except through an edge (docs, stories). A change to one selects only the tests that reach it, never runs a suite by the orphan rule, and doesn't select the tests whose dependencies are unknown
aliasSpecifier prefix → path, as the bundler aliases it. Tried before tsconfig paths and package.json exports. A key ending in / is the prefix, as Vite reads it: { "@/": "src/" } maps @/x to src/x
edgesGenerated file glob → its inputs, which it depends on transitively, as with @depends-on: { "dist/*.css": "styles/**" }. A key may name files git doesn't list, such as a gitignored generator output ({ ".velite/*": "content/**" }): an import that resolves under it, by extension or as an index, lands on that file instead of being unresolved. In a value, $1, $2… are what the key's wildcards matched, counted from the left: { "examples/*/package.json": "examples/$1/**" }
readsFile glob → the files it reads as text, as with @reads: only a direct change to one counts, so their imports and unknowns don't spread to it. An edges or reads key that names one file (no wildcard) declares what it reads, as its own @depends-on or @reads would, so its disk reads aren't unknown. Values take $1… as in edges: { "tests/*.test.ts": "tests/__snapshots__/$1.test.ts.snap" }
selfThe selector's own files: a change to one runs everything. The config file is always one
importOnlyFiles only an import reaches: deleting one never orphans a suite
hermeticFiles whose disk reads and non-literal imports reach no file of the repository: a library's fs calls on its caller's paths, a test that reads only the temporary files it writes. They add no unknown
barrelsModules that may bind named imports. Default: every module made only of re-exports
externalPackages a dependency installs that your code imports without declaring
inertPackages no test uses except by importing them: linters, formatters, type packages (["@biomejs/biome", "knip", "@types/*"]). A lockfile change that only they reach selects nothing. With loadedByName set, only packages with a bin need listing
loadedByNamePackages a test may load other than by an import: a test environment or a plugin a config names by string (["jsdom", "ts-jest"]). Setting it, even to [], says every other package without a bin is loaded only by imports, so a lockfile change that reaches one no test imports selects only its importers. A package with a bin always counts as one a test may run
extensionsSuffixes tried after a specifier
deadGlobsDead globs check allows, each { file, glob, reason }, the glob as file spells it
knownUnknownsNodes check allows to have unknown dependencies, each { file, reason }. With it set, even to [] (which is --max-unknown 0), check fails on an unknown node some test reaches that it doesn't list, and on an entry that's no longer unknown, so a new unknown can't replace one that went away
narrowersSee Narrowers
baseThe default base ref. Otherwise the upstream default branch (origin/HEAD, then origin/main, origin/master, main, master)
cacheThe cache's directory, or false. Default: node_modules/.cache/bivium, or .bivium/cache

A change no test reaches

A changed file under a suite's root that no test of the suite reaches is wired some other way (a runner config, a setup file), so the whole suite runs: the orphan rule. With the default root, the whole repository, any file bivium can't trace to a test runs the suite, which is sound and coarse. Set root on every suite to the directories its tests and sources live in, and say what the rest is:

You changed…and want…Use
a file no test can observe: docs, stories, a changelognothing to run, unless some test imports itignore
a file under the suite's root only another tool reads: type-check inputsnothing to run for this suitethe suite's nonInputs
a runner config or a second harnessthe whole suitethe suite's wiring
a setup file the runner loads before every test, or what it importsevery test of the suitethe suite's setup
the selector's config, or its own codeeverythingself (the config file always is)
a declaration file, in a suite that runs codenothingthe suite's types: false
a types-only module only import type reaches, in a suite that runs codenothingthe suite's types: false, and the module in its nonInputs
a file a test lists but doesn't read (a route, a story)the test, when one is added or deleted// @lists <glob> in the test
a file a build turns into what tests loadthe tests that load the outputedges from the output to its inputs

A file outside roots that isn't ignored runs everything: it's the backstop for wiring the graph can't see, such as a CI workflow.

Recipes

Vitest or Jest. Name the setup files the runner loads, mirror resolve.alias (a Vite key like "@/" works as written), give snapshots to their tests, and, for a suite that runs code, skip type-only edges:

suites: {
  unit: {
    tests: "src/**/*.test.{ts,tsx}",
    run: "vitest run {files}",
    root: "src",
    setup: ["src/setupTests.ts"], // vitest.config.ts `setupFiles`
    wiring: ["vitest.config.ts"],
    types: false,
    nonInputs: ["src/**/types.ts"], // types only: nothing loads them at runtime
  },
},
alias: { "@/": "src/" },
reads: { "src/**/*.test.{ts,tsx}": "src/$1__snapshots__/$2.test.$3.snap" },

tsconfig paths (what vite-tsconfig-paths reads) are read without an alias. An extensionless vi.mock("@/x") resolves as an import does. A test environment the runner loads by name (jsdom, happy-dom) goes in loadedByName if you set it.

pnpm workspaces. Workspace packages resolve through their package.json exports, and a .js specifier into one (@acme/ui/Button.js) reaches its .ts source. A pnpm-lock.yaml change selects through the packages it changed. Set roots to the packages and apps whose changes the suites should see, and a suite per test runner.

Playwright. Map page.goto() URLs to the pages they open with urls, list pages only a goto() opens in pages, and name playwright.config.ts in wiring:

e2e: {
  tests: "e2e/**/*.spec.ts",
  root: "e2e",
  urls: { "/*": "e2e/fixtures/*.html" },
  wiring: ["playwright.config.ts"],
},

Generated code. A content or type generator that writes into a gitignored directory (.velite, .contentlayer/generated) is an edges entry from what it writes to what it reads: { ".velite/*": ["content/**", "velite.config.ts"] }. An import of #velite or ../.velite then reaches the content, and not every change.

Lockfiles

A dependency bump changes a lockfile, which no file imports. When a root bun.lock, package-lock.json (v2 or v3) or pnpm-lock.yaml (v6 or v9) changes, bivium diffs it against the merge base and seeds the walk with the pkg: nodes of the packages whose entries changed, and of every package that depends on one, so a svelte bump selects what imports svelte, or a package built on it. A package.json whose only change is to dependencies the lockfile records the same way counts as those packages changing.

A changed package can also be used in ways the graph can't see: a CLI a test spawns, a runner, a type package. So when a change reaches a package that a package.json declares, or that some file imports, and that no test imports, everything runs, with the reason (bun.lock: playwright 1.63.0 → 1.64.0, and no test imports it), unless the package is inert. With loadedByName set, only a package with a bin in the lockfile, or one it lists, counts as used that way: a bump that reaches a library only a CMS config imports selects nothing. Everything also runs for a lockfile bivium can't parse, one whose settings other than its packages changed (overrides, patches, pnpm's settings, catalogs, or the version of pnpm itself), and a package.json change the lockfile doesn't record. Other lockfiles (yarn.lock, bun.lockb) are files like any other: outside the roots, a change to one runs everything.

$ bunx bivium plan
bun.lock: @biomejs/biome 2.5.14 → 2.6.3, inert
bun.lock: svelte 5.57.1 → 5.57.2
unit tests: 4 of 12 files
…

Annotations

CommentMeaning
// @depends-on <glob> [glob…]Inputs that aren't imports: files a script reads, a tool it spawns. Transitive. A file that reads the disk without one (or @reads, or @lists) is unknown, unless it's hermetic
// @reads <glob> [glob…]Files read as text. Only a direct change to one counts, not a change to what it imports. The config's reads says the same for files that can't hold a comment
// @lists <glob> [glob…]Files whose names it lists, not their contents: a convention test that checks every route has a handler. Only adding, deleting or renaming one counts; an edit doesn't select it, and doesn't run its suite by the orphan rule either
// @glob-registryThis file's import.meta.glob is a lookup table and adds no edges: whatever loads an entry names it itself

CLI

CommandDoes
bivium plan [base] [--json] [--github]Prints each suite's selection (none, the files out of how many, all files when each reaches a change, or all (full run)) and why a suite runs in full. --json adds the base, merge base, changed files, narrower results, changed packages, each suite's test count (totals), and the reasons as data (causes, each a FullRunCause: { kind, suites, file?, package?, narrower?, message }). --github also writes each suite's selection to $GITHUB_OUTPUT
bivium run [base] [--suite <name>]Runs the suite's run command with its selection (default: the first suite), or skips it when nothing is affected
bivium why <test> [file] [--format tree|chain|source|json] [--all]The shortest chain of edges from a changed file, or from file, to the test, with what spells each edge and the line it's on, and how many other changes reach the test. --format tree (the default) draws it from the test; chain reads from the change to the test; source prints each hop's line of source; json (or --json) prints the chain as data. --all shows every change's chain. For a test that isn't selected, it says what the file's chains reach instead
bivium check [--max-unknown <n>] [--json]Counts what it found by cause first, then lists every node with unknown dependencies, with where and how to fix each cause, and fails when more than n of them reach a test, or on one knownUnknowns doesn't list. A node no test depends on is listed apart and not counted: plans only run tests, so it widens nothing. Also lists tsconfig.json files that don't parse, and dead globs, and fails on one deadGlobs doesn't allow or on an entry that matches none. --json prints the report as data
bivium graph [--suite <name>] [--top <n>] [--json]How many of each suite's tests a change to each file selects through the graph (leaving out the tests that run on any change, which it counts apart): the n files that reach the most, and how files spread from none to every test. A few hub files that reach most tests are what make plans wide. --json prints each suite's total and everyChange tests, and every node with its imports and importers (file edges), fanIn per suite and whether it's unknown

--config, --cwd and --no-cache work with every command, before or after it, and why and check take --base <ref>. --help (or -h) prints usage. An unknown command, option or --format is an error. Exit status: 0 when done, 1 when check finds problems, 2 for a usage or configuration error; run exits with its command's status.

why names each edge by what spells it: import, type (an import type, export type … from, JSDoc type, or an edge to or from a declaration file), setup (a suite's setup file), goto (a page page.goto() opens), html (a module an HTML page loads: script[src], link[href], iframe[src] or an inline script's import), name (a named import bound to a barrel name) and export (the file that exports it), glob (import.meta.glob), depends-on, edges (the config's), reads and lists.

$ bunx bivium why e2e/accordion.test.ts
e2e/accordion.test.ts
└─ goto    e2e/fixtures/accordion.html             e2e/accordion.test.ts:13
   └─ html    e2e/fixtures/accordion.ts            e2e/fixtures/accordion.html:10
      └─ import  e2e/fixtures/Fixture.svelte       e2e/fixtures/accordion.ts:1
         └─ name    src/index.js#AccordionItem     e2e/fixtures/Fixture.svelte:2
            └─ export  src/AccordionItem.svelte    src/index.js:2
               ● changed
+ 1 more change reaches this test: css/_accordion.scss (--all shows each chain)

$ bunx bivium why e2e/accordion.test.ts --format chain
src/AccordionItem.svelte changed
  exported as AccordionItem  src/index.js:2
  imported by                e2e/fixtures/Fixture.svelte:2
  imported by                e2e/fixtures/accordion.ts:1
  loaded by page             e2e/fixtures/accordion.html:10
  opened by                  e2e/accordion.test.ts:13  ← selected
+ 1 more change reaches this test: css/_accordion.scss (--all shows each chain)

$ bunx bivium why e2e/accordion.test.ts --format source
selected: e2e/accordion.test.ts ← src/AccordionItem.svelte (5 hops)

  --> e2e/accordion.test.ts:13  goto
13 | await page.goto("/accordion.html");
  --> e2e/fixtures/accordion.html:10  html
10 | <script type="module" src="./accordion.ts"></script>
  --> e2e/fixtures/accordion.ts:1  import
 1 | import Fixture from "./Fixture.svelte";
  --> e2e/fixtures/Fixture.svelte:2  name
 2 | import { Accordion, AccordionItem } from "carbon-components-svelte";
  --> src/index.js:2  export
 2 | export { default as AccordionItem } from "./AccordionItem.svelte";
  ● src/AccordionItem.svelte: changed
+ 1 more change reaches this test: css/_accordion.scss (--all shows each chain)

For a test that depends on every change, chain says why, where, and how to fix it, as check does:

$ bunx bivium why tests/e2e.test.ts --format chain
tests/e2e.test.ts runs on every change
  because scripts/launcher.js:10  unresolved "./cli-entry.js"
    fix: no file at that path: fix the import, or add an `edges` entry if a build generates it
  reached via scripts/launcher.js
    depended on by  scripts/build.ts:1
    depended on by  tests/e2e.test.ts:2  ← selected

Dead globs

A typo'd @depends-on, or one pointing only at files the planner skips, looks fine and never selects its file. check evaluates every @depends-on, @reads, @lists and import.meta.glob pattern, every edges and reads value, and every suite's setup, against every file git lists (tracked and untracked, inside the roots or not) and reports one that:

ProblemMeans
matches nothingNo file matches: a typo or a stale path
matches only ignored files, partly ignoredSome matches are ignored and not in the graph, so a change to one selects nothing

A config value with captures is dead only when it matches nothing for every file its key matches, and a key that matches no file the graph reads is reported too, under the config's file. An ignored file the graph reads still seeds a walk, and a change outside the roots runs everything, so neither is dead. A glob that's dead on purpose goes in deadGlobs with a reason:

deadGlobs: [
  { file: "tests/css/tokens.test.ts", glob: "docs/src/**", reason: "docs-only changes skip CI" },
],

CI

Plan in a job of its own, before any install, and feed each suite's job its selection:

jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      unit: ${{ steps.plan.outputs.unit-tests }}
      e2e: ${{ steps.plan.outputs.e2e-tests }}
    steps:
      - uses: actions/checkout@v7
        with:
          # History for the merge base, without old file contents: git
          # fetches the few a plan reads (a changed barrel or lockfile).
          fetch-depth: 0
          filter: blob:none
      - uses: oven-sh/setup-bun@v2
      - id: merge-base
        run: echo "sha=$(git merge-base origin/main HEAD)" >> "$GITHUB_OUTPUT"
      - uses: actions/cache@v6
        with:
          path: .bivium/cache
          key: bivium-${{ steps.merge-base.outputs.sha }}
          restore-keys: bivium-
      - id: plan
        # --bun: run on Bun, which loads a TypeScript config whatever Node
        # the runner has.
        run: bunx --bun bivium plan origin/main --github
  unit:
    needs: plan
    if: needs.plan.outputs.unit != 'none'
    steps:
      # …install, then run all tests or the selected files:
      - run: bunx vitest run ${{ needs.plan.outputs.unit != 'all' && needs.plan.outputs.unit || '' }}

The cache holds what bivium extracted from each file, keyed by git blob, so a warm plan reads only the files a branch changed. Key it on the merge base: a pull request can restore caches from the default branch, whose run at that commit is the one to hit. Without node_modules, the cache lives in .bivium/cache; it writes a .gitignore so it never shows up as a change.

What a plan costs, measured on an Apple M-series laptop under load (median of five, wall time):

RepositoryFilesBun, no cacheBun, warmNode 24, no cacheNode 24, warmCache
carbon-components-svelte4,6800.15 s0.09 s0.27 s0.13 s452 KB
generated TypeScript, 23 MB7,0000.14 s0.08 s0.30 s0.12 s896 KB

So the cache is optional at this size, bunx --bun is worth it, and planning inside a test job (no extra job, no install) costs well under a second. A cold plan of the generated repository peaks at 88 MB of RSS on Bun and 124 MB on Node.

Narrowers

Some changes reach every test through a generated file: every page loads the compiled stylesheet, so any style edit selects every e2e test. A narrower replaces such a change's coarse seeds with finer ones from a semantic diff, such as the sources that render a changed CSS rule.

narrowers: [
  {
    name: "css",
    match: "styles/**", // the changed files it narrows
    opaque: "dist/*.css", // the generated nodes its seeds stand in for
    suites: ["e2e"],
    async narrow({ base, head, changed, graph }) {
      const rules = diffRules(await compile(base.commit), await compile(head.commit));
      if (!rules) return { all: "a rule that requires no class changed" };
      return { seeds: sourcesSpelling(rules), note: `${rules.length} rules changed` };
    },
  },
],

The matched files still seed the walk, but it stops at the opaque nodes, and the seeds stand in for them. A narrower can only narrow: its suites' selection is cut to what the walk without it selects. One that returns { all: reason }, throws, or returns anything else runs its suites in full with the reason. narrow() gets the repository root, the merge base and the working tree as base and head (each with commit and read(path)), the matched changed files, and a read-only graph (nodes(), importers(node), dependents(nodes), tests(suite)). eval/carbon/css-narrower.ts is carbon-components-svelte's, over crassus.

From code

import { defineConfig, plan } from "bivium";

const config = defineConfig({ suites: { unit: { tests: "tests/**/*.test.ts" } } });
const result = await plan({ config, base: "origin/main" });
result.suites.unit; // { mode: "some", files: [...] }; result.suites.e2e is a type error

plan(options?) takes cwd, base, config (default: the config file in cwd) and cache, and returns each suite's selection, its test count, the reasons as sentences and as data, the packages a lockfile change seeded or found inert, the base, merge base and changed files, and what each narrower returned. Given a config, the suite names carry through to the result's type. The package exports only plan, defineConfig and their types.

Compared with

SelectsMisses
vitest --changed, jest --changedSinceTests whose module graph reaches a changed file, from the runner's own resolverDisk reads, fixtures, pages a test visits, config and setup wiring
Playwright --only-changedTest files whose imports changedThe pages a test opens and what they load
Nx, TurborepoWhole projects whose inputs changedNothing within a project's declared inputs, at project granularity
BazelTargets whose declared inputs changedNothing, if every input is declared
biviumTest files, across runners and file types, from one graphNothing it can see; what it can't, it runs

版本与发布记录

bivium 共发布 8 个版本,首次发布于 2026/10/26;过去 12 个月发布了 8 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.3.0latest2026/11/03今天
0.2.12026/10/286 天前
0.2.02026/10/286 天前
0.1.22026/10/276 天前
0.1.12026/10/277 天前
0.1.02026/10/267 天前

关于 bivium 的常见问题

bivium 是什么?

只运行变更可能影响的测试,不要错过任何一个。

如何安装 bivium?

在项目目录运行 npm install bivium,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 bivium,可以用 npx bivium 直接运行。

bivium 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

bivium 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

bivium 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

bivium 可以商用吗?

bivium 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

bivium 还在维护吗?

最新版本 0.3.0 发布于 2026/11/03(今天);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/10/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。