border-customs 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -222,9 +222,10 @@ staged artifact, `border check` can no longer PASS that channel on signatures al
222
222
  artifact's sha256 must also carry a **fresh empirical roundtrip verdict** in the ledger.
223
223
 
224
224
  The check gate NEVER runs Docker itself (there is nothing to trust it to run); the proof is
225
- pre-supplied out-of-band by `border roundtrip <spec>`, which installs the real bytes, diffs the
225
+ pre-supplied out-of-band by `border roundtrip <spec-or-local-file>`, which installs the real
226
+ bytes — fetched from a registry or read from a local artifact (W2.4(b)) — diffs the
226
227
  filesystem, and appends a `t:"roundtrip"` ledger record — `{artifactSha256, verdict:
227
- clean|residue, rulesHash, ts, rows}` — keyed by the exact bytes it fetched. A `residue` verdict
228
+ clean|residue, rulesHash, ts, rows}` — keyed by the exact bytes it proved. A `residue` verdict
228
229
  still counts as a proof-of-fact: the valve demands *evidence someone looked*, and the finding
229
230
  itself keeps blocking until it stops tripping the scan.
230
231
 
@@ -236,17 +237,76 @@ itself keeps blocking until it stops tripping the scan.
236
237
  - Both new rules are ordinary findings: the allow-list can waive them and every waiver is
237
238
  enumerated in `allowHits` — no hidden channel.
238
239
 
240
+ ### Release coherence (0.4.1)
241
+
242
+ Founding case — **aihr**: the wheel published on PyPI under version `0.2.2` carried
243
+ `__init__.__version__ = "0.2.1"` while `pyproject.toml` declared `0.2.2`. The artifact's name,
244
+ its metadata and its actual module behaviour disagreed, and every consumer who pinned `==0.2.2`
245
+ silently installed the older behaviour. The same drift class bit this repo twice: the published
246
+ npm tarball's User-Agent string said `0.3.0` while `package.json` said `0.3.1`, and
247
+ `package-lock.json`'s root version sat at `0.1.0` through two releases. Version truth must AGREE
248
+ across every source **inside the artifact** before anything ships — so the publish-stage artifact
249
+ scanners cross-check those sources statically. No second source of truth outside the artifact is
250
+ consulted: no registry, no git tag.
251
+
252
+ | Rule | Severity | Fires when |
253
+ | --- | --- | --- |
254
+ | `release-coherence-version-drift` | CRITICAL | two version sources packed in one artifact disagree: `package.json` vs a force-packed `package-lock.json` root or `packages[""]` (npm); wheel filename vs `*-*.dist-info` directory vs `METADATA Version:` vs a literal `__version__` in any `__init__.py`; sdist `pyproject.toml [project].version` vs `PKG-INFO` / `setup.cfg` / `setup.py` string literals vs `__init__`; `Cargo.toml [package].version` vs the `Cargo.lock` entry when the lock is packed; `.gemspec` first-literal vs the built gem's `metadata.gz` name/version; `dist` filename vs the packed root `PKG-INFO` (sdist) |
255
+ | `release-coherence-unverifiable-source` | MEDIUM | a second version source is present but not statically parseable — a computed/dynamic `__version__`, a `setup.cfg` `attr:`/interpolation, a `dynamic = ["version"]` pyproject, a malformed lockfile, a missing `Cargo.lock` entry, an unreadable gemspec block. Fail-closed beats silent-clean: the gate refuses to call uncheckable "clean" |
256
+ | `release-coherence-twin-drift` | CRITICAL | opt-in `release.twin` pairs (see Configuration): a configured PyPI↔npm twin pair publishes different versions. This is the one cross-manager equality that is statically enforceable — both artifacts are already in `.border/dist/` in the same run |
257
+
258
+ The absent-source boundary is load-bearing: a missing `package-lock.json`/`Cargo.lock`/`PKG-INFO`
259
+ means nothing was packed — **not** that anything drifted — and no row is emitted. Publishing
260
+ gatekeepers that cry wolf train users to pass `--force`; these rules fire only on evidence inside
261
+ the shipped bytes. Like the residue rule table, this family's source folds into `rulesHash`, so
262
+ editing a matcher invalidates every cached PASS.
263
+
239
264
  ### Running `border roundtrip`
240
265
 
241
- `border roundtrip <[ecosystem:]name@version>` fetches the real registry bytes, installs them in a
242
- throwaway Docker container (per-ecosystem image, whole-filesystem content-hash manifest before
243
- install and after the manager's own uninstall), prints the residue manifest, and records the
244
- verdict. Known fidelity envelope, from the W2.0 spike and the W2.3 registrar-chain demo:
266
+ `border roundtrip <[ecosystem:]name@version | local-artifact-file>` fetches the real registry
267
+ bytes — or, since W2.4(b), accepts a LOCAL artifact file (`border roundtrip
268
+ dist/foo-1.0.0-py3-none-any.whl`) — installs them in a throwaway Docker container
269
+ (per-ecosystem image, whole-filesystem content-hash manifest before install and after the
270
+ manager's own uninstall), prints the residue manifest, and records the verdict.
271
+
272
+ The local lane exists so unpublished/hardened artifacts can be proven **pre-publish**. Its rules
273
+ are deliberately narrow: the ecosystem comes from the extension alone, a closed table
274
+ (`.whl`→pypi, `.tgz`→npm, `.crate`→crates, `.gem`→rubygems); anything else exits 2, because
275
+ content-sniffing would grade an artifact through a guessed manager's install/uninstall contract.
276
+ Local mode touches no network: the file passes one streaming sha256 pass (the 200 MiB cap fails
277
+ closed mid-stream), the exact bytes are staged and `docker cp`'d, and every later leg — closure
278
+ resolution, m1/m2/m3, residue rows, ledger proof — runs the identical registry pipeline.
279
+ Provenance stays honest: human and `--json` output stamp `source: "local:<abs-path>"` plus the
280
+ `artifactSha256` — the digest is the identity, and the requireProof valve consumes a local proof
281
+ by the same digest it uses for a fetched one. A directory argument, or a path-shaped argument
282
+ that does not resolve to an existing file, exits 2 before anything is fetched.
283
+
284
+ Wheel-lane boundary, stated honestly: the LOCAL PyPI-wheel lane is residue-INERT by
285
+ construction — pip fully records what it installs and prunes its own directories, so even a
286
+ wheel planted with hostile `.data/data/...` entries leaves a zero survivor set after uninstall
287
+ (verified live on pip 25.0.1 AND 24.0, 2026-09-11, verifier harness); a PASS on such a plant is
288
+ pip's contract working, not a missed detection. The local wheel roundtrip still proves
289
+ installability, benign-clean, and exact-bytes identity — but planted-detection demos belong to
290
+ the lanes whose managers execute install hooks: npm (`.tgz` postinstall EXECUTES under
291
+ `npm install -g --foreground-scripts`), gem (extconf custom writes) and crates (build.rs).
292
+
293
+ Known fidelity envelope, from the W2.0 spike and the W2.3 registrar-chain demo:
245
294
 
246
295
  - Docker is required; absence or any step failure ⇒ exit 2, never a silent `clean`.
247
- - npm/gem/crates lanes diff exactly; the pypi lane reports pip's left-behind **transitive
248
- dependencies** as orphan rows (calibration to a target-only diff is a known follow-up —
249
- the over-report direction is deliberately conservative).
296
+ - A local run proves its own bytes and says so: `source`/`artifactSha256` in the report and the
297
+ `t:"roundtrip"` ledger record all key on the streamed digest of the file given — never phrased
298
+ as registry-verified.
299
+ - npm/gem/crates lanes diff exactly; the pypi lane calibrates pip's left-behind **transitive
300
+ dependencies** (W2.4): before install, `pip install --dry-run --report` resolves the closure in a
301
+ throwaway resolver container, and post-uninstall rows still claimed by a still-installed closure
302
+ dist demote to LOW `residue-roundtrip-dep-owned` — non-blocking, owner named, still printed.
303
+ A claim only counts from a legitimate claimant: its dist-info must match the resolver's
304
+ name-version pin exactly, be canonically named and the unique dist-info for that name, and may
305
+ only claim paths inside its own site-packages root — a shadow or forged `X-Y.dist-info` planted
306
+ by install code launders nothing. What the demotion cannot see stays blocking, deliberately:
307
+ dependency writes outside their own RECORD, paths ambiguously claimed by two dists, every MODIFIED
308
+ row (the W2.3 founding run's genuine `/usr/local/share/aihr` orphan is exactly this shape), and if the
309
+ closure itself cannot be resolved the run exits 2 — cannot-verify is never clean.
250
310
  - Only manager-lifecycle surfaces are observed: persistence performed by explicitly-invoked
251
311
  bins (not npm hooks) is outside what the roundtrip watches — W2.3 measured exactly this
252
312
  blind spot on our own registrar and documented its `{"plugin": []}` config residue.
@@ -510,6 +570,8 @@ engines:
510
570
  require: [gitleaks, secretlint] # trufflehog: true adds the third-party engine
511
571
  residue:
512
572
  enabled: true # default; false hides ONLY the residue-* rows
573
+ release:
574
+ twin: [] # opt-in [{pypi, npm}] pairs — see Release coherence
513
575
  ```
