@effected/lockfiles

中文文档
0.25.0发布于 2026/11/13MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 9221

Pure lockfile parsing for bun, npm, pnpm and yarn Berry into one unified Effect schema model, with pure integrity checking against workspace manifests.

原始使用文档
文档目录10 节
  1. Why @effected/lockfiles
  2. Install
  3. Quick start
  4. Formats
  5. Supported lockfile versions
  6. Errors
  7. Instances and resolution
  8. Comparing two lockfiles
  9. Features
  10. License

@effected/lockfiles

npm License: MIT Node.js %3E%3D24.11.0 TypeScript 7.0

Lockfile parsing for Effect v4: bun (bun.lock), npm (package-lock.json), pnpm (pnpm-lock.yaml) and yarn Berry (yarn.lock) all normalized into one Lockfile schema model, plus pure integrity checking of that model against workspace manifests and a pure before/after comparison of two lockfiles. Four formats, one model, no IO and no external runtime dependencies.

Pre-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 (effect ~4.1.0) and still in 0.x development. Stable Effect makes a kit 1.0.0 possible, not automatic. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version — even a package marked stable before 1.0.0 can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.

Why @effected/lockfiles

Every package manager writes its lockfile in a different dialect — JSONC for bun, JSON for npm, YAML for pnpm and yarn — and each encodes packages, workspace edges and integrity data differently. Tooling that wants to answer "which version of typescript is resolved here" ends up with four code paths and four sets of bugs. This package normalizes all four into one model, so the question is asked once regardless of which package manager produced the file.

Every entrypoint takes content as a string. The package performs no IO at all: reading files, finding workspace roots and detecting which package manager a repo uses belong to its consumers, and keeping them out means the parser is a pure function you can drive from a fixture, a network response or a git blob. Malformed input always exits through a typed error channel, never as a defect, and the two ways a lockfile can be unusable are distinct tags rather than one blurry reason string. Yarn support is Berry only, and that is enforced: classic v1 content fails typed instead of being mis-normalized into something that looks plausible.

Install

npm install @effected/lockfiles @effected/jsonc @effected/npm @effected/semver @effected/yaml effect
pnpm add @effected/lockfiles @effected/jsonc @effected/npm @effected/semver @effected/yaml effect

