diff --git a/.changeset/lockfile-integrity-gate.md b/.changeset/lockfile-integrity-gate.md new file mode 100644 index 0000000000..994f11569f --- /dev/null +++ b/.changeset/lockfile-integrity-gate.md @@ -0,0 +1,7 @@ +--- +--- + +Internal tooling only: a new CI lockfile-integrity gate (`scripts/check-lockfile-integrity.mjs`), +its workflow, its test suite and its `ci-cd-pipeline.md` section. No published package source, no +publish-contract field and no `apps/console` executable source is touched, so nothing here changes +any released artifact — declared explicitly rather than left to inference (objectui#8326). diff --git a/.github/workflows/lockfile-integrity.yml b/.github/workflows/lockfile-integrity.yml new file mode 100644 index 0000000000..35c0c4b5ac --- /dev/null +++ b/.github/workflows/lockfile-integrity.yml @@ -0,0 +1,89 @@ +name: Lockfile Integrity + +# objectui#8326 — a pull request's `pnpm-lock.yaml` may not silently DUPLICATE a +# dependency the workspace declares, or move an `@objectstack/*` identity +# BACKWARD. The mechanism, both incident causes and every boundary this gate +# keeps are documented at length in `scripts/check-lockfile-integrity.mjs`. +# +# ── Why it is its own workflow ─────────────────────────────────────────────── +# +# It is the only gate in this repository that needs TWO revisions of a file: the +# pull request's lockfile and its merge base's. That is why it checks out with +# `fetch-depth: 0` and fetches the base branch, which no other gate here pays +# for. Keeping it separate keeps that cost off every other job. +# +# ── Why it IS path-filtered, unlike shell-escape-residue.yml and friends ───── +# +# Those gates read the whole tree, so any change can trip them and a path filter +# would build the hole they exist to close. This one reads exactly one file and +# compares it with its own base: a pull request that does not touch +# `pnpm-lock.yaml` cannot produce a finding, by construction — the head and base +# blobs are then the same bytes. So the filter costs no coverage. +# +# ⚠️ The filter is also what makes it UNREQUIRABLE: `#3523`'s rule, enforced by +# `scripts/__tests__/dependabot-merge-gate.test.ts`, is that a required context +# must come from a workflow whose `pull_request` trigger has no path filter. +# +# ── ⛔ Deliberately NOT enrolled as a blocking context ──────────────────────── +# +# `scripts/dependabot-merge-gate.mjs` classifies `Lockfile Integrity Check` in +# `NOT_A_GATE`, so a red here does not stop a Dependabot auto-merge and does not +# block the merge queue. That is a MAINTAINER decision with its own cost, and +# objectui#8326's dispatch reserved it: what enrolling would take, and what it +# would have cost over the last 40 lockfile-changing commits on `main`, is +# written up in that card's pull request as input. ⛔ Do not promote this to +# `REQUIRED_CONTEXTS` or `OPTIONAL_CONTEXTS` without that decision. +# +# Until then it is an ALARM: it names the packages, in one line, on the pull +# request where the change is. That alone is the whole of what the incident +# needed — two seats each spent a full diagnosis discovering that a +# `Bundle Analysis` red was not about the dependency being bumped. + +on: + pull_request: + branches: [main, develop] + paths: + - 'pnpm-lock.yaml' + - 'scripts/check-lockfile-integrity.mjs' + - '.github/workflows/lockfile-integrity.yml' + workflow_dispatch: + +concurrency: + group: lockfile-integrity-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lockfile-integrity: + name: Lockfile Integrity Check + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + # `fetch-depth: 0` because the gate's subject is a DELTA. A shallow + # checkout has no merge base, and the script refuses to guess one — it + # exits 2 (could not take a reading), which is never a pass. + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + + # No install and no build: the gate is one `node` call over two text + # files, builtins plus one repo-relative module only, which + # `pre-install-import-graph.yml` enforces (objectui#6148). + - name: Fetch the base branch so the merge base exists + env: + BASE_REF: ${{ github.base_ref || 'main' }} + run: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + + - name: Compare this lockfile with its merge base + env: + BASE_REF: ${{ github.base_ref || 'main' }} + run: node scripts/check-lockfile-integrity.mjs --base-ref "origin/${BASE_REF}" diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 3e624af14b..d224467bcc 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -43,6 +43,7 @@ one has its own section below. | `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) | | `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval record (on any commit) is refused there; on the pull request itself it is deliberately green and prints an early warning | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | +| `lockfile-integrity.yml` | Lockfile Integrity Check | PR to `main`, `develop` touching `pnpm-lock.yaml` or the gate's own two files; manual | No — **deliberately not a blocking context** ([#8326](https://github.com/objectstack-ai/objectui/issues/8326)); it names the packages and the Dependabot merge gate classifies it `NOT_A_GATE` | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane: not in the required-check set, and it declares no `merge_group` trigger | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | | `dependabot-auto-merge.yml` | Dependabot Auto-merge | PR to `main`/`develop` authored by `dependabot[bot]` | No — but it gates *its own* merge, and goes red instead of merging when the check set is not green | @@ -1472,6 +1473,54 @@ workflow runs as its own first step because a rotted predicate must redden rathe governed diff through, and the wiring is pinned by `scripts/__tests__/check-governed-queue-guard.test.ts`. +## Lockfile Integrity (`lockfile-integrity.yml`) + +**Triggers:** Pull requests to `main`/`develop` that touch `pnpm-lock.yaml`, +`scripts/check-lockfile-integrity.mjs` or the workflow file itself, plus manual dispatch. It appears +in the checks list as **Lockfile Integrity Check**. + +Runs `scripts/check-lockfile-integrity.mjs`, which compares the pull request's `pnpm-lock.yaml` +against the **merge base's** and reports two things: + +1. an `@objectstack/*` identity resolving to a version **lower** than the base resolved; +2. a package the workspace declares at runtime (`dependencies`, `peerDependencies`, + `optionalDependencies`) resolving to **more physical copies** than the base did — where a copy is + a `snapshots:` key, peer suffix included, because that is the directory pnpm creates. + +It is the only gate here that needs two revisions of a file, which is why it checks out with +`fetch-depth: 0`. Exit `0` clean, `1` findings, `2` **could not take a reading** — a shallow checkout +or a lockfile whose format moved exits 2 rather than reporting a clean lockfile it never read. + +**Why it needed a gate.** In [#8326](https://github.com/objectstack-ai/objectui/issues/8326) +Dependabot's regenerated lockfile on three pull requests at once downgraded the whole +`@objectstack/*` family 17.3.0 → 17.2.0 and forked zod, splitting the workspace across **two physical +`@objectstack/spec` copies**. Nothing dedupes two real paths, so `vendor-objectstack` went +4,030,557 → 8,423,436 raw bytes. What that produced was a **red `Bundle Analysis` on a dependency bump +for a reason that had nothing to do with the dependency** — and the obvious reading, "this bump bloats +the bundle", was wrong. Two seats each spent a full diagnosis reaching the same answer independently. +Every workspace range on the family is a floating `^17.x` against a registry whose latest is 17.3.0, +so no fresh resolve can produce 17.2.0: it was a stale resolution carried across a rebase. + +**Why one rule was not enough.** [#8333](https://github.com/objectstack-ai/objectui/issues/8333) +measured the other cause: floating `better-auth` to 1.7.3 from a **clean** base forks zod while moving +**no `@objectstack/*` identity at all**, and re-measuring it on `da5e4f69e` shows `@objectstack/spec` +staying at one version and still landing as **two directories**, one per peer resolution. A gate that +only asked "did an identity move backward", or that counted versions rather than snapshot keys, is +green on that. + +**⛔ What this gate does not do.** It does not pin, dedupe or re-resolve anything — the remedy for a +finding is a decision the pull request makes. It is a **ratchet, not an audit**: a lockfile that is +already duplicated stays green while the change does not make it worse. It does not judge backward +moves outside `@objectstack/*` (third-party downgrades are legitimate) or duplications of packages +this workspace does not itself declare at runtime — measured, scoping it wider reds on ordinary merged +build-tooling churn. And it says nothing about bytes; `Bundle Analysis` measures those. + +**⛔ It cannot block anything today**, by design. `scripts/dependabot-merge-gate.mjs` classifies it in +`NOT_A_GATE`, and its `pull_request` trigger is path-filtered, which under +[#3523](https://github.com/objectstack-ai/objectui/issues/3523)'s rule makes it unrequirable while +that filter stands. Enrolling it is a maintainer decision with its own cost, written up on #8326's +pull request as input. + ## Link Checking (`check-links.yml`) **Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run diff --git a/package.json b/package.json index c5a4732083..5c9ca853d0 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "check:governed-queue-guard": "node scripts/check-governed-queue-guard.mjs --self-test", "governed": "node scripts/check-governed-queue-guard.mjs --test", "check:merge-queue-head": "node scripts/check-merge-queue-head.mjs --self-test", + "check:lockfile-integrity": "node scripts/check-lockfile-integrity.mjs", "merge-queue-head": "node scripts/check-merge-queue-head.mjs", "check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs", "check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs", diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts new file mode 100644 index 0000000000..7e9236aff7 --- /dev/null +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -0,0 +1,219 @@ +/** + * objectui#8326 — the lockfile-integrity gate, pinned against the two causes it + * was built for and against the three ways it could be worthless. + * + * The gate's own logic is exercised by `--self-test` inside the script (29 + * cases, synthetic lockfiles, no git). This file adds the three things a + * synthetic corpus cannot say: + * + * 1. it is GREEN on this repository's real `pnpm-lock.yaml`; + * 2. it is RED on the two real incident samples, built from that same real + * lockfile by a transformation this file asserts actually landed; + * 3. it is wired — the workflow runs it, and the Dependabot merge gate + * classifies the check it produces. + * + * ⚠️ (2) matters more than it looks. A gate that is red on everything passes + * both samples and is worthless, so every red assertion here is paired with a + * green one over the same corpus. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { readWorkflows, repoRoot } from './workflow-checks'; +import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; +import { + compareLockfiles, + indexLockfile, + readDeclaredNames, + readSnapshotKeys, +} from '../check-lockfile-integrity.mjs'; + +const GATE = path.join(repoRoot, 'scripts/check-lockfile-integrity.mjs'); +const LOCKFILE = path.join(repoRoot, 'pnpm-lock.yaml'); +const realLock = fs.readFileSync(LOCKFILE, 'utf8'); + +/** The check-run name the workflow's job produces. */ +const CHECK_NAME = 'Lockfile Integrity Check'; + +describe('the gate runs its own self-test', () => { + it('exits 0 with every case passing', () => { + const out = execFileSync('node', [GATE, '--self-test'], { encoding: 'utf8' }); + expect(out).toMatch(/^✓ check-lockfile-integrity self-test: \d+ cases pass/m); + }); +}); + +describe('the real lockfile parses, and parses to something', () => { + // Anti-vacuity, the failure mode this repository keeps re-teaching: every + // assertion below is a comparison over these sets, and an empty set compares + // clean no matter what the change did. + const keys = readSnapshotKeys(realLock); + const declared = readDeclaredNames(realLock); + + it('reads a four-figure resolution set out of pnpm-lock.yaml', () => { + expect(keys.length).toBeGreaterThan(1000); + }); + + it('reads the workspace-declared runtime names out of the importers section', () => { + expect(declared.size).toBeGreaterThan(50); + // The two packages the incident is about, both declared by real packages: + // `zod` by packages/types, app-shell and plugin-timeline; the spec by most + // of the workspace. + expect(declared).toContain('zod'); + expect(declared).toContain('@objectstack/spec'); + // ⛔ And a devDependency-only name is NOT in scope — the exclusion that + // keeps the gate off ordinary build-tooling churn (`90bc5d16a`). + expect(declared).not.toContain('vitest'); + }); +}); + +describe('green on main — the negative control', () => { + it('is clean against itself', () => { + const idx = indexLockfile(realLock, 'main'); + expect(compareLockfiles(idx, idx).findings).toEqual([]); + }); + + it('is clean against its own merge base in a real run', () => { + // Exercises the git path (`--base-ref`), not just the pure functions. + const out = execFileSync('node', [GATE, '--base-ref', 'origin/main'], { + encoding: 'utf8', + cwd: repoRoot, + }); + expect(out).toContain('VERDICT clean'); + }); +}); + +/** + * Sample (a) — objectui#8326's own scenario. + * + * ⛔ It cannot be produced by running the resolver, and that impossibility is + * the card's central claim: every workspace range on `@objectstack/*` is a + * floating `^17.x` and the registry's latest is 17.3.0, so no fresh resolve can + * pick 17.2.0. The sample is therefore a transformation of the real lockfile + * reproducing the identity facts measured on PRs #7053 / #7058 — the family + * down to 17.2.0, and `@objectstack/spec` resolved at BOTH versions. + */ +function sampleA(): string { + let out = realLock; + for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { + out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.3\\.0`, 'g'), '$117.2.0'); + } + const specKey = " '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))':"; + expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain( + specKey, + ); + return out.replace( + specKey, + " '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.4.3))':\n dependencies:\n zod: 4.4.3\n" + + specKey, + ); +} + +/** + * Sample (b) — objectui#8333, re-measured on `da5e4f69e` on 2026-09-08 by + * `pnpm update better-auth --lockfile-only --recursive`, which resolved 1.7.3 + * and produced exactly this: the better-auth family forward, `zod@4.5.4` added, + * and every `@objectstack/*` package that sits under `ai` gaining a SECOND peer + * context at the SAME version. + * + * ⭐ That last part is the discriminator. No `@objectstack/*` version moves, so + * a gate that only asks "did an identity move backward" is green here. + */ +function sampleB(): string { + let out = realLock; + const forked = [ + " '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))':", + " '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))':", + " ai@7.0.65(zod@4.4.3):", + ]; + for (const key of forked) { + expect(out, `snapshot key moved: ${key}`).toContain(key); + out = out.replace(key, `${key.replace('zod@4.4.3', 'zod@4.5.4')}\n dependencies: {}\n${key}`); + } + const zodKey = ' zod@4.4.3: {}'; + expect(out).toContain(zodKey); + return out.replace(zodKey, `${zodKey}\n zod@4.5.4: {}`); +} + +describe('cause 1 — an @objectstack/* identity moves backward (objectui#8326)', () => { + const reading = compareLockfiles(indexLockfile(realLock, 'base'), indexLockfile(sampleA(), 'a')); + + it('the sample really differs from the base (the mutation landed)', () => { + expect(sampleA()).not.toEqual(realLock); + expect(sampleA()).toContain('@objectstack/client@17.2.0'); + }); + + it('goes red, naming every family member that moved', () => { + const names = reading.backward.map((f) => f.name); + expect(names).toEqual( + expect.arrayContaining([ + '@objectstack/client', + '@objectstack/core', + '@objectstack/formula', + '@objectstack/lint', + '@objectstack/sdui-parser', + ]), + ); + }); + + it('also names the duplication the downgrade created', () => { + expect(reading.duplicated.map((f) => f.name)).toContain('@objectstack/spec'); + }); +}); + +describe('cause 2 — a declared dependency forks with no version moving (objectui#8333)', () => { + const reading = compareLockfiles(indexLockfile(realLock, 'base'), indexLockfile(sampleB(), 'b')); + + it('the sample really differs from the base (the mutation landed)', () => { + expect(sampleB()).not.toEqual(realLock); + expect(sampleB()).toContain('zod@4.5.4'); + }); + + it('⭐ moves no @objectstack/* identity at all — so rule 1 is blind to it', () => { + expect(reading.backward).toEqual([]); + }); + + it('⭐ still goes red: zod gained a copy, though it was never single-copy', () => { + const zod = reading.duplicated.find((f) => f.name === 'zod'); + expect(zod, 'the gate must not be blind to objectui#8333').toBeTruthy(); + // ⛔ 2 -> 3, not 1 -> 2. Both cards say "forks a single-copy dependency"; + // measured on `main`, zod is ALREADY two copies (3.25.76 + 4.4.3), so a + // rule keyed on "was one, is now two" would be green here. + expect(zod?.baseCount).toBe(2); + expect(zod?.headCount).toBe(3); + }); + + it('⭐ names the second physical @objectstack/spec, at one unchanged version', () => { + const spec = reading.duplicated.find((f) => f.name === '@objectstack/spec'); + expect(spec?.headCount).toBe(2); + // This is #8326's exact bundling mechanism — two real paths, nothing + // dedupes them — arriving with no version change anywhere. + expect(spec?.head.every((k) => k.includes('@17.3.0'))).toBe(true); + }); +}); + +describe('the gate is wired, and wired the way the dispatch reserved', () => { + const workflow = readWorkflows().find((w) => w.file === 'lockfile-integrity.yml'); + + it('exists and runs the gate', () => { + expect(workflow, '.github/workflows/lockfile-integrity.yml is missing').toBeTruthy(); + expect(workflow?.text).toContain('node scripts/check-lockfile-integrity.mjs'); + }); + + it('checks out deep enough to have a merge base', () => { + // A shallow checkout has no merge base and the script exits 2 rather than + // guessing — which would be a check that reports nothing and looks fine. + expect(workflow?.text).toContain('fetch-depth: 0'); + }); + + it('⛔ is classified as NOT a blocking context (the maintainer floor)', () => { + expect(Object.keys(NOT_A_GATE)).toContain(CHECK_NAME); + expect(REQUIRED_CONTEXTS).not.toContain(CHECK_NAME); + expect(Object.keys(OPTIONAL_CONTEXTS)).not.toContain(CHECK_NAME); + }); + + it('states in its own classification why enrolling it is not this card', () => { + expect(NOT_A_GATE[CHECK_NAME]).toMatch(/maintainer decision/); + }); +}); diff --git a/scripts/check-lockfile-integrity.mjs b/scripts/check-lockfile-integrity.mjs new file mode 100644 index 0000000000..7512dd8c6d --- /dev/null +++ b/scripts/check-lockfile-integrity.mjs @@ -0,0 +1,632 @@ +#!/usr/bin/env node +/** + * check-lockfile-integrity -- a pull request's `pnpm-lock.yaml` may not silently + * DUPLICATE a dependency or move an `@objectstack/*` identity BACKWARD. + * + * node scripts/check-lockfile-integrity.mjs # head = worktree, base = merge-base with origin/main + * node scripts/check-lockfile-integrity.mjs --base-ref origin/main + * node scripts/check-lockfile-integrity.mjs --base --head + * node scripts/check-lockfile-integrity.mjs --json + * node scripts/check-lockfile-integrity.mjs --self-test # offline, no git + * + * Exit: 0 = a reading was taken and the change is clean; 1 = FINDINGS; + * 2 = the reading COULD NOT BE TAKEN (missing base, unparseable lockfile). + * 2 is never a pass: nothing about the change was judged by that run. + * + * ## The incident (objectui#8326, corroborated on #7053, #7054, #7058) + * + * Dependabot's regenerated lockfile on #7058 changed 46 package identities, + * downgrading the whole `@objectstack/*` family 17.3.0 -> 17.2.0 and forking + * zod into 4.4.3 + 4.5.4. That split the workspace across TWO physical + * `@objectstack/spec` copies (console / core / runner on one, app-shell / + * components on the other). Nothing dedupes two real paths, so spec was bundled + * twice: `vendor-objectstack` 4,030,557 -> 8,423,436 raw bytes (2.09x), the + * `objectstack/spec` marker count 620 -> 1136. + * + * ⭐ The failure mode is what earns the gate, not the byte count: `Bundle + * Analysis` reds on a dependency bump FOR A REASON THAT HAS NOTHING TO DO WITH + * THE DEPENDENCY, and the obvious reading -- "this bump bloats the bundle" -- + * is wrong. Two seats each spent a full diagnosis reaching the same answer + * independently. This gate exists to say that in one line. + * + * ⛔ It reports. It does not pin, dedupe, or re-resolve anything: the remedy for + * a finding is a decision (re-lock from a clean base, pin, accept), and the + * 2026-09-08 triage ruling on #8326 kept that decision out of the gate. + * + * ## The two causes it must catch, and why one rule is not enough + * + * cause 1 an `@objectstack/*` identity moves BACKWARD (objectui#8326) + * cause 2 a dependency gains a physical COPY (objectui#8333) + * + * ⭐ objectui#8333 is the measured proof that cause 1 alone is blind. Floating + * `better-auth` from 1.7.2 to 1.7.3 -- re-measured on `da5e4f69e`, 2026-09-08 -- + * moves NO `@objectstack/*` identity at all: the delta is the better-auth + * family, `@mongodb-js/saslprep` 1.5.0->1.5.2, `seroval`(-plugins) 1.6.4->1.6.7, + * every one of them FORWARD, plus one ADDED `zod@4.5.4`. Rule 1 is green on it. + * + * ⭐ And rule 2 is not "a single-copy dependency forks", which is the wording + * both cards use. Measured on `da5e4f69e`: zod is ALREADY two copies on `main` + * (`zod@3.25.76` + `zod@4.4.3`), so a rule keyed on "was 1, is now 2" is green + * on #8333 too. The gate therefore compares COUNTS: head > base is a finding, + * whatever the base count was. + * + * ## Why the unit of rule 2 is the SNAPSHOT key, not the version + * + * ⭐ Also measured on that re-resolve: `@objectstack/spec` stays at ONE version + * (17.3.0) and still ends up as TWO physical directories -- + * + * base '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))' + * head '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))' + * '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.5.4))' + * + * -- because pnpm gives each peer resolution its own directory under `.pnpm`. + * That is #8326's exact bundling mechanism (two real paths, nothing dedupes + * them) arriving with no version change anywhere. A gate counting VERSIONS + * reports one spec and passes; a gate counting snapshot keys reports two. + * + * ## Boundary: what this does NOT own + * + * ⛔ The absolute invariant "the lockfile resolves exactly one + * `@objectstack/spec` VERSION" is already owned, and is deliberately not + * restated here -- `scripts/__tests__/ci-cd-pipeline-doc.test.ts` + * (`ci-cd-pipeline.md — live-e2e backend pin (#7689)`) asserts it as the + * precondition of comparing `OBJECTSTACK_VERSION` in `e2e/live/ci/backend.env` + * against the lockfile. Two definitions of one rule are free to drift, so this + * gate asks a DIFFERENT question: not "is the tree in the right state" but "did + * THIS CHANGE make it worse". That guard is absolute, single-package, + * version-level, and on the unit lane; this one is a delta, over every package, + * at snapshot-key level, and reads two lockfiles. + * + * Consequences of that boundary, stated so nobody reads a green as more: + * + * - A lockfile that is ALREADY duplicated stays green here as long as the + * change does not make it worse. This is a ratchet, not an audit. + * - A backward move outside `@objectstack/*` is not judged. Third-party + * downgrades are legitimate (a revert, a yanked release, a security pin). + * `@objectstack/*` is scoped in because every workspace range on it is a + * floating `^17.x` against a registry whose latest is 17.3.0 -- so a + * backward move there cannot be the result of any fresh resolution, which + * is #8326's impossibility argument. + * - It says nothing about bytes. `Bundle Analysis` measures those; this names + * the cause so that reading is not attributed to the dependency. + * + * ## Anti-vacuity + * + * Both lockfiles must parse to a non-empty snapshot set, and the base and head + * must not be byte-identical-with-zero-identities. A lockfile format change + * therefore exits 2 (cannot read) instead of green-over-an-empty-set. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const REPO_ROOT = path.join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const LOCKFILE = 'pnpm-lock.yaml'; + +export const EXIT_CLEAN = 0; +export const EXIT_FINDINGS = 1; +export const EXIT_CANNOT_RUN = 2; + +/** The scope whose backward movement is structurally impossible from a fresh resolve. */ +export const FIRST_PARTY_SCOPE = '@objectstack/'; + +// ── parsing ───────────────────────────────────────────────────────────────── + +/** + * Split a pnpm snapshot key into its package name, version and peer suffix. + * + * `'@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))'` -> + * { name: '@objectstack/spec', version: '17.3.0', peers: '(ai@7.0.65(zod@4.4.3))' } + * + * A key whose version does not start with a digit (a git or URL dependency) is + * returned with `version: null`. Such a key still counts as a physical copy — + * it is a directory like any other — it is only excluded from the ordering + * comparison in rule 1, which has nothing to compare it with. + * + * @param {string} key + * @returns {{ name: string, version: string|null, peers: string }} + */ +export function parseSnapshotKey(key) { + const paren = key.indexOf('('); + const base = paren === -1 ? key : key.slice(0, paren); + const peers = paren === -1 ? '' : key.slice(paren); + const at = base.lastIndexOf('@'); + if (at <= 0) return { name: base, version: null, peers }; + const version = base.slice(at + 1); + return { + name: base.slice(0, at), + version: /^[0-9]/.test(version) ? version : null, + peers, + }; +} + +/** + * Every `snapshots:` key of a lockfile, in file order. + * + * The `snapshots:` section is read rather than `packages:` because a snapshot + * key IS the physical directory pnpm creates; two snapshot keys of one version + * are two directories, and that is the duplication the incident is about. + * Keys sit at exactly two spaces of indentation, optionally single-quoted; the + * section ends at the next column-0 key. + * + * @param {string} text + * @returns {string[]} + */ +export function readSnapshotKeys(text) { + const lines = text.split('\n'); + const start = lines.indexOf('snapshots:'); + if (start === -1) return []; + const keys = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (/^[^\s#]/.test(line)) break; // next top-level section + const m = line.match(/^ {2}('?)([^\s'].*?)\1:(?:\s.*)?$/); + if (m) keys.push(m[2]); + } + return keys; +} + +/** + * The importer blocks whose contents can reach a shipped bundle. + * + * ⛔ `devDependencies` is excluded, and that is the second half of the same + * measurement. Over the 40 most recent lockfile-changing commits on `main`, + * scoping rule 2 to every declared name reds on three: `1bae75bb8` + * (`@vitejs/plugin-react` 1 -> 2), `90bc5d16a` (`vite` and `vitest` 2 -> 3) and + * `bde6483b1` (`lucide-react` 1 -> 2). The first two are build tooling — a + * second copy of vitest ships nothing and costs no bytes anywhere `Bundle + * Analysis` can see. The third is a real runtime dependency of nine packages, + * and is the kind of finding this gate is for. Excluding devDependencies keeps + * the third and drops the first two. + */ +const RUNTIME_BLOCKS = new Set(['dependencies', 'peerDependencies', 'optionalDependencies']); + +/** + * Every package name THIS REPOSITORY declares, read from the lockfile's own + * `importers:` section (name at six spaces, `specifier:` on the next line). + * + * ⭐ This is the scope of rule 2, and the scope is a measurement, not taste. + * Run repo-wide, rule 2 reds on ordinary merged history: `5505aec1a` + * (mermaid 11.17.1 -> 11.17.2, merged, nothing wrong with it) adds a fourth + * `tinyexec` to the three already there. A gate that reds on that gets turned + * off, and then it is not there for the one that matters. + * + * A name the workspace declares is one this repository chose, with one range, + * in one place — `zod` is declared `^4.4.3` by five packages — so a copy of it + * nobody declared is a fact about the resolution rather than about a bump. A + * name only some transitive subtree asks for has no such author here. + * + * ⛔ The boundary that follows: a purely transitive package duplicating is NOT + * judged. Read from the lockfile rather than by walking `package.json` files so + * a base revision can be read with `git show` and no checkout. + * + * @param {string} text + * @returns {Set} + */ +export function readDeclaredNames(text) { + const lines = text.split('\n'); + const start = lines.indexOf('importers:'); + const names = new Set(); + if (start === -1) return names; + let runtime = false; + for (let i = start + 1; i < lines.length; i++) { + if (/^[^\s#]/.test(lines[i])) break; // next top-level section + const block = lines[i].match(/^ {4}([A-Za-z]+):\s*$/); + if (block) { + runtime = RUNTIME_BLOCKS.has(block[1]); + continue; + } + if (!runtime) continue; + const m = lines[i].match(/^ {6}('?)([^\s'].*?)\1:\s*$/); + if (m && /^ {8}specifier:/.test(lines[i + 1] ?? '')) names.add(m[2]); + } + return names; +} + +/** + * @param {string} text + * @param {string} label + * @returns {{ label: string, keys: string[], declared: Set, copies: Map, versions: Map }} + */ +export function indexLockfile(text, label) { + const keys = readSnapshotKeys(text); + const declared = readDeclaredNames(text); + /** @type {Map} */ const copies = new Map(); + /** @type {Map} */ const versions = new Map(); + for (const key of keys) { + const { name, version } = parseSnapshotKey(key); + if (!copies.has(name)) copies.set(name, []); + copies.get(name).push(key); + if (version) { + if (!versions.has(name)) versions.set(name, []); + const seen = versions.get(name); + if (!seen.includes(version)) seen.push(version); + } + } + return { label, keys, declared, copies, versions }; +} + +// ── version ordering ──────────────────────────────────────────────────────── + +/** + * Compare two semver-ish versions. Numeric segments compare numerically; a + * prerelease sorts below the release it belongs to. This is an ORDERING only — + * the gate needs "is head lower than base", never range satisfaction. + * + * @param {string} a + * @param {string} b + * @returns {number} negative if a < b, 0 if equal, positive if a > b + */ +export function compareVersions(a, b) { + const split = (v) => { + const [core, pre = ''] = String(v).split('-', 2); + return { core: core.split('.').map((n) => Number.parseInt(n, 10) || 0), pre }; + }; + const x = split(a); + const y = split(b); + for (let i = 0; i < Math.max(x.core.length, y.core.length); i++) { + const d = (x.core[i] ?? 0) - (y.core[i] ?? 0); + if (d !== 0) return d; + } + if (x.pre === y.pre) return 0; + if (!x.pre) return 1; // a release outranks its own prerelease + if (!y.pre) return -1; + return x.pre < y.pre ? -1 : 1; +} + +const maxVersion = (list) => list.reduce((hi, v) => (compareVersions(v, hi) > 0 ? v : hi), list[0]); + +// ── the two rules ─────────────────────────────────────────────────────────── + +/** + * @typedef {{ rule: 'backward', name: string, base: string[], head: string[], baseline: string, regressions: string[] }} BackwardFinding + * @typedef {{ rule: 'duplicated', name: string, baseCount: number, headCount: number, base: string[], head: string[], added: string[] }} DuplicationFinding + */ + +/** + * @param {ReturnType} base + * @param {ReturnType} head + * @returns {{ findings: Array, backward: BackwardFinding[], duplicated: DuplicationFinding[] }} + */ +export function compareLockfiles(base, head) { + /** Rule 1 — an `@objectstack/*` identity moved backward. @type {BackwardFinding[]} */ + const backward = []; + for (const [name, headVersions] of head.versions) { + if (!name.startsWith(FIRST_PARTY_SCOPE)) continue; + const baseVersions = base.versions.get(name); + if (!baseVersions?.length) continue; + const baseline = maxVersion(baseVersions); + // ⚠️ `!baseVersions.includes(v)` is load-bearing, not defensive. Without it an + // ALREADY-split base (17.2.0 + 17.3.0, unchanged by this pull request) reports its + // own 17.2.0 as a regression on every PR after it — the gate would red on changes + // that touched nothing. This is a delta gate: only a version the base did not + // resolve can have been introduced here. + const regressions = headVersions.filter( + (v) => compareVersions(v, baseline) < 0 && !baseVersions.includes(v), + ); + if (regressions.length) { + backward.push({ + rule: 'backward', + name, + base: baseVersions.slice().sort(compareVersions), + head: headVersions.slice().sort(compareVersions), + baseline, + regressions: regressions.sort(compareVersions), + }); + } + } + + /** + * Rule 2 — a package this repository declares gained a physical copy. + * + * A name absent from the base has no baseline to have "gained" against, so it + * is judged only when it ARRIVES already multiplied (> 1 copy at once) — + * otherwise every newly declared dependency is a finding. Measured: without + * that carve-out, `639114c4d` reds because `@objectstack/types` goes 0 -> 1. + */ + const inScope = new Set([...base.declared, ...head.declared]); + /** @type {DuplicationFinding[]} */ + const duplicated = []; + for (const [name, headKeys] of head.copies) { + if (!inScope.has(name) && !name.startsWith(FIRST_PARTY_SCOPE)) continue; + const baseKeys = base.copies.get(name) ?? []; + if (headKeys.length <= baseKeys.length) continue; + if (baseKeys.length === 0 && headKeys.length <= 1) continue; + duplicated.push({ + rule: 'duplicated', + name, + baseCount: baseKeys.length, + headCount: headKeys.length, + base: baseKeys.slice().sort(), + head: headKeys.slice().sort(), + added: headKeys.filter((k) => !baseKeys.includes(k)).sort(), + }); + } + + backward.sort((a, b) => a.name.localeCompare(b.name)); + duplicated.sort((a, b) => a.name.localeCompare(b.name)); + return { findings: [...backward, ...duplicated], backward, duplicated }; +} + +// ── reporting ─────────────────────────────────────────────────────────────── + +/** + * The verdict line, plus the named packages. One line is the point of the gate: + * the incident cost two seats a day each because the red they got named the + * wrong subject. + * + * @param {{ backward: BackwardFinding[], duplicated: DuplicationFinding[] }} reading + * @param {{ base: string, head: string, baseKeys: number, headKeys: number }} sources + * @returns {string} + */ +export function renderReading(reading, sources) { + const lines = []; + const total = reading.backward.length + reading.duplicated.length; + lines.push( + `base: ${sources.base} (${sources.baseKeys} resolutions) head: ${sources.head} (${sources.headKeys} resolutions)`, + ); + + for (const f of reading.backward) { + lines.push(''); + lines.push(`✗ ${f.name} moved BACKWARD: ${f.baseline} -> ${f.regressions.join(', ')}`); + lines.push(` base resolved ${f.base.join(', ')}`); + lines.push(` head resolves ${f.head.join(', ')}`); + lines.push( + ' Every workspace range on this scope is a floating `^17.x`, so no fresh resolution', + ); + lines.push( + ' can produce this. It is a stale resolution carried across a rebase (objectui#8326).', + ); + } + + for (const f of reading.duplicated) { + lines.push(''); + lines.push(`✗ ${f.name} gained a physical copy: ${f.baseCount} -> ${f.headCount}`); + for (const k of f.base) lines.push(` base ${k}`); + for (const k of f.head) lines.push(` head ${k}${f.added.includes(k) ? ' <- added' : ''}`); + } + + lines.push(''); + if (total === 0) { + lines.push( + `VERDICT clean — no @objectstack/* identity moved backward and no package gained a copy.`, + ); + } else { + lines.push( + `VERDICT ${total} lockfile-integrity finding(s): ` + + `${reading.backward.length} backward move(s), ${reading.duplicated.length} duplication(s).`, + ); + lines.push( + 'Two physical copies of one package are two real paths, and nothing dedupes those — a', + ); + lines.push( + 'bundle-size red downstream of this is caused by THIS, not by the dependency being bumped.', + ); + lines.push( + '⛔ This gate reports only. Re-locking from a clean base, pinning, or accepting the split', + ); + lines.push('is a decision for the pull request, not for this check.'); + } + return lines.join('\n'); +} + +// ── sources ───────────────────────────────────────────────────────────────── + +/** + * @param {string[]} args + * @param {string} flag + * @returns {string|null} + */ +function flagValue(args, flag) { + const i = args.indexOf(flag); + return i === -1 ? null : (args[i + 1] ?? null); +} + +/** + * Read a lockfile from the worktree or from a git revision (`:`). + * + * @param {string} spec + * @returns {string} + */ +function readSource(spec) { + if (spec.includes(':') && !fs.existsSync(spec)) { + const [rev, file] = [spec.slice(0, spec.indexOf(':')), spec.slice(spec.indexOf(':') + 1)]; + const out = spawnSync('git', ['show', `${rev}:${file}`], { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + if (out.status !== 0) { + throw new Error(`git show ${rev}:${file} failed: ${(out.stderr || '').trim()}`); + } + return out.stdout; + } + return fs.readFileSync(spec, 'utf8'); +} + +/** + * @param {string[]} argv + * @returns {{ base: string, head: string }} + */ +export function resolveSources(argv) { + const head = flagValue(argv, '--head') ?? path.join(REPO_ROOT, LOCKFILE); + const explicitBase = flagValue(argv, '--base'); + if (explicitBase) return { base: explicitBase, head }; + + const baseRef = flagValue(argv, '--base-ref') ?? 'origin/main'; + const mergeBase = spawnSync('git', ['merge-base', baseRef, 'HEAD'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }); + const rev = mergeBase.status === 0 ? mergeBase.stdout.trim() : null; + if (!rev) { + throw new Error( + `could not find a merge base with ${baseRef}: ${(mergeBase.stderr || '').trim()}\n` + + 'A shallow checkout has no merge base — fetch the base branch with enough depth, ' + + 'or pass --base .', + ); + } + return { base: `${rev}:${LOCKFILE}`, head }; +} + +/** + * @param {string[]} argv + */ +export function main(argv = process.argv.slice(2)) { + const { base, head } = resolveSources(argv); + const baseIndex = indexLockfile(readSource(base), base); + const headIndex = indexLockfile(readSource(head), head); + + // Anti-vacuity: an empty snapshot set means the format moved, not that the + // change is clean. Reported as "cannot run", never as a pass. + for (const idx of [baseIndex, headIndex]) { + if (idx.keys.length === 0) { + throw new Error( + `no \`snapshots:\` entries parsed out of ${idx.label}. Every comparison below would ` + + 'run against an empty set and pass whatever the change did. The lockfile format ' + + 'moved, or the path is not a pnpm lockfile — fix readSnapshotKeys(), do not ' + + 'read this as a clean lockfile.', + ); + } + } + + const reading = compareLockfiles(baseIndex, headIndex); + const sources = { + base, + head, + baseKeys: baseIndex.keys.length, + headKeys: headIndex.keys.length, + }; + + if (argv.includes('--json')) { + console.log(JSON.stringify({ sources, ...reading }, null, 2)); + } else { + console.log(renderReading(reading, sources)); + } + if (reading.findings.length) { + console.error( + `::error title=Lockfile integrity::${[...new Set(reading.findings.map((f) => f.name))].join(', ')} — ` + + 'see the run log; this is a lockfile regression, not a bundle regression (objectui#8326).', + ); + } + return reading.findings.length ? EXIT_FINDINGS : EXIT_CLEAN; +} + +// ── self-test ─────────────────────────────────────────────────────────────── + +const lock = (snapshots, declared = ['zod', '@objectstack/spec', '@objectstack/client', 'lucide-react', 'better-auth']) => + `lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n` + + declared.map((d) => ` ${/[@/]/.test(d) ? `'${d}'` : d}:\n specifier: '*'\n version: 0.0.0\n`).join('') + + `\nsnapshots:\n${snapshots.map((s2) => ` ${/[@(]/.test(s2) ? `'${s2}'` : s2}: {}`).join('\n')}\n`; + +export function selfTest() { + const cases = []; + const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail }); + const run = (baseKeys, headKeys) => + compareLockfiles(indexLockfile(lock(baseKeys), 'base'), indexLockfile(lock(headKeys), 'head')); + + // ── the parser ──────────────────────────────────────────────────────────── + const parsed = parseSnapshotKey('@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))'); + t('scoped key splits at the version', parsed.name === '@objectstack/spec' && parsed.version === '17.3.0'); + t('the peer suffix survives', parsed.peers === '(ai@7.0.65(zod@4.4.3))'); + t('unscoped key splits', parseSnapshotKey('zod@4.4.3').name === 'zod'); + t('a non-numeric version is not ordered', parseSnapshotKey('pkg@github:o/r#abc').version === null); + t('sub-keys are not read as snapshots', readSnapshotKeys("snapshots:\n zod@4.4.3:\n dependencies:\n foo: 1.0.0\n").join() === 'zod@4.4.3'); + t('the section ends at the next top-level key', readSnapshotKeys("snapshots:\n zod@4.4.3: {}\nother:\n nope@1.0.0: {}\n").join() === 'zod@4.4.3'); + + // ── ordering ────────────────────────────────────────────────────────────── + t('17.2.0 < 17.3.0', compareVersions('17.2.0', '17.3.0') < 0); + t('17.10.0 > 17.9.0 (numeric, not lexical)', compareVersions('17.10.0', '17.9.0') > 0); + t('a prerelease sorts below its release', compareVersions('17.3.0-rc.1', '17.3.0') < 0); + + // ── cause 1: objectui#8326's downgrade ──────────────────────────────────── + const a = run( + ['@objectstack/spec@17.3.0', '@objectstack/client@17.3.0'], + ['@objectstack/spec@17.2.0', '@objectstack/spec@17.3.0', '@objectstack/client@17.2.0'], + ); + t('cause 1 — the backward move is named', a.backward.some((f) => f.name === '@objectstack/client' && f.regressions.includes('17.2.0'))); + t('cause 1 — the duplication is named too', a.duplicated.some((f) => f.name === '@objectstack/spec' && f.headCount === 2)); + + // ── cause 2: objectui#8333, no @objectstack/* movement at all ───────────── + const b = run( + ['zod@3.25.76', 'zod@4.4.3', '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))', 'better-auth@1.7.2'], + [ + 'zod@3.25.76', + 'zod@4.4.3', + 'zod@4.5.4', + '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))', + '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.5.4))', + 'better-auth@1.7.3', + ], + ); + t('cause 2 — no @objectstack/* identity moved (rule 1 is blind here)', b.backward.length === 0); + t('cause 2 — zod 2 -> 3 is a finding, though it was never single-copy', b.duplicated.some((f) => f.name === 'zod' && f.baseCount === 2 && f.headCount === 3)); + t('cause 2 — the peer-forked spec is a finding at one version', b.duplicated.some((f) => f.name === '@objectstack/spec' && f.headCount === 2)); + t('cause 2 — the gate is red on it', b.findings.length > 0); + + // ── the control: a clean forward bump ───────────────────────────────────── + const c = run( + ['lucide-react@1.35.0', 'zod@3.25.76', 'zod@4.4.3', '@objectstack/spec@17.3.0'], + ['lucide-react@1.41.0', 'zod@3.25.76', 'zod@4.4.3', '@objectstack/spec@17.3.0'], + ); + t('control — a forward third-party bump is clean', c.findings.length === 0); + const d = run(['left-pad@1.0.0'], ['left-pad@0.9.0']); + t('control — a third-party DOWNGRADE is not judged (stated boundary)', d.findings.length === 0); + const e = run(['@objectstack/spec@17.2.0', '@objectstack/spec@17.3.0'], ['@objectstack/spec@17.2.0', '@objectstack/spec@17.3.0']); + t('control — an already-duplicated base stays clean when unchanged (ratchet, not audit)', e.findings.length === 0); + const f = run(['@objectstack/spec@17.2.0', '@objectstack/spec@17.3.0'], ['@objectstack/spec@17.3.0']); + t('control — repairing a duplication is clean', f.findings.length === 0); + + // ── the scope of rule 2 is what the workspace declares ──────────────────── + t('importers are read as the declared set', readDeclaredNames(lock([], ['zod'])).has('zod')); + t('a peerDependencies line in packages: is not a declaration', !readDeclaredNames("importers:\n\n .:\n dependencies:\n zod:\n specifier: '*'\n version: 1\npackages:\n foo@1.0.0:\n peerDependencies:\n react: '*'\n").has('react')); + t('devDependencies are out of scope (measured: vite/vitest on 90bc5d16a)', !readDeclaredNames("importers:\n\n .:\n devDependencies:\n vitest:\n specifier: '*'\n version: 1\n").has('vitest')); + t('peerDependencies of an importer ARE in scope', readDeclaredNames("importers:\n\n .:\n peerDependencies:\n react:\n specifier: '*'\n version: 1\n").has('react')); + const g = run(['tinyexec@1.2.4', 'tinyexec@1.3.0'], ['tinyexec@1.2.4', 'tinyexec@1.3.0', 'tinyexec@1.3.1']); + t('scope — an undeclared transitive package gaining a copy is not judged (measured: 5505aec1a)', g.findings.length === 0); + const h = run(['@objectstack/client@17.3.0'], ['@objectstack/client@17.3.0', '@objectstack/types@17.3.0']); + t('scope — a package ARRIVING at one copy is not a duplication (measured: 639114c4d)', h.findings.length === 0); + const i = run(['@objectstack/client@17.3.0'], ['@objectstack/client@17.3.0', 'zod@4.4.3', 'zod@4.5.4']); + t('scope — a declared package ARRIVING already multiplied is judged', i.duplicated.some((f2) => f2.name === 'zod' && f2.headCount === 2)); + + // ── the verdict line renders what it found ──────────────────────────────── + const sources = { base: 'b', head: 'h', baseKeys: 1, headKeys: 1 }; + t('a clean reading says clean', renderReading(c, sources).includes('VERDICT clean')); + t('a finding names the package in the report', renderReading(b, sources).includes('zod gained a physical copy: 2 -> 3')); + t('a finding names the backward move', renderReading(a, sources).includes('moved BACKWARD')); + + const failed = cases.filter((c2) => !c2.ok); + for (const c2 of failed) console.error(` ✗ ${c2.name}${c2.detail ? ` — ${c2.detail}` : ''}`); + if (failed.length) { + console.error(`✗ check-lockfile-integrity self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log( + `✓ check-lockfile-integrity self-test: ${cases.length} cases pass ` + + "(both incident causes, the peer-fork that moves no version, and the controls a red-on-everything gate would fail).", + ); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + process.exitCode = selfTest(); + } else { + try { + process.exitCode = main(); + } catch (error) { + console.error( + `::error::check-lockfile-integrity could not take a reading: ${error instanceof Error ? error.message : String(error)}`, + ); + console.error( + 'A reading that could not be taken is NOT a clean lockfile. Nothing about this change was judged by this run.', + ); + process.exitCode = EXIT_CANNOT_RUN; + } + } +} diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 6ecbb4316c..52a7f5294b 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -238,6 +238,8 @@ export const NOT_A_GATE = Object.freeze({ 'labeler.yml applies labels. It is a mutation, not a verdict — nothing about the change is judged by it.', 'Changeset Overwrite Report': "changeset-guard.yml's second job is REPORT-ONLY by measurement (objectui#6336): it names any `.changeset/*.md` the change modified or deleted without having added it, and exits 0 whatever it finds — all 19 such modifications in this repository's history were legitimate, so blocking would have failed every one of those pull requests. It goes red only when it cannot compute its diff, which is a fact about the checkout rather than a verdict on the change. Its pull_request trigger is also path-filtered to `.changeset/**` and the two gate scripts, so a Dependabot bump never produces this check at all.", + 'Lockfile Integrity Check': + "lockfile-integrity.yml (objectui#8326) reports a lockfile DELTA — an `@objectstack/*` identity moving backward, or a workspace-declared dependency gaining a physical copy. ⛔ It is deliberately NOT a blocking context: enrolling it changes what stops the merge queue, which is a maintainer decision the #8326 dispatch reserved rather than took, and its pull request writes up the cost as input (measured: it would have blocked 3 of the 40 most recent lockfile-changing commits on `main`, each for a real duplication of a runtime-declared package). Its pull_request trigger is also path-filtered to `pnpm-lock.yaml` and its own two files, so it cannot be REQUIRED under #3523's rule while that filter stands — promoting it means removing the filter as well.", 'Live half-state sweep': 'half-state-patrol.yml is REPORT-ONLY by ruling (objectui#5791): a completed sweep exits 0 whether it found 0 half-states or 40, and the job gates no branch and blocks no queue. It goes red only when the sweep could not RUN — the patrol reporting its own death, which is a fact about the patrol, not a verdict on the pull request. Its pull_request trigger is also path-filtered to the sweeper and the workflow, so a Dependabot bump never produces this check at all.', });