514
576
 
515
577
  Notes that change behavior:
@@ -613,6 +675,39 @@ same one that wrote the commit) how to run the five subcommands, how to produce
613
675
 
614
676
  ## Changelog
615
677
 
678
+ ### 0.4.2 (2026-09-12)
679
+ - Fix: `npm publish` no longer prints the misleading "bin[border] script name
680
+ dist/index.js was invalid and removed" warning — package.json now carries the
681
+ canonical bare-relative bin form npm normalizes to anyway (mechanism traced to
682
+ @npmcli/package-json publish-side; artifacts were never broken; an always-on
683
+ test locks the form).
684
+ - Change: RubyGems freshness (skip-ledger) parity compares a normalized CONTENT
685
+ digest instead of raw file bytes — `gem build` stamps the building toolchain
686
+ (RubyGems <3.6 embeds the build second in gzip headers), so raw-sha repack
687
+ parity could never be honored on those machines. The ledger digest and the
688
+ publish-time same-bytes re-hash stay RAW: what gets uploaded is still exactly
689
+ the certified bytes, and any unparseable artifact fails closed to a full
690
+ re-check.
691
+
692
+ ### 0.4.1 (2026-09-11)
693
+ - Add: release-coherence rule family — every version source inside a published artifact
694
+ must agree (pyproject vs MODULE vs filename, gemspec vs gem metadata, Cargo.toml vs
695
+ crate filename, package.json self-consistency; the git-tag half is a publish-workflow
696
+ hard gate). Trigger: the aihr incident where a wheel labeled 0.2.2 shipped modules
697
+ self-reporting 0.2.1 — every pinned consumer silently got old behavior.
698
+ - Add: `border roundtrip` local-artifact input — an argument resolving to an existing
699
+ file (.whl/.tgz/.crate/.gem, closed table) proves unpublished bytes end-to-end; the
700
+ streaming sha256 of those exact bytes is the ledger identity and the report carries
701
+ `source:local:<abs>` provenance. Honest boundary documented: the local PyPI-wheel lane
702
+ is residue-INERT by construction (pip fully tracks and prunes; verified live on pip
703
+ 24.0 AND 25.0.1) — planted-detection demos belong to the hook-executing lanes
704
+ (npm postinstall, gem extconf, crates build.rs).
705
+ - Change: pypi roundtrip lane dep-closure calibration — pip's resolver report demotes
706
+ dependency-storm files to LOW `dep-owned` rows, so genuine orphans stand alone; the
707
+ demotion requires a legitimate claimant dist-info (pin Name+Version match, canonical
708
+ dirname, unique, own site-packages root, not the target) after an independent verifier
709
+ proved a shadow `*-9.9.9.dist-info` could otherwise launder hostile writes.
710
+
616
711
  ### 0.4.0 (2026-09-10)
617
712
  - Add: `border roundtrip <[ecosystem:]name@version>` — fact-proof valve. Installs the
618
713
  package inside a throwaway Docker container, content-hashes the whole filesystem before