Requires Node.js >=24.11.0. effect v4, @effected/jsonc, @effected/npm, @effected/semver and @effected/yaml are peer dependencies — the JSONC and YAML engines, the shared dependency-specifier vocabulary and the SemVer range checker all arrive through those siblings, so nothing outside effect and @effected/* reaches your tree. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not.

All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.

Quick start

import { Lockfile, LockfileIntegrity, WorkspaceManifest } from "@effected/lockfiles";
import { Effect } from "effect";

declare const content: string; // lockfile text, read by the caller

const program = Effect.gen(function* () {
  // The only fallible boundary in the package.
  const lockfile = yield* Lockfile.parse(content, { format: "pnpm" });

  // pnpm workspace packages come back keyed by importer path; rewrite them
  // once you have read the manifests. Total and pure — no error channel.
  const named = lockfile.withImporterNames(new Map([["packages/core", "@acme/core"]]));

  // Total lookups over the model.
  const versions = named.packagesNamed("typescript").map((p) => p.version);

  // Pure integrity checking — no Effect, no error channel, no IO.
  const report = LockfileIntegrity.compare(named, [
    WorkspaceManifest.make({ name: "@acme/core", dependencies: { lodash: "^4.17.0" } }),
  ]);

  return { versions, workspaces: named.workspacePackages.length, valid: report.valid };
});

Effect.runPromise(program).then(console.log);
// { versions: [...resolved typescript versions], workspaces: <count>, valid: true | false }

Formats

format is a literal, and filenameFor / fromFilename map between the literal and the file on disk so a consumer that detected a package manager never has to hard-code a filename.

FormatFilenameDialectNotes
"bun"bun.lockJSONCTrusted dependencies preserved on BunExtension
"npm"package-lock.jsonJSONlockfileVersion 3+ — v2 and older fail typed
"pnpm"pnpm-lock.yamlYAMLlockfileVersion 9+ — older fails typed. Catalogs preserved on PnpmExtension; see below
"yarn"yarn.lockYAMLBerry only — classic v1 fails typed

Supported lockfile versions

The gate is on the lockfile format version, which is the only version a lockfile records:

FormatMinimumOlder input
pnpmlockfileVersion 9LockfileParseError, stage: "validation"
npmlockfileVersion 3LockfileParseError, stage: "validation"
bun, yarnnot gated—

The cause is a structured { _tag: "UnsupportedLockfileVersion", format, lockfileVersion, minimumSupported, message }, so a consumer can distinguish "your lockfile is too old" from "your lockfile is malformed" without parsing prose. isUnsupportedLockfileVersion is the exported predicate that narrows to it — discriminate on the tag, never on message, which is a summary rather than contract:

import { isUnsupportedLockfileVersion, Lockfile } from "@effected/lockfiles";
import { Effect } from "effect";

declare const content: string;

const program = Lockfile.parse(content, { format: "pnpm" }).pipe(
  Effect.catchTag("LockfileParseError", (error) =>
    isUnsupportedLockfileVersion(error.cause)
      ? Effect.fail(`lockfileVersion ${error.cause.lockfileVersion}; need ${error.cause.minimumSupported}+`)
      : Effect.fail("malformed lockfile"),
  ),
);
// a pre-v9 pnpm lockfile takes the first branch, a truncated one the second

cause stays an open channel deliberately: it carries whatever the delegated parsing engines throw, so declaring it as a closed union would present an open channel as an exhaustive one. The predicate is what makes narrowing it honest.

Older formats are rejected rather than parsed because they cannot answer the questions the model now asks: pre-v9 pnpm has no snapshots: section, and npm v1/v2 trees record no per-instance nesting, so both would decode into rows with no resolution data.

Tested against pnpm 11.22.0, npm 11.19.0, bun 1.3.14 and yarn 4.9.1 — a separate claim from the gate, and deliberately so. A lockfile does not record which package manager wrote it, and the mapping is many-to-one (pnpm 9, 10 and 11 all write format 9.0), so no manager-version requirement could be enforced here even in principle.

Errors

Lockfile.parse is the only fallible entrypoint, and it fails two ways.

TagMeansFields
LockfileParseErrorThe content is not a valid lockfile of that format.format, stage ("syntax" when the text itself did not parse, "validation" when it parsed but did not have the expected shape), cause (structural, never stringified)
LockfileFramingErrorThe text parsed, but no single lockfile document could be located in it.format, documents, reason ("noLockfileDocument", "noImporters", "unexpectedDocuments")

The framing error exists because pnpm-lock.yaml is a YAML stream, not a YAML document. pnpm 11 writes a config-dependencies preamble ahead of the lockfile whenever a workspace uses configDependencies, so the file carries two documents — and the preamble declares lockfileVersion, importers and packages too, so a parser that reads only the first document gets a lockfile that validates and describes an empty workspace. That is a wrong answer shaped exactly like a right one. The rule here is positional and deterministic rather than a heuristic: pnpm composes the preamble as a prefix, so the lockfile is the last document. A stream carrying no lockfile document fails typed. It never degrades into an empty Lockfile.

yarn defines no document framing at all, so multi-document yarn.lock content fails with "unexpectedDocuments" rather than being silently truncated to its first document. Where a format states no rule, this package refuses to guess.

Instances and resolution

A row in lockfile.packages is one package instance, not one package. A dependency installed twice under different peers is two rows, and instanceId is that row's lockfile-native identity, carried verbatim. Treat it as opaque: look an id up in the model, never split or pattern-match one, because its spelling is the format's business and differs per manager.

resolved maps each dependency name — and each peer name the lockfile records a resolution for — to the instanceId it actually resolved to, which is what lets a consumer walk the real graph rather than re-resolving ranges itself. peerDependencies and peerDependenciesMeta carry the declarations as written, defaulting to {}.

import { Option } from "effect";

const [instance] = lockfile.packagesNamed("react-dom");
const reactId = instance?.resolved["react"];
const react = reactId === undefined ? Option.none() : lockfile.packageByInstanceId(reactId);
// Option.some(ResolvedPackage) — the version this particular react-dom instance resolved to
// Option.none() when the lockfile records no row under that id

packageByInstanceId is the lookup to reach for rather than a scan over lockfile.packages: it builds its index lazily on first call and reuses it, so walking a graph edge by edge stays linear instead of quadratic. A repeated id resolves to the first row, so a malformed lockfile gives a stable answer rather than an iteration-order one.

unresolvedEdges names the edges the lockfile records but the model could not name. A name listed there is not absent — it is unanswered, so a missing resolved key must not be read as "nothing is there". A gate that cares about completeness checks that array before trusting an empty result.

Comparing two lockfiles

LockfileDiff.compare(before, after) reports what moved between two parsed lockfiles — what an update tool reports after an install. It is total and pure, like LockfileIntegrity.compare: no Effect, no error channel, no IO.

import { Lockfile, LockfileDiff } from "@effected/lockfiles";
import { Effect } from "effect";

declare const beforeText: string; // the lockfile before `pnpm update`
declare const afterText: string; // and after

const program = Effect.gen(function* () {
  const before = yield* Lockfile.parse(beforeText, { format: "pnpm" });
  const after = yield* Lockfile.parse(afterText, { format: "pnpm" });
  const diff = LockfileDiff.compare(before, after);

  for (const change of diff.catalogs) {
    // A side that does not exist, or a version that does not resolve, is absent.
    const from = change.version.before ?? "-";
    const to = change.version.after ?? "-";
    console.log(`${change.catalog}/${change.name} ${change.kind}: ${from} -> ${to}`);
  }
  return diff.importers.filter((change) => change.specifier.after?._tag !== "catalog");
});
// With a default catalog entry `lodash: ^4.17.21` resolved at 4.17.23 before
// and 4.17.24 after, and a new `zod` entry, this prints:
// default/lodash changed: 4.17.23 -> 4.17.24
// default/zod added: - -> 3.25.76
  • catalogs holds one CatalogChange per catalog entry added, removed or changed: catalog ("default" for the default catalog in every format), name, specifier and version as { before, after }, kind, and consumers — the importers that reference the entry through catalog:, read from the before lockfile for a removed entry.
  • importers holds one ImporterChange per importer dependency added, removed or changed, identified by importer path, name and depType together. specifier sides are classified specifiers, as on ImporterDependency, so _tag tells a catalog: reference apart.
  • A side that does not exist is absent, and so is a version that cannot be resolved. A record is changed when its specifier differs, or when its version resolves on both sides and differs; a version resolvable on one side only is not a change by itself.
  • hasChanges is false exactly when both arrays are empty.

Where a resolved version lives differs by format, and the comparison normalizes it so the answer is the same for every format:

FormatCatalog entry versionImporter dependency version
pnpmrecorded on the entryrecorded on the importer dependency (a workspace link reads link:<path>)
bunthe one version the entry's consumers resolved to (consumers that disagree give no version); with no consumer, the package's version when the lockfile holds exactly oneread through the importer's resolved edge to its package entry
npm— (npm has no catalogs)read through the importer's resolved edge to its package entry
yarn—— (yarn records no importers, so two yarn lockfiles compare empty)

A catalog bump shows in catalogs, and also in importers for each consumer whose resolved version moved; filter on specifier.after?._tag !== "catalog" to keep the two apart, as above.

Features

  • Lockfile.parse(content, { format }) — the package's only fallible boundary; fails with LockfileParseError or LockfileFramingError.
  • Lockfile#withImporterNames(names) — the pure second stage for pnpm: rewrites importer-path names and both ends of each dependency edge once the consumer has read the manifests.
  • Lockfile#packagesNamed(name), Lockfile#packageByInstanceId(id) (Option-answering, over a lazily built index) and Lockfile#workspacePackages — total lookups over the model.
  • LockfileFormat with filenameFor / fromFilename — the format literal and its mapping to lockfile filenames.
  • LockfileIntegrity.compare(lockfile, manifests) — total, pure integrity checking with no error channel; the report carries valid, missingWorkspaces, extraWorkspaces and unsatisfiedConstraints. Constraint checking is best-effort by design: workspace: / link: / file: specifiers and rows whose range does not parse as SemVer are skipped.
  • LockfileDiff.compare(before, after) — total, pure before/after comparison: catalog entries (CatalogChange) and importer dependencies (ImporterChange) added, removed or changed, with resolved versions normalized across formats. See Comparing two lockfiles.
  • WorkspaceManifest — the manifest input shape for integrity checking: a package name plus four optional dependency records. Deliberately a plain value rather than a strict manifest model, so consumers can derive it from anything.
  • ResolvedPackage — one package instance: name, version, optional integrity hash, workspace flag and relative path, dependency map, the peer declarations (peerDependencies, peerDependenciesMeta), the opaque instanceId, the resolved name-to-instance map and the unresolvedEdges the model could not name.
  • isUnsupportedLockfileVersion and the UnsupportedLockfileVersion type — the predicate that narrows a LockfileParseError.cause to a version-gate rejection, so "too old" and "malformed" are told apart on a tag rather than on prose.
  • WorkspaceDependency — a from/to edge between workspace packages, with depType spelled in @effected/npm's DependencyField vocabulary and its declared constraint.
  • PnpmExtension and BunExtension — format-specific data such as pnpm catalogs and bun trusted dependencies, preserved on the model's optional extension field.

License

MIT

版本与发布记录

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

版本发布日期距今
0.25.0latest2026/11/13今天
0.24.12026/11/112 天前
0.24.02026/11/102 天前
0.23.22026/11/103 天前
0.23.12026/11/094 天前
0.23.02026/11/094 天前

其他发布通道:next → 0.12.0-next-20260927071821。

关于 @effected/lockfiles 的常见问题

@effected/lockfiles 是什么?

Pure lockfile parsing for bun, npm, pnpm and yarn Berry into one unified Effect schema model, with pure integrity checking against workspace manifests.

如何安装 @effected/lockfiles?

在项目目录运行 npm install @effected/lockfiles,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)、@effected/npm(^0.34.1)、@effected/glob(^0.22.0)、@effected/yaml(^0.30.0)、@effected/jsonc(^0.25.0)、@effected/semver(^0.24.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/lockfiles 支持 TypeScript 吗?

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

@effected/lockfiles 支持 ESM 还是 CommonJS?

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

@effected/lockfiles 需要什么版本的 Node.js?

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

@effected/lockfiles 可以商用吗?

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

@effected/lockfiles 的下载量有多少?

根据 npm 官方下载统计,@effected/lockfiles 在 2026/10/13 – 2026/11/11 这 30 天内被下载 9,221 次,日均约 307 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/lockfiles 还在维护吗?

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