@@ -683,11 +778,30 @@ MIT, see [LICENSE](LICENSE).
683
778
  border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install -g border-customs` 安装,在仓库里跑 `border check`。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI/crates/RubyGems 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 PASS 记录时才允许 `border push --yes` 放行。指纹是 sha256(head、porcelain 摘要、规则哈希、暴露面、ref 集合、有效目标)六元组,任何一处变动,旧的 PASS 立即失效,必须重查。流水线:gitleaks(历史+工作区+tag,内置 8.30.1 规则,仓库自带的 ignore 文件直接判 CRITICAL)+ secretlint(进程内,AWS Key 规则强制开启)+ 原生规则(AI 会话产物闭集、提交身份白名单含传输对象检查)+ 注册表预检(版本已存在=必须 bump,名称被外人占有=拒绝;空响应/超时/解析失败一律 exit 2,沉默绝不等于不存在)。构件只构建一次进 `.border/dist/`,扫描的就是发布的字节,发布时再哈希比对,不一致直接拒发。跳过台账让重复检查不到 1 秒,但回放前先重算指纹并重新 pack 验证新鲜度。报告只输出掩码片段(sha256 摘要 + 前4…后4)。push 是多目标状态机,多 remote 先做全有或全无的 fast-forward 预检,永不 force-push;npm/twine/cargo/gem 的凭据经 stdio 透传,border 从不触碰。0.2.0 新增 crates.io 与 RubyGems 通道(公开 crates.io 固定、rubygems 可用 host 覆盖私有镜像),既有配置行为不变。0.3.0 新增残留扫描(residue gate):发布字节里的安装期钩子按 T0-T4 闭集签名表分类(闭集 T1 树内钩子降为 MEDIUM,未知形态原样保留 CRITICAL),外加跨包管理器写入、配对标记缺失(`# BEGIN` 块只检测不豁免)与 gem 不可解析扩展共七条 `residue-*` 规则;`residue.enabled` 是唯一豁免开关且改动指纹使旧 PASS 失效,规则表与分类器源码进入 rulesHash,改一个签名即强制重查。静态分析只证明"能力"不证明"事实",border 不是恶意软件沙箱,从不执行被读代码。0.4.0 新增证明阀
684
779
  `residue.requireProof`(默认关,strict):开启后,发布构件若触发阻断级 `residue-*` 发现,通道 PASS
685
780
  还要求账本里存在按其 sha256 索引且 rulesHash 新鲜的 `border roundtrip` 实测记录——check 自身从不
686
- 运行 Docker,证据由 `border roundtrip` 离线写入(默认记账,`--no-record` 关闭),clean 与 residue
781
+ 运行 Docker,证据由 `border roundtrip`(注册表 spec 或本地构件文件,W2.4b)离线写入(默认记账,`--no-record` 关闭),clean 与 residue
687
782
  两种裁决都算"事实已在";缺记录判 `roundtrip-proof-missing`、rulesHash 过期判
688
783
  `roundtrip-proof-stale`(均 CRITICAL/native,与普通发现同受白名单管辖并在 allowHits 枚举),翻转
689
784
  该配置即轮换 rulesHash,所有缓存 PASS 自动失效。`border roundtrip` 本身保真边界:Docker 必需、
690
- 缺失即 exit 2 绝不假装干净;pypi 通道会把 pip 遗留的传递依赖如实报成孤儿行(收窄到目标包自身是
691
- 已知后续项);只有包管理器生命周期触发的写入被观测,显式调用的 bin 自写配置不在射程——W2.3 在
785
+ 缺失即 exit 2 绝不假装干净;本地构件输入(W2.4b)——参数解析为已存在的文件即走本地模式,生态仅由扩展名闭表判定
786
+ (.whl→pypi、.tgz→npm、.crate→crates、.gem→rubygems),表外一律 exit 2:内容嗅探等于猜测某个管理器的装卸契约,
787
+ 按猜测定级不予采纳;本地模式全程不触网,文件流式过一遍 sha256(200 MiB 上限在流内超限即闭),同一批字节暂存后
788
+ docker cp,闭包解析、m1/m2/m3、残留行与账本证明走的都是与注册表模式完全相同的流水线;出处保持诚实:人机与
789
+ --json 输出都盖上 `source: "local:<绝对路径>"` 与 artifactSha256——摘要即身份,requireProof 阀按同一摘要原样
790
+ 消费本地证明,本地证明绝不表述为注册表已验证;目录参数、或形如路径却并不存在的参数,在任何拉取发生之前即 exit 2;
791
+ wheel 通道诚实边界:本地 PyPI-wheel 通道天然残留惰性(residue-INERT)——pip 完整登记所装文件并清理自建目录,
792
+ 即便往 wheel 里种入恶意 `.data/data/...` 条目,卸载后幸存集也为空(2026-09-11 在 pip 25.0.1 与 24.0 上实机验证,
793
+ 验证器工装);对这种种植判 PASS 是 pip 契约在起作用,不是漏检。本地 wheel 往返仍证明可安装性、benign-clean
794
+ 与字节级同一性,但种植检测演示属于会执行安装钩子的通道:npm(.tgz 的 postinstall 在 `npm install -g
795
+ --foreground-scripts` 下真实执行)、gem(extconf 自定义写入)与 crates(build.rs);
796
+ pypi 通道已做目标收窄校准(W2.4)——安装前先在一次性解析容器里用
797
+ `pip install --dry-run --report` 解析依赖闭包,卸载后仍被闭包内在册依赖认领的残留行降级为 LOW
798
+ `residue-roundtrip-dep-owned`(非阻断,注明归属依赖,仍照常打印);认领只对合法主张者生效:其 dist-info 必须与
799
+ 解析器的 name-version 钉值完全一致、目录名规范且为该名字唯一发行版,且只能认领自身 site-packages 根内的路径——
800
+ 安装代码私设的伪影 shadow dist-info 洗白不了任何残留;分类器看不见的刻意继续阻断:
801
+ 依赖写在自身 RECORD 之外的路径、被两个发行包同时认领的歧义路径、一切 MODIFIED 行(W2.3 首轮那条真实孤儿
802
+ `/usr/local/share/aihr` 正是此类),闭包解析失败则整轮 exit 2,不可验证绝不等于干净;
803
+ 只有包管理器生命周期触发的写入被观测,显式调用的 bin 自写配置不在射程——W2.3 在
692
804
  自家 registrar 链上实测到该盲区并留下了 `{"plugin": []}` 残留证据。clean 是"某一版本构件在某类
693
805
  机器上"的事实证明,不是安全担保。可选 LLM 层 border 自身从不调用模型 API:`llm-request` 导出掩码审阅包,`llm-ingest` 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
806
+
807
+ 发布一致性(0.4.1):起因是 aihr 事故——PyPI 上标为 0.2.2 的 wheel 里 `__init__.__version__` 却写着 0.2.1,构件名、元数据与实际模块行为三者不一致,所有 `==0.2.2` 的用户静默装上了旧行为;同类漂移在本仓库也出现过两次(npm tarball 的 User-Agent 停在 0.3.0 而 package.json 是 0.3.1;package-lock.json 根版本两个 release 一直躺在 0.1.0)。发布阶段扫描器因此在构件内部逐源交叉核对版本号:`release-coherence-version-drift` 为 CRITICAL(package.json 与被强制打包的 package-lock、wheel 文件名对 dist-info 目录对 METADATA `Version:` 对 `__init__` 字面量、sdist 的 pyproject 对 PKG-INFO/setup 系字面量、Cargo.toml 对已打包的 Cargo.lock、.gemspec 对 metadata.gz、dist 文件名对 sdist 内 PKG-INFO);版本源存在但无法静态解析(动态 `__version__`、`attr:`、`dynamic = ["version"]`、坏 lockfile)判 MEDIUM `release-coherence-unverifiable-source`——不可核查绝不静默算干净;第二版本源根本没被打包时不发现在内,缺源不是漂移,这条边界是承重的,误报的门禁会把用户训练成 `--force`。唯一可静态强制的跨包管理器声明是 opt-in `release.twin`(`{pypi, npm}` 严格 zod 对列表,未知键 exit 2):同一 run 的 dist 里孪生版本不等 ⇒ CRITICAL `release-coherence-twin-drift`,消息点名两个构件。规则源文件与 residue 指纹表一样并入 rulesHash,改一个匹配器即令全部缓存 PASS 失效。
@@ -0,0 +1,423 @@
1
+ // provenance: border-inspect-roadmap.md W4.1/W4.2 — the release-coherence rule family.
2
+ //
3
+ // FOUNDING CASE (aihr incident): a wheel labeled version 0.2.2 on PyPI shipped a package whose
4
+ // `__init__.__version__` said 0.2.1 (pyproject itself said 0.2.2) — users installed "0.2.2"
5
+ // bytes carrying older module behavior. A second drift class bit border directly: the published
6
+ // npm tarball's User-Agent said 0.3.0 while package.json said 0.3.1, and package-lock.json's
7
+ // root version sat at 0.1.0 through two releases. Version truth must AGREE across every source
8
+ // packed INSIDE the artifact before anything ships. This module is the closed-table analyzers;
9
+ // the ecosystem stages (artifacts/{npm,pypi,crates,rubygems}.ts) route their extracted trees
10
+ // here and the check pipeline routes release.twin pairs after the channel loop (src/check.ts).
11
+ //
12
+ // ABSENT-SOURCE BOUNDARY (load-bearing, documented here per plan W4.1): an absent SECOND
13
+ // version source is NOT drift and NEVER a finding. package-lock.json / Cargo.lock / PKG-INFO
14
+ // are usually not packed at all (npm `files` whitelists, cargo default ignores) — a rule that
15
+ // fired on "not packed" would false-green-block honest releases and train users to pass
16
+ // --force, which is exactly how gate credibility dies. Conversely, a source that IS present
17
+ // but statically unreadable (dynamic `__version__`, malformed lock) is 'cannot-verify' — it
18
+ // emits release-coherence-unverifiable-source MEDIUM and is NEVER silently clean.
19
+ //
20
+ // Doctrine (mirrors rules/residueMatchers.ts + artifacts/manifestDiff.ts):
21
+ // * regex/text extraction ONLY — no python/ruby/toml parsing, nothing is ever executed;
22
+ // * every rule id in RELEASE_COHERENCE_SEVERITIES is closed-table (no user-configurable
23
+ // patterns); severities fixed: drift CRITICAL, twin-drift CRITICAL, unverifiable MEDIUM;
24
+ // * findings flow through the native pipeline (residueFindings-style digest keying, then
25
+ // applyAllowList/report as for every other rule);
26
+ // * this file rides computeCheckRulesHash's fingerprint (RELEASE_FINGERPRINT_SOURCES in
27
+ // src/check/rulesHash.ts) so any edit here rotates rulesHash and invalidates proofs,
28
+ // exactly like the residue sources.
29
+ import { spawnSync } from "node:child_process";
30
+ import { basename, join } from "node:path";
31
+
32
+ import { type Finding, type Severity } from "../findings.ts";
33
+ import { type LedgerArtifact } from "../ledger/records.ts";
34
+ import { redact, type TextSanitizer } from "../redact.ts";
35
+
36
+ export const RELEASE_COHERENCE_DRIFT_RULE = "release-coherence-version-drift";
37
+ export const RELEASE_COHERENCE_TWIN_RULE = "release-coherence-twin-drift";
38
+ export const RELEASE_COHERENCE_UNVERIFIABLE_RULE = "release-coherence-unverifiable-source";
39
+
40
+ /** Closed severity table — the ONLY rule ids this family may emit. */
41
+ export const RELEASE_COHERENCE_SEVERITIES: Readonly<Record<string, Severity>> = {
42
+ [RELEASE_COHERENCE_DRIFT_RULE]: "CRITICAL",
43
+ [RELEASE_COHERENCE_TWIN_RULE]: "CRITICAL",
44
+ [RELEASE_COHERENCE_UNVERIFIABLE_RULE]: "MEDIUM",
45
+ };
46
+
47
+ const TARGET = "artifact";
48
+ const ENGINE = "native";
49
+ const FOUNDING = "version truth must AGREE across every source packed in the artifact (aihr: a wheel named 0.2.2 shipped __version__ 0.2.1)";
50
+
51
+ // --------------------------------------------------------------- source probes (pure text)
52
+
53
+ type Probe = { readonly status: "literal"; readonly value: string } | { readonly status: "dynamic"; readonly reason: string } | null;
54
+
55
+ /** `__version__ = "X"` literal on its own line; ANY other assignment/import shape ⇒ dynamic. */
56
+ export function probeDunderVersion(text: string): Probe {
57
+ for (const line of text.split(/\r?\n/)) {
58
+ const assign = /^\s*__version__\s*=\s*(.+?)\s*$/.exec(line);
59
+ if (assign?.[1] !== undefined) {
60
+ const lit = /^(['"])((?:(?!\1).)+)\1(?:\s*#.*)?$/.exec(assign[1]);
61
+ return lit?.[2] !== undefined ? { status: "literal", value: lit[2] } : { status: "dynamic", reason: `__version__ assigned a non-literal (${assign[1].slice(0, 60)})` };
62
+ }
63
+ if (/^\s*(?:from\s+\S+\s+)?import\b.*\b__version__\b/.test(line)) {
64
+ return { status: "dynamic", reason: "__version__ imported from another module" };
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Core metadata `Version:` header (PKG-INFO / wheel METADATA). */
71
+ export function probeHeaderVersion(text: string): Probe {
72
+ const m = /^Version:[ \t]*(.*?)[ \t]*$/m.exec(text);
73
+ if (m === null) return null;
74
+ return m[1] !== undefined && m[1] !== "" ? { status: "literal", value: m[1] } : { status: "dynamic", reason: "Version: header present but empty" };
75
+ }
76
+
77
+ /** pyproject `[project].version` literal; [project] absent ⇒ null (legacy setup.* project). */
78
+ export function probePyprojectVersion(text: string): Probe {
79
+ const lines = text.split(/\r?\n/);
80
+ let inProject = false;
81
+ const slice: string[] = [];
82
+ for (const line of lines) {
83
+ if (/^\s*\[/.test(line)) {
84
+ if (inProject) break;
85
+ inProject = /^\s*\[project\]\s*$/.test(line);
86
+ continue;
87
+ }
88
+ if (inProject) slice.push(line);
89
+ }
90
+ if (!inProject && slice.length === 0 && !text.includes("[project]")) return null;
91
+ const body = slice.join("\n");
92
+ const lit = /^\s*version\s*=\s*(['"])((?:(?!\1).)+)\1\s*(?:#.*)?$/m.exec(body);
93
+ if (lit?.[2] !== undefined) return { status: "literal", value: lit[2] };
94
+ const dyn = /dynamic\s*=\s*\[([\s\S]*?)\]/.exec(`${body}\n]`);
95
+ if (dyn?.[1] !== undefined && /["']version["']/.test(dyn[1])) return { status: "dynamic", reason: 'project.version listed in dynamic = ["version"]' };
96
+ return { status: "dynamic", reason: "[project] declares no statically-readable version" };
97
+ }
98
+
99
+ /** setup.cfg `[metadata] version =` — bare scalars literal, attr:/interp ⇒ dynamic. */
100
+ export function probeSetupCfgVersion(text: string): Probe {
101
+ // line-slice the [metadata] section (a lazy [\s\S] regex + /m $ would stop at the first
102
+ // newline and silently lose every later key — classic multiline-anchor trap)
103
+ const lines = text.split(/\r?\n/);
104
+ let inSection = false;
105
+ const slice: string[] = [];
106
+ for (const line of lines) {
107
+ if (/^\s*\[/.test(line)) {
108
+ if (inSection) break;
109
+ inSection = /^\s*\[metadata\]\s*$/.test(line);
110
+ continue;
111
+ }
112
+ if (inSection) slice.push(line);
113
+ }
114
+ const v = /^[ \t]*version[ \t]*=[ \t]*(.+?)[ \t]*$/m.exec(slice.join("\n"));
115
+ if (v?.[1] === undefined) return null;
116
+ return /^[\w.+-]+$/.test(v[1]) ? { status: "literal", value: v[1] } : { status: "dynamic", reason: `setup.cfg version = '${v[1].slice(0, 40)}' is not a bare literal` };
117
+ }
118
+
119
+ /** setup.py `version="X"` string-literal kwarg ONLY (never executed). */
120
+ export function probeSetupPyVersion(text: string): Probe {
121
+ const lit = /(?:^|[,(]\s*)version\s*=\s*(['"])((?:(?!\1).)+)\1/.exec(text);
122
+ if (lit?.[2] !== undefined) return { status: "literal", value: lit[2] };
123
+ return /(?:^|[,(]\s*)version\s*=/.test(text) ? { status: "dynamic", reason: "setup.py version= is not a string literal" } : null;
124
+ }
125
+
126
+ /** Wheel filename `name-version-py-abi-plat.whl` ⇒ version segment (legacy dash form). */
127
+ export function parseWheelFilenameVersion(fileName: string): { name: string; version: string } | null {
128
+ const base = basename(fileName).replace(/\.whl$/i, "");
129
+ const parts = base.split("-");
130
+ if (parts.length < 5) return null;
131
+ const name = parts[0] as string;
132
+ const version = parts[1] as string;
133
+ return name !== "" && version !== "" ? { name, version } : null;
134
+ }
135
+
136
+ /** `pkg-0.2.2.dist-info` directory segment ⇒ version (text after the last dash). */
137
+ export function parseDistInfoDirVersion(relPath: string): string | null {
138
+ const dir = /(?:^|\/)([^/]+\.dist-info)\//.exec(relPath)?.[1];
139
+ if (dir === undefined) return null;
140
+ const stem = dir.slice(0, -".dist-info".length);
141
+ const idx = stem.lastIndexOf("-");
142
+ return idx >= 0 ? stem.slice(idx + 1) : null;
143
+ }
144
+
145
+ /** PEP 503 name canonicalization for cross-artifact matching. */
146
+ const normName = (s: string): string => s.toLowerCase().replace(/[-_.]+/g, "_");
147
+
148
+ // --------------------------------------------------------------- hit → Finding plumbing
149
+
150
+ type Hit = { readonly rule: string; readonly message: string; readonly path: string; readonly disc: string };
151
+
152
+ function toFindings(hits: readonly Hit[], identity: string, sanitizer?: TextSanitizer): Finding[] {
153
+ return hits.map((hit) => {
154
+ // digest key mirrors residueFindings: identity:family:rule:disc keeps two independent
155
+ // drift rows on one (rule, file) pair separately allow-listable.
156
+ const { valueDigest, snippet } = redact(`${identity}:release-coherence:${hit.rule}:${hit.disc}`);
157
+ return {
158
+ rule: hit.rule,
159
+ severity: RELEASE_COHERENCE_SEVERITIES[hit.rule] as Severity,
160
+ target: TARGET,
161
+ path: hit.path,
162
+ engine: ENGINE,
163
+ message: sanitizer?.sanitize(hit.message) ?? hit.message,
164
+ valueDigest,
165
+ snippet,
166
+ };
167
+ });
168
+ }
169
+
170
+ /** One collected version declaration inside an artifact. */
171
+ type Src = { readonly label: string; readonly path: string; readonly probe: Probe };
172
+
173
+ /** Primary = first LITERAL source in ecosystem priority order; every other literal is compared
174
+ * to it (one CRITICAL per disagreeing source); every dynamic source ⇒ one MEDIUM row. */
175
+ function analyze(sources: readonly Src[], pathPrefix: string, hits: Hit[]): void {
176
+ const primary = sources.find((s) => s.probe?.status === "literal");
177
+ for (const s of sources) {
178
+ if (s.probe === null) continue;
179
+ if (s.probe.status === "dynamic") {
180
+ hits.push({
181
+ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE,
182
+ message: `release coherence: ${s.label} (${s.path}) carries a version that cannot be verified statically — ${s.probe.reason}; surfaced, never silent-clean`,
183
+ path: `${pathPrefix}${s.path}`,
184
+ disc: `${s.path}:unverifiable`,
185
+ });
186
+ continue;
187
+ }
188
+ if (primary === undefined || s === primary) continue;
189
+ if (s.probe.value !== (primary.probe as { value: string }).value) {
190
+ hits.push({
191
+ rule: RELEASE_COHERENCE_DRIFT_RULE,
192
+ message: `release coherence: ${primary.label} '${(primary.probe as { value: string }).value}' disagrees with ${s.label} '${s.probe.value}' — ${FOUNDING}`,
193
+ path: `${pathPrefix}${s.path}`,
194
+ disc: `${s.path}:vs:${primary.label}`,
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ // --------------------------------------------------------------- npm
201
+
202
+ /** package.json vs package-lock.json root + packages[""] — ONLY when the lock is packed.
203
+ * lockText null ⇒ absent source ⇒ zero rows (see boundary note at the header). */
204
+ export function npmPackCoherenceFindings(o: {
205
+ packedVersion: string | null;
206
+ lockText: string | null;
207
+ lockRelPath: string;
208
+ identity: string;
209
+ sanitizer?: TextSanitizer;
210
+ }): Finding[] {
211
+ if (o.lockText === null || o.packedVersion === null) return [];
212
+ let lock: Record<string, unknown>;
213
+ try {
214
+ lock = JSON.parse(o.lockText) as Record<string, unknown>;
215
+ } catch {
216
+ return toFindings([{ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: `release coherence: ${o.lockRelPath} is packed but not parseable JSON — cannot verify, never silent-clean`, path: o.lockRelPath, disc: "malformed" }], o.identity, o.sanitizer);
217
+ }
218
+ const hits: Hit[] = [];
219
+ const rootVer = typeof lock["version"] === "string" ? (lock["version"] as string) : null;
220
+ const pkgs = lock["packages"];
221
+ const rootPkg = typeof pkgs === "object" && pkgs !== null ? (pkgs as Record<string, unknown>)[""] : undefined;
222
+ const pkgEmptyVer = typeof rootPkg === "object" && rootPkg !== null ? ((rootPkg as Record<string, unknown>)["version"]) : null;
223
+ const cmp = (label: string, value: string | null, disc: string): void => {
224
+ if (value !== null && value !== o.packedVersion) {
225
+ hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: packed package.json version '${String(o.packedVersion)}' disagrees with ${label} '${value}' — ${FOUNDING}`, path: o.lockRelPath, disc });
226
+ }
227
+ };
228
+ cmp("package-lock.json root version", rootVer, "root");
229
+ cmp('package-lock.json packages[""] version', typeof pkgEmptyVer === "string" ? pkgEmptyVer : null, "packages-empty");
230
+ return toFindings(hits, o.identity, o.sanitizer);
231
+ }
232
+
233
+ // --------------------------------------------------------------- pypi (wheel + sdist)
234
+
235
+ export function pypiArtifactCoherenceFindings(o: {
236
+ kind: "wheel" | "sdist";
237
+ archiveName: string;
238
+ files: readonly string[];
239
+ read: (rel: string) => string | null;
240
+ identity: string;
241
+ sanitizer?: TextSanitizer;
242
+ }): Finding[] {
243
+ const sources: Src[] = [];
244
+ const inTree = (rel: string): boolean => o.files.includes(rel);
245
+ const textOf = (rel: string): string | null => (inTree(rel) ? o.read(rel) : null);
246
+
247
+ if (o.kind === "wheel") {
248
+ const metaRel = o.files.find((f) => /\.dist-info\/METADATA$/.test(f));
249
+ if (metaRel !== undefined) {
250
+ const body = o.read(metaRel);
251
+ sources.push({ label: "wheel METADATA Version:", path: metaRel, probe: body === null ? null : probeHeaderVersion(body) ?? { status: "dynamic", reason: "METADATA has no Version: header" } });
252
+ const dirVer = parseDistInfoDirVersion(metaRel);
253
+ if (dirVer !== null) sources.push({ label: "wheel .dist-info directory name", path: metaRel, probe: { status: "literal", value: dirVer } });
254
+ }
255
+ const fn = parseWheelFilenameVersion(o.archiveName);
256
+ if (fn !== null) sources.push({ label: "wheel filename", path: metaRel ?? o.archiveName, probe: { status: "literal", value: fn.version } });
257
+ } else {
258
+ const pyproject = textOf("pyproject.toml");
259
+ if (pyproject !== null) sources.push({ label: "pyproject [project].version", path: "pyproject.toml", probe: probePyprojectVersion(pyproject) });
260
+ const cfg = textOf("setup.cfg");
261
+ if (cfg !== null) sources.push({ label: "setup.cfg [metadata] version", path: "setup.cfg", probe: probeSetupCfgVersion(cfg) });
262
+ const py = textOf("setup.py");
263
+ if (py !== null) sources.push({ label: "setup.py version literal", path: "setup.py", probe: probeSetupPyVersion(py) });
264
+ const pkgInfoRel = o.files.find((f) => f === "PKG-INFO" || f.endsWith(".egg-info/PKG-INFO"));
265
+ if (pkgInfoRel !== undefined) {
266
+ const body = o.read(pkgInfoRel);
267
+ if (body !== null) sources.push({ label: "PKG-INFO Version:", path: pkgInfoRel, probe: probeHeaderVersion(body) ?? { status: "dynamic", reason: "PKG-INFO has no Version: header" } });
268
+ }
269
+ }
270
+ for (const initRel of o.files.filter((f) => /(?:^|\/)__init__\.py$/.test(f)).sort()) {
271
+ const body = o.read(initRel);
272
+ if (body === null) continue;
273
+ const probe = probeDunderVersion(body);
274
+ if (probe !== null) sources.push({ label: `__version__ literal in ${initRel}`, path: initRel, probe });
275
+ }
276
+
277
+ const hits: Hit[] = [];
278
+ analyze(sources, `${o.archiveName}!`, hits);
279
+ return toFindings(hits, o.identity, o.sanitizer);
280
+ }
281
+
282
+ // --------------------------------------------------------------- crates
283
+
284
+ export function cratesCoherenceFindings(o: {
285
+ name: string;
286
+ version: string;
287
+ lockText: string | null;
288
+ lockRelPath: string;
289
+ identity: string;
290
+ sanitizer?: TextSanitizer;
291
+ }): Finding[] {
292
+ if (o.lockText === null) return []; // Cargo.lock not packed ⇒ absent source, not drift.
293
+ const entries = o.lockText
294
+ .split(/^\[\[package\]\][ \t]*$/m)
295
+ .map((block) => ({ name: /^\s*name\s*=\s*"([^"]*)"/m.exec(block)?.[1], version: /^\s*version\s*=\s*"([^"]*)"/m.exec(block)?.[1] }))
296
+ .filter((e): e is { name: string; version: string } => e.name !== undefined && e.version !== undefined)
297
+ .filter((e) => e.name === o.name);
298
+ if (entries.length === 0) {
299
+ return toFindings([{ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: `release coherence: ${o.lockRelPath} is packed but names no [[package]] '${o.name}' — cannot verify, never silent-clean`, path: o.lockRelPath, disc: "no-entry" }], o.identity, o.sanitizer);
300
+ }
301
+ const hits: Hit[] = [];
302
+ for (const v of [...new Set(entries.map((e) => e.version))].sort()) {
303
+ if (v !== o.version) {
304
+ hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: Cargo.toml [package].version '${o.version}' disagrees with Cargo.lock entry '${o.name}' version '${v}' — ${FOUNDING}`, path: o.lockRelPath, disc: v });
305
+ }
306
+ }
307
+ return toFindings(hits, o.identity, o.sanitizer);
308
+ }
309
+
310
+ // --------------------------------------------------------------- rubygems
311
+
312
+ /**
313
+ * The .gem FORMAT derives the registry name-version from the spec itself, so for a
314
+ * conventionally-built gem filename↔metadata drift looks vacuous — but it is REACHABLE
315
+ * through border's own identity seam: channels/gemspec.ts GEMSPEC_VERSION (no /m flag)
316
+ * takes the FIRST literal `s.version = "..."`, while Ruby evaluates assignments in order
317
+ * (the LAST wins). A gemspec with two literal s.version assignments therefore produces a
318
+ * border-guessed `.gem` filename disagreeing with metadata.gz — the shipped bytes' own
319
+ * claim. metadata.gz is authoritative; this closes exactly that divergence.
320
+ */
321
+ export function gemCoherenceFindings(o: {
322
+ specName: string;
323
+ specVersion: string;
324
+ metadataText: string | null;
325
+ identity: string;
326
+ sanitizer?: TextSanitizer;
327
+ }): Finding[] {
328
+ if (o.metadataText === null) return []; // non-gem container layout ⇒ absent source, not drift.
329
+ const name = /^name:[ \t]*(\S+)[ \t]*$/m.exec(o.metadataText)?.[1];
330
+ const version = /^version:[ \t]*!ruby\/object:Gem::Version[ \t]*\n[ \t]+version:[ \t]*(\S+)[ \t]*$/m.exec(o.metadataText)?.[1];
331
+ if (name === undefined || version === undefined) {
332
+ return toFindings([{ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: "release coherence: metadata.gz is present but its Gem::Specification name/version could not be read statically — cannot verify, never silent-clean", path: "metadata.gz", disc: "unparseable" }], o.identity, o.sanitizer);
333
+ }
334
+ const hits: Hit[] = [];
335
+ if (name !== o.specName) hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: built gem '${o.identity}' names '${o.specName}' but metadata.gz Gem::Specification name is '${name}' — ${FOUNDING}`, path: "metadata.gz", disc: "name" });
336
+ if (version !== o.specVersion) hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: built gem '${o.identity}' names version '${o.specVersion}' but metadata.gz Gem::Version is '${version}' — ${FOUNDING}`, path: "metadata.gz", disc: "version" });
337
+ return toFindings(hits, o.identity, o.sanitizer);
338
+ }
339
+
340
+ // --------------------------------------------------------------- release.twin (cross-manager)
341
+
342
+ export type TwinPair = { readonly pypi: string; readonly npm: string };
343
+
344
+ /** Read a packed tgz's package.json name+version straight from the STAGED BYTES (tar -xzO;
345
+ * no extraction, nothing executed). Returns null when the member/JSON is unreadable. */
346
+ function packedNpmIdentity(archiveAbs: string, env?: Readonly<Record<string, string | undefined>>): { name: string; version: string } | null {
347
+ const opts = { encoding: "utf8" as const, timeout: 30_000, ...(env !== undefined ? { env: env as NodeJS.ProcessEnv } : {}) };
348
+ const listing = spawnSync("tar", ["-tzf", archiveAbs], opts);
349
+ if (listing.status !== 0) return null;
350
+ const members = (listing.stdout ?? "").split("\n").filter((l) => /^[^/]+\/package\.json$/.test(l));
351
+ const member = members.includes("package/package.json") ? "package/package.json" : members.length === 1 ? members[0] : undefined;
352
+ if (member === undefined) return null;
353
+ const body = spawnSync("tar", ["-xzOf", archiveAbs, member], opts);
354
+ if (body.status !== 0) return null;
355
+ try {
356
+ const pkg = JSON.parse(body.stdout as string) as Record<string, unknown>;
357
+ return typeof pkg["name"] === "string" && typeof pkg["version"] === "string" ? { name: pkg["name"], version: pkg["version"] } : null;
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * release.twin: the ONE place a cross-manager version claim is statically enforceable
365
+ * (plan W4.1) — when a project declares {pypi, npm} twins, the wheel's version and the npm
366
+ * twin's PACKED package.json version must be string-equal. Wheels are read by filename
367
+ * (rule-3 already reconciled filename↔METADATA↔__version__ in the same run); the npm side
368
+ * is read from the staged tarball bytes. Either side missing/unreadable ⇒ MEDIUM — a
369
+ * declared obligation that cannot be proven is never silent-clean.
370
+ */
371
+ export async function twinCoherenceFindings(o: {
372
+ pairs: readonly TwinPair[];
373
+ artifacts: readonly LedgerArtifact[];
374
+ repoDir: string;
375
+ env?: Readonly<Record<string, string | undefined>>;
376
+ sanitizer?: TextSanitizer;
377
+ }): Promise<Finding[]> {
378
+ const wheels = o.artifacts.filter((a) => a.file.endsWith(".whl")).sort((a, b) => a.file.localeCompare(b.file));
379
+ const tgzs = o.artifacts.filter((a) => a.file.endsWith(".tgz")).sort((a, b) => a.file.localeCompare(b.file));
380
+ const hits: Hit[] = [];
381
+ for (const pair of o.pairs) {
382
+ const wh = wheels.find((a) => {
383
+ const parsed = parseWheelFilenameVersion(basename(a.file));
384
+ return parsed !== null && normName(parsed.name) === normName(pair.pypi);
385
+ });
386
+ if (wh === undefined) {
387
+ hits.push({ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: `release coherence: release.twin pair '${pair.pypi}'/'${pair.npm}' — no staged wheel for '${pair.pypi}' in this run, so the declared cross-manager equality cannot be verified`, path: `${pair.pypi}(missing).whl`, disc: `twin:${pair.pypi}:no-wheel` });
388
+ continue;
389
+ }
390
+ const wheelVersion = (parseWheelFilenameVersion(basename(wh.file)) as { version: string }).version;
391
+ let npmSide: { file: string; version: string } | null = null;
392
+ let npmUnreadable = false;
393
+ for (const t of tgzs) {
394
+ const id = packedNpmIdentity(join(o.repoDir, t.file), o.env);
395
+ if (id === null) {
396
+ npmUnreadable = true;
397
+ continue;
398
+ }
399
+ if (id.name === pair.npm) {
400
+ npmSide = { file: t.file, version: id.version };
401
+ break;
402
+ }
403
+ }
404
+ if (npmSide === null) {
405
+ hits.push({
406
+ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE,
407
+ message: `release coherence: release.twin pair '${pair.pypi}'/'${pair.npm}' — ${npmUnreadable ? `npm twin '${pair.npm}' is staged but its package.json could not be read from the tarball bytes` : `no staged npm artifact for twin '${pair.npm}' in this run`}, so the declared cross-manager equality cannot be verified`,
408
+ path: basename(wh.file),
409
+ disc: `twin:${pair.pypi}:no-npm`,
410
+ });
411
+ continue;
412
+ }
413
+ if (wheelVersion !== npmSide.version) {
414
+ hits.push({
415
+ rule: RELEASE_COHERENCE_TWIN_RULE,
416
+ message: `release coherence: release.twin pair '${pair.pypi}'/'${pair.npm}' — wheel '${basename(wh.file)}' says version '${wheelVersion}' but npm twin '${basename(npmSide.file)}' packed package.json says '${npmSide.version}' — ${FOUNDING}`,
417
+ path: basename(wh.file),
418
+ disc: `twin:${pair.pypi}:${basename(npmSide.file)}`,
419
+ });
420
+ }
421
+ }
422
+ return toFindings(hits, "release.twin", o.sanitizer);
423
+ }
@@ -24,6 +24,7 @@ import { BORDER_STATE_DIR, ensureStateDir } from "../check/lock.ts";
24
24
  import { extractArchive, removeSandbox } from "./extract.ts";
25
25
  import { residueFindings } from "./residue.ts";
26
26
  import { residueCratesHits } from "./residueRust.ts";
27
+ import { cratesCoherenceFindings } from "../rules/releaseCoherence.ts";
27
28
  import { scanTree } from "../engines/gitleaks.ts";
28
29
  import { scanPaths, type SecretlintMode } from "../engines/secretlint.ts";
29
30
  import { EngineMissingError, EngineRunError, type EngineOptions } from "../engines/support.ts";
@@ -214,6 +215,18 @@ export async function runCargoArtifactStage(o: CrateStageOptions): Promise<Crate
214
215
  identity: crateName,
215
216
  ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
216
217
  }));
218
+ // W4.1 release coherence: Cargo.toml [package].version ↔ the packed Cargo.lock's
219
+ // [[package]] entry for this crate. cargo only ships Cargo.lock when packed —
220
+ // ABSENT-SOURCE BOUNDARY: unpacked lock is not drift, never a finding.
221
+ const lockRel = `${identity.name}-${identity.version}/Cargo.lock`;
222
+ findings.push(...cratesCoherenceFindings({
223
+ name: identity.name,
224
+ version: identity.version,
225
+ lockText: relPaths.includes(lockRel) ? readFileSync(join(extractDir, lockRel), "utf8") : null,
226
+ lockRelPath: lockRel,
227
+ identity: crateName,
228
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
229
+ }));
217
230
  } finally {
218
231
  removeSandbox(extractDir);
219
232
  }