diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aec5024d936..4ba9c3b5f39 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3384,6 +3384,32 @@ jobs:
- name: Verify type-check coverage
run: pnpm --dir ui run type-check:coverage
+ # objectui's `lockfile-integrity.yml` (objectui#8326), row 19 of the
+ # objectstack#25697 workflow roster, ported by objectstack#26717: a pull
+ # request's `pnpm-lock.yaml` may not give any package a second physical
+ # copy. The script's header has the mechanism and its boundary.
+ #
+ # It lives here because this job already checks out with
+ # `fetch-depth: 0`, so `origin/` and the merge base are in the
+ # clone, and because it runs on every change to `pnpm-lock.yaml` (the
+ # `ui` filter lists it). Before the install on purpose: the gate reads
+ # two text files with Node builtins only, which Lint UI's pre-install
+ # import graph check holds. `pull_request` only, because the gate's
+ # subject is the pull request's delta against its base; a push or queue
+ # run has none of its own.
+ #
+ # ⛔ `continue-on-error` keeps it an ALARM, as objectui ran it: a finding
+ # prints an `::error::` annotation naming the packages on the pull
+ # request and does NOT fail this required job. Making it blocking is a
+ # maintainer decision (`NOT_A_GATE` in `ui/scripts/dependabot-merge-gate.mjs`
+ # says why), not a step edit.
+ - name: check:lockfile-integrity — compare this pull request's lockfile with its merge base
+ if: github.event_name == 'pull_request'
+ continue-on-error: true
+ env:
+ BASE_REF: ${{ github.base_ref }}
+ run: pnpm --dir ui run check:lockfile-integrity --base-ref "origin/${BASE_REF}"
+
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/ui/package.json b/ui/package.json
index 516d0a69584..82c263f0c68 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -84,7 +84,6 @@
"check:merge-queue-head": "node scripts/check-merge-queue-head.mjs --self-test",
"check:required-check-set": "node scripts/check-required-check-set.mjs --self-test",
"check:lockfile-integrity": "node scripts/check-lockfile-integrity.mjs",
- "check:lockfile-dedupe": "node scripts/check-lockfile-dedupe.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:test-path-roots": "node scripts/check-test-path-roots.mjs",
diff --git a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts b/ui/scripts/__tests__/check-lockfile-dedupe.test.ts
deleted file mode 100644
index f46988fe977..00000000000
--- a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts
+++ /dev/null
@@ -1,600 +0,0 @@
-/**
- * objectui#8333 — the lockfile-dedupe gate, pinned against the thing it exists
- * to prevent and against the ways it could be worthless.
- *
- * ## ⛔ Nothing in this file may take a LIVE reading (objectui#9562)
- *
- * This file runs in the `unit` vitest project, which `ci.yml` shards four ways
- * as `Test (shard N/4)` — and all four shards are REQUIRED contexts in
- * `scripts/dependabot-merge-gate.mjs`. A required context may only carry
- * readings that are a function of the repository's own bytes.
- *
- * `scripts/check-lockfile-dedupe.mjs` is not such a reading and never was: it
- * shells out to `pnpm dedupe --check`, which resolves against
- * `registry.npmjs.org`. Its own header says so, and `lockfile-dedupe.yml` is
- * built around it — that workflow is PATH-FILTERED precisely so the live
- * reading reaches only the pull requests that can change its subject, which
- * under objectui#3523's rule is also what keeps it out of the required set.
- *
- * Until objectui#9562 this file ran that same live command TWICE on every pull
- * request, inside a required context, bypassing the filter that was put there
- * to bound it. Measured on objectui#9562, holding the lockfile blob fixed
- * (sha256 identical before and after every leg) and varying only what sits
- * outside the repository:
- *
- * - warm metadata cache, cold cache, and both half-populated states
- * (pnpm keeps abbreviated and full packuments in SEPARATE caches):
- * exit 0, `VERDICT deduped`, four for four;
- * - the same bytes with the registry unreachable: exit 2,
- * `VERDICT could not take a reading` — which the checker documents as
- * "never a pass", so the required context reds.
- *
- * ⇒ byte-identical repository input, two different verdicts, decided by a term
- * the repository does not contain. ⚠️ That reproduces the CLASS objectui#9562
- * reported, ⛔ not the specific red it recorded (that one printed
- * `VERDICT not deduped`, and its mechanism is still unidentified — ⛔ do not
- * adopt one here).
- *
- * So the live reading stays where this repository already put it — the
- * path-filtered `Lockfile Dedupe Check` context — and this file drives the
- * shipped script through a STUBBED `pnpm` instead.
- *
- * ⚠️ That context is no longer BLOCKING on its verdict. objectui#9562's ruling
- * (letter A, comment 5717182406) made it REPORT on pull requests: the workflow
- * passes `--report-only`, a `not deduped` verdict becomes a `::warning::` plus a
- * step summary, and the step exits 0. The bare script keeps its 0/1/2 exit
- * codes. Both halves are pinned below, and ⛔ the relaxation is scoped to the
- * VERDICT: a job that fails for any other reason still reds this context.
- * ⛔ Do not reintroduce a bare spawn of the checker here: every run below
- * asserts the stub served it (`pnpmArgv` is written by the stub and by nothing
- * else), so a spawn that reached the real pnpm fails rather than going quiet.
- *
- * ## ⛔ And neither may anything ELSE that a required job runs (objectui#9693)
- *
- * The assertion below that pins the live reading to ONE place used to be
- * measured over the workflow files and nothing else. Its own comment stated the
- * intent as "if a required job ever grows a `pnpm dedupe` of its own" — but the
- * way a required job actually grew one, the time it happened, was through THIS
- * FILE, and a test file is not a workflow. ⇒ the lock named after objectui#9562
- * could not have seen objectui#9562 arrive: another test file spawning the
- * checker, or running `pnpm dedupe` itself, was green.
- *
- * So the population is now every place in what CI runs that this repository can
- * enumerate, in ONE assertion — ⛔ never a second lock per class of file, which
- * would only move the blind spot to the next class:
- *
- * - every workflow, comment LINES stripped (prose may not satisfy wiring);
- * - every tracked test file, plus the `vitest.config` / `vitest.setup`
- * modules loaded around them, with COMMENTS BLANKED through
- * `scripts/js-comment-mask.mjs` — the tree's one answer to "comment, or
- * code?". The paragraph you are reading names both hazardous spellings, so
- * without the mask this assertion could never be green.
- *
- * The exemption list is two entries and each is checkable: the path-filtered
- * workflow, and this file, whose every run asserts the stub served it.
- *
- * ⚠️ What the scan still does NOT see — said here because nothing re-derives it:
- *
- * - a non-test SCRIPT that a required job runs, which shells out to `pnpm
- * dedupe` itself. Its callers carry no marker, and the tree's own
- * classification strings quote the command in prose that no mask blanks, so
- * that population needs a way to tell a command from a citation before it
- * can be added;
- * - an invocation assembled at runtime (`path.join(root, 'scripts', …)`)
- * rather than written as the path;
- * - a file that is not tracked, since the walk is `git ls-files`. CI only
- * ever runs tracked bytes, so this bites locally — before `git add` — and
- * not in the context the lock protects.
- *
- * ## What this file covers that the checker's `--self-test` cannot
- *
- * 1. the self-test really passes, run as shipped rather than re-implemented;
- * 2. `main()` end to end — the exit-code mapping, the annotations, and the
- * `dedupe --check` argv that is WHY the checker cannot rewrite the
- * lockfile it judges. ⭐ The live legs this replaced only ever exercised
- * the GREEN path; the red and could-not-run paths through `main()` had no
- * coverage at all;
- * 3. it is WIRED — a workflow runs it, that workflow is path-filtered (so it
- * cannot be REQUIRED under objectui#3523's rule), and the Dependabot merge
- * gate classifies the check it produces as BLOCKING.
- *
- * ⚠️ (3) is the half that rots silently. The partition test in
- * `dependabot-merge-gate.test.ts` already fails if the name is classified
- * NOWHERE — but it passes just as happily if a future change moves the name
- * into `NOT_A_GATE`, which would turn this gate into an alarm without anything
- * going red. That demotion is a real option and the workflow header says how to
- * take it deliberately; this file is what makes it deliberate rather than
- * incidental.
- */
-import { execFileSync, spawnSync } from 'node:child_process';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-import { describe, expect, it } from 'vitest';
-
-import { DEDUPE_SENTINEL } from '../check-lockfile-dedupe.mjs';
-import { FLOORS, TEST_FILE } from '../check-test-path-roots.mjs';
-import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs';
-import { maskComments } from '../js-comment-mask.mjs';
-import { rootWorkflow, rootWorkflowFiles, rootWorkflowsDir, withoutComments } from './helpers/root-ci';
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
-
-const SCRIPT = 'scripts/check-lockfile-dedupe.mjs';
-const CONTEXT = 'Lockfile Dedupe Check';
-
-/** This file, by its own path — a rename may not silently drop what it pins. */
-const THIS_FILE = path.relative(repoRoot, fileURLToPath(import.meta.url));
-
-/**
- * The spellings that TAKE the live reading, as opposed to naming it.
- *
- * ⛔ Not the word `dedupe` on its own: this repository spends that word on UI
- * dedupe keys in hundreds of files, and a marker that fires on those is one
- * nobody can keep green.
- */
-const LIVE_READING = [
- /** The shipped checker, run by any means: a `run:` step, a spawn argument, a package script body. */
- new RegExp(SCRIPT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
- /** pnpm's own dedupe — shell form (`pnpm dedupe --check`) and argv form (`'pnpm', ['dedupe'`) together. */
- /\bpnpm\b[\s'",[\]()]{1,12}\bdedupe\b/,
- /** The package script that is a second name for the first marker. */
- /\bcheck:lockfile-dedupe\b/,
-];
-
-/** Does this CODE take the live reading? Blanking the comments is the caller's job. */
-function takesLiveReading(code: string): boolean {
- return LIVE_READING.some((marker) => marker.test(code));
-}
-
-/**
- * The modules vitest loads AROUND every test file. A live reading here would
- * run in every file of the project rather than in one, so they are scanned on
- * the same terms as the tests themselves.
- */
-const VITEST_RUNTIME = /^vitest\.(config|setup)[.\w-]*\.(ts|tsx|mts|js|mjs)$/;
-
-/** The same `git ls-files -z` walk `check-test-path-roots.mjs` takes over this same population. */
-function trackedFiles(): string[] {
- return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 })
- .toString('utf8')
- .split('\0')
- .filter(Boolean);
-}
-
-interface LiveScan {
- /** Repo-relative paths that take the live reading, sorted. */
- sites: string[];
- /** How many workflows were read — a floor under the equality, so a collapsed read cannot pass. */
- workflows: number;
- /** The source files that were read, so the same floor covers the other half. */
- population: string[];
-}
-
-/**
- * Every place in what CI RUNS that takes the live reading.
- *
- * Two carriers, one population (objectui#9693): the workflows, which say what a
- * job runs, and the files those jobs execute — every tracked test file plus the
- * vitest runtime modules. The workflows are the root `.github/workflows/`, the
- * only ones GitHub runs under the monorepo (objectstack#25712), read with their
- * comment LINES stripped; `ui/.github/workflows/` runs nowhere, so a live
- * reading there is no reading at all. Source comments are blanked by
- * `scripts/js-comment-mask.mjs`, the tree's one answer to "comment, or code?",
- * so the paragraphs in this very file explaining the hazard cannot count as the
- * hazard.
- */
-function scanForLiveReading(): LiveScan {
- const workflows = rootWorkflowFiles();
- const sites = workflows
- .filter((file) => takesLiveReading(withoutComments(rootWorkflow(file))))
- .map((file) => path.relative(repoRoot, path.join(rootWorkflowsDir, file)));
-
- const population = trackedFiles().filter((file) => TEST_FILE.test(file) || VITEST_RUNTIME.test(file));
- for (const file of population) {
- let text: string;
- try {
- text = fs.readFileSync(path.join(repoRoot, file), 'utf8');
- } catch {
- continue; // a tracked-but-absent path (a stale index) is not this assertion's business
- }
- // A pre-filter and ONLY a pre-filter: masking blanks spans, so it can remove
- // a hit and never add one — a file with no raw hit cannot have a masked one.
- if (!takesLiveReading(text)) continue;
- if (takesLiveReading(maskComments(text))) sites.push(file);
- }
-
- return { sites: sites.sort(), workflows: workflows.length, population };
-}
-
-/**
- * The only places a live reading may be taken, and why each may:
- *
- * - this file, which drives the checker ONLY through the `PATH` stub above —
- * every run asserts `pnpmArgv`, so a leg that reached the real pnpm reds
- * rather than going quiet, which is what makes this exemption checkable
- * instead of trusted.
- *
- * The second entry this list carried, objectui's path-filtered
- * `lockfile-dedupe.yml`, left with objectstack#25712: it sits under
- * `ui/.github/workflows/`, which runs nowhere. It is row 18 of the
- * objectstack#25697 workflow roster, "kept, follow-up port", and the port adds
- * its root workflow back here. Until then the equality below has no workflow
- * member, so its workflow half rests on the read floor alone.
- *
- * ⛔ Nothing else belongs here. An addition is a live, registry-dependent
- * reading inside a REQUIRED context — objectui#9562, again.
- */
-const LIVE_READING_SITES = [THIS_FILE];
-
-/**
- * Real `pnpm@10.31.0` output, same capture objectui#8333 took and the checker's
- * `--self-test` carries: a tree where `better-auth` has forked `zod`.
- */
-const PNPM_RED = [
- '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.6.4))',
- '└── zod 4.4.3 → 4.6.4',
- '',
- '+ zod-validation-error@4.0.2(zod@4.6.4)',
- '- @ai-sdk/gateway@4.0.52(zod@4.4.3)',
- '- @objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))',
- '- ai@7.0.65(zod@4.4.3)',
- '- zod@4.4.3',
- '',
- DEDUPE_SENTINEL,
- '',
-].join('\n');
-
-/** Real `pnpm@10.31.0` output on a deduped tree. */
-const PNPM_GREEN = ' WARN 7 deprecated subdependencies found: glob@7.2.3\nProgress: resolved 1767, done\n';
-
-/**
- * Real `pnpm@10.31.0` output captured on objectui#9562 by pointing the registry
- * at a closed port while the lockfile stayed byte-identical. ⭐ This is the
- * shape that used to red a REQUIRED context for a reason that was not about the
- * pull request — it exits non-zero and carries NO remedy sentinel.
- */
-const PNPM_REGISTRY_DOWN = [
- ' WARN GET http://127.0.0.1:9/@dnd-kit%2Fcore error (ECONNREFUSED). Will retry in 10 seconds. 2 retries left.',
- ' ERR_PNPM_META_FETCH_FAIL GET http://127.0.0.1:9/@dnd-kit%2Fcore: request to',
- ' http://127.0.0.1:9/@dnd-kit%2Fcore failed, reason: connect ECONNREFUSED 127.0.0.1:9',
- '',
-].join('\n');
-
-interface CheckerRun {
- status: number | null;
- stdout: string;
- stderr: string;
- /** Written by the stub and by nothing else — absent means the real pnpm ran. */
- pnpmArgv: string | null;
- /**
- * What the run appended to `$GITHUB_STEP_SUMMARY`, which is pointed at a file
- * of this run's own for EVERY leg — including the legs that must write
- * nothing. ⚠️ Without that, an empty summary would be indistinguishable from
- * an environment variable that never arrived; the legs that DO write are the
- * positive control for the legs that must not.
- */
- stepSummary: string;
-}
-
-/**
- * Run the checker AS SHIPPED with `pnpm` stubbed on `PATH`.
- *
- * ⛔ The only way this file may invoke the checker. The stub records its argv,
- * and every caller asserts that recording exists — that is the control which
- * makes "no live reading" a measurement rather than a promise in a comment.
- */
-function runChecker(stub: { stdout?: string; stderr?: string; exit: number }, args: string[] = []): CheckerRun {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lockfile-dedupe-stub-'));
- try {
- const bin = path.join(dir, 'bin');
- fs.mkdirSync(bin);
- const argvFile = path.join(dir, 'pnpm-argv');
- const stdoutFile = path.join(dir, 'pnpm-stdout');
- const stderrFile = path.join(dir, 'pnpm-stderr');
- fs.writeFileSync(stdoutFile, stub.stdout ?? '');
- fs.writeFileSync(stderrFile, stub.stderr ?? '');
- const pnpm = path.join(bin, 'pnpm');
- fs.writeFileSync(
- pnpm,
- [
- '#!/usr/bin/env bash',
- `printf '%s' "$*" > ${JSON.stringify(argvFile)}`,
- `cat ${JSON.stringify(stdoutFile)}`,
- `cat ${JSON.stringify(stderrFile)} >&2`,
- `exit ${stub.exit}`,
- '',
- ].join('\n'),
- );
- fs.chmodSync(pnpm, 0o755);
-
- const summaryFile = path.join(dir, 'step-summary.md');
- fs.writeFileSync(summaryFile, '');
-
- const proc = spawnSync('node', [SCRIPT, ...args], {
- cwd: repoRoot,
- encoding: 'utf8',
- env: {
- ...process.env,
- PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}`,
- // ⛔ Overridden rather than inherited: under CI this variable is already
- // set, and a checker that appended there would both pollute the real run
- // summary and make the assertions below read the wrong file.
- GITHUB_STEP_SUMMARY: summaryFile,
- },
- });
- return {
- status: proc.status,
- stdout: proc.stdout,
- stderr: proc.stderr,
- pnpmArgv: fs.existsSync(argvFile) ? fs.readFileSync(argvFile, 'utf8') : null,
- stepSummary: fs.readFileSync(summaryFile, 'utf8'),
- };
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
-}
-
-describe('the lockfile-dedupe checker', () => {
- it('ships its own self-test, and it passes as shipped', () => {
- const run = spawnSync('node', [SCRIPT, '--self-test'], { cwd: repoRoot, encoding: 'utf8' });
- expect(run.status, `${SCRIPT} --self-test failed:\n${run.stdout}\n${run.stderr}`).toBe(0);
- expect(run.stdout).toContain('self-test:');
- // Anti-vacuity: a self-test that asserted nothing would also exit 0.
- expect(run.stdout).toMatch(/\d+ cases pass/);
- });
-
- it('asks pnpm for `dedupe --check`, which is WHY it cannot rewrite the lockfile', () => {
- // This replaces a live leg that re-resolved the whole workspace and then
- // compared the lockfile's bytes. That leg could only catch a dropped
- // `--check` if the run happened to rewrite something; this catches it
- // always, and in milliseconds. `--check` is pnpm's own contract for
- // "report without installing packages or editing the lockfile".
- const run = runChecker({ stdout: PNPM_GREEN, exit: 0 });
- expect(run.pnpmArgv, 'the stub did not serve this run — a live pnpm reached the registry').toBe('dedupe --check');
- });
-
- it('reports a deduped tree as clean, on stdout, exit 0', () => {
- const run = runChecker({ stdout: PNPM_GREEN, exit: 0 });
- expect(run.pnpmArgv).toBe('dedupe --check');
- expect(run.status).toBe(0);
- expect(run.stdout).toContain('VERDICT deduped');
- });
-
- it('reports a collapsible tree as a finding, names the packages, exit 1', () => {
- const run = runChecker({ stdout: PNPM_RED, exit: 1 });
- expect(run.pnpmArgv).toBe('dedupe --check');
- expect(run.status).toBe(1);
- expect(run.stderr).toContain('VERDICT not deduped');
- expect(run.stderr).toContain('zod');
- expect(run.stderr).toContain('@objectstack/spec');
- // The annotation is what a reviewer sees on the Files tab.
- expect(run.stderr).toContain('::error title=Lockfile dedupe::');
- // A finding is never written to stdout, where a `VERDICT deduped` grep would
- // be looking for it.
- expect(run.stdout).not.toContain('VERDICT');
- });
-
- it('⭐ a registry it cannot reach is NOT a finding and NOT a pass — exit 2', () => {
- // The leg objectui#9562 turns on. Before it, this same condition arrived in
- // a REQUIRED context as a bare `expect(status).toBe(0)` failure, telling the
- // reader to run `pnpm dedupe` and commit the lockfile — advice that is
- // wrong here and, because the lockfile is shared by every open pull
- // request, advice that spreads.
- const run = runChecker({ stderr: PNPM_REGISTRY_DOWN, exit: 1 });
- expect(run.pnpmArgv).toBe('dedupe --check');
- expect(run.status).not.toBe(0);
- expect(run.status, 'a crash must not be reported as a lockfile finding').not.toBe(1);
- expect(run.status).toBe(2);
- expect(run.stderr).toContain('VERDICT could not take a reading');
- expect(run.stderr).toContain('Nothing here was judged');
- // ⛔ It must not print the remedy: there is no finding to remedy.
- expect(run.stderr).not.toContain('VERDICT not deduped');
- });
-
- it('treats a signal-killed pnpm as could-not-run, never as clean', () => {
- // `status === null` is unreachable through a shell stub, so this pins the
- // next-worst shape the checker must not read as a pass: a non-zero exit
- // with no output at all.
- const run = runChecker({ exit: 137 });
- expect(run.pnpmArgv).toBe('dedupe --check');
- expect(run.status).toBe(2);
- expect(run.stderr).toContain('VERDICT could not take a reading');
- });
-
- it('⛔ the instruction to commit a dedupe is gone from the finding — in BOTH modes', () => {
- // The sentence objectui#9562 reported: a confident red that told the reader
- // to `pnpm dedupe` and commit, to a lockfile every open pull request shares.
- // The ruling removes it from the blocking mode too, so this leg is measured
- // WITHOUT the flag as well — a fix that only reached the new mode would let
- // the bad advice survive wherever the hard verdict is still read.
- for (const args of [[], ['--report-only']]) {
- const run = runChecker({ stdout: PNPM_RED, exit: 1 }, args);
- const report = `${run.stdout}${run.stderr}`;
- expect(run.pnpmArgv, `the stub did not serve the ${args.join(' ') || 'bare'} run`).toBe('dedupe --check');
- expect(report, 'the removed instruction is back').not.toContain('Fix it HERE');
- // …and what replaced it names the instrument rather than a remedy.
- expect(report).toContain('RE-RUN this check');
- expect(report).toContain('ONLY when');
- // Anti-vacuity: the leg really did produce the finding it is reading.
- expect(report).toContain('VERDICT not deduped');
- }
- });
-
- it('returns the same verdict and the same bytes for the same input', () => {
- // The property objectui#9562 is about, asserted over the half of the input
- // this repository controls. ⚠️ It says nothing about the live reading in
- // `lockfile-dedupe.yml`, which is still registry-dependent by design.
- const first = runChecker({ stdout: PNPM_RED, exit: 1 });
- const second = runChecker({ stdout: PNPM_RED, exit: 1 });
- expect(first.pnpmArgv).toBe('dedupe --check');
- expect(second.pnpmArgv).toBe('dedupe --check');
- expect(second.status).toBe(first.status);
- expect(second.stdout).toBe(first.stdout);
- expect(second.stderr).toBe(first.stderr);
- });
-});
-
-describe('--report-only — the mode objectui#9562 ruled (letter A, comment 5717182406)', () => {
- it('⭐ a not-deduped verdict warns, summarises the split, and exits 0', () => {
- const run = runChecker({ stdout: PNPM_RED, exit: 1 }, ['--report-only']);
- expect(run.pnpmArgv, 'the stub did not serve this run — a live pnpm reached the registry').toBe('dedupe --check');
-
- // The relaxation itself.
- expect(run.status, 'the ruled mode exits 0 — the verdict reports, it does not fail').toBe(0);
- expect(run.stderr).toContain('::warning title=Lockfile dedupe::');
- expect(run.stderr, 'an error annotation still fails the Files tab reading').not.toContain('::error');
-
- // ⛔ and it is a relaxation of the CONSEQUENCE, not of the reading: the same
- // verdict, naming the same split, is still printed.
- expect(run.stderr).toContain('VERDICT not deduped');
- expect(run.stderr).toContain('zod');
- expect(run.stderr).toContain('@objectstack/spec');
- // A green exit with a warning needs to say so, or it reads as a near-miss.
- expect(run.stderr).toContain('REPORT ONLY');
-
- // The step-summary block, which is the other half of "reports".
- expect(run.stepSummary, 'the ruled step summary was not written').toContain('not deduped');
- expect(run.stepSummary).toContain('zod');
- expect(run.stepSummary).toContain('@objectstack/spec');
- // It carries the instrument caveat too, from the checker's single copy.
- expect(run.stepSummary).toContain('RE-RUN this check');
- });
-
- it('⭐ leg E survives the flag: "could not look" stays distinguishable from "clean"', () => {
- // The thing this change may NOT erase. Under the flag both outcomes exit 0,
- // so the exit code no longer separates them — something else has to, and
- // these two runs are read against each other rather than in isolation.
- const down = runChecker({ stderr: PNPM_REGISTRY_DOWN, exit: 1 }, ['--report-only']);
- const clean = runChecker({ stdout: PNPM_GREEN, exit: 0 }, ['--report-only']);
- expect(down.pnpmArgv).toBe('dedupe --check');
- expect(clean.pnpmArgv).toBe('dedupe --check');
-
- expect(down.status, 'the registry being down may not fail a pull request').toBe(0);
- expect(clean.status).toBe(0);
-
- // ⇒ the discriminators, each asserted in BOTH directions.
- expect(down.stderr).toContain('VERDICT could not take a reading');
- expect(down.stderr).toContain('Nothing here was judged');
- expect(`${down.stdout}${down.stderr}`, 'a failed reading reported as a deduped tree').not.toContain(
- 'VERDICT deduped',
- );
- expect(down.stderr, 'nothing was judged, so nothing may be reported as a finding').not.toContain(
- 'VERDICT not deduped',
- );
- expect(down.stderr, 'the annotation is the only signal left once the exit code is 0').toContain(
- '::warning title=Lockfile dedupe::',
- );
- expect(down.stepSummary).toContain('could not take a reading');
-
- expect(clean.stdout).toContain('VERDICT deduped');
- expect(clean.stderr, 'a clean tree may not raise an annotation of any kind').not.toContain('::warning');
- expect(clean.stderr).not.toContain('::error');
- expect(clean.stepSummary, 'a clean tree writes no step summary — the block itself is the signal').toBe('');
- });
-
- it('CONTROL — the BARE script keeps its exit codes, which is what the flag is for', () => {
- // The ruling grants ONE explicit flag and keeps the hand-run contract. This
- // is also the control for the three legs above: without it, a checker that
- // had simply stopped failing would pass them all.
- expect(runChecker({ stdout: PNPM_RED, exit: 1 }).status, 'the bare script stopped reporting findings').toBe(1);
- expect(runChecker({ stderr: PNPM_REGISTRY_DOWN, exit: 1 }).status, 'the bare script stopped reporting 2').toBe(2);
- expect(runChecker({ stdout: PNPM_GREEN, exit: 0 }).status).toBe(0);
- // ⛔ And the mode is never inferred: an unrelated argument must not turn it on.
- const unrelated = runChecker({ stdout: PNPM_RED, exit: 1 }, ['--verbose']);
- expect(unrelated.pnpmArgv).toBe('dedupe --check');
- expect(unrelated.status, 'report-only was entered by something other than its own flag').toBe(1);
- });
-});
-
-describe('the lockfile-dedupe gate is fenced and classified the way its header claims', () => {
- // Retired with objectstack#25712: `has a workflow that runs the checker`, the
- // `--report-only` pin, `is path-filtered, and the filter lists the gate’s own
- // runtime closure`, `declares no merge_group trigger` and `uses the shared
- // pnpm setup rather than a bare corepack call`. Each read objectui's
- // `lockfile-dedupe.yml`, which runs nowhere under the monorepo: GitHub runs
- // the root `.github/workflows/` alone. That workflow is row 18 of the
- // objectstack#25697 workflow roster, "kept, follow-up port", re-aimed at the
- // one root `pnpm-lock.yaml`; the port brings these pins back against its root
- // workflow, by path.
-
- it('is the only place the LIVE reading runs, in workflows AND in what they run (objectui#9562, objectui#9693)', () => {
- // The live, registry-dependent execution belongs to this file's stubbed legs
- // and, once roster row 18 is ported, to one path-filtered root workflow:
- // nowhere else. ⭐ "Nowhere else" is the whole assertion, and
- // it used to be measured over the workflows alone — while the way a
- // required job actually grew a `pnpm dedupe`, the time it happened, was
- // through a TEST FILE, which is not a workflow. So the population is both
- // carriers at once: one assertion over every place a live reading could
- // run, ⛔ never a second lock per class of file.
- const scan = scanForLiveReading();
-
- // Floors first. An equality over a population that collapsed to nothing
- // passes for the wrong reason, and this one spans two independent reads —
- // so each read carries its own floor. `check-test-path-roots.mjs` already
- // publishes the floor for the test-file half; this reuses it rather than
- // writing a second number that would drift from it.
- expect(scan.workflows, 'the workflow read collapsed').toBeGreaterThan(20);
- expect(scan.population.length, 'the source read collapsed').toBeGreaterThan(FLOORS.testFiles);
- // …and it reached this file's PEERS and the runtime modules, not just the
- // file the expectation names — a scan that only ever looked at itself
- // would satisfy the equality below exactly as well.
- expect(scan.population.filter((f) => f.startsWith('scripts/__tests__/') && f !== THIS_FILE).length).toBeGreaterThan(50);
- expect(scan.population).toContain('vitest.setup.base.ts');
-
- // The equality was also the positive control for both halves while one
- // member was a workflow and the other a test file. Until the row-18 port
- // restores the workflow member, the workflow half rests on the floor above
- // (see `LIVE_READING_SITES`).
- expect(scan.sites, 'a live, registry-dependent reading inside a REQUIRED context — objectui#9562, again').toEqual(
- LIVE_READING_SITES,
- );
- });
-
- it('the population test fires on every route objectui#9693 enumerates, and not on prose', () => {
- // The control for the detector the assertion above is built on. Each case
- // is the spelling ITSELF rather than a description of one, so a marker that
- // stopped matching fails here instead of reporting an empty tree as clean.
-
- // Row 3 of objectui#9693: another test file spawns the shipped checker.
- expect(takesLiveReading(maskComments(`spawnSync('node', ['${SCRIPT}'], { cwd: repoRoot });`))).toBe(true);
- // Row 4: another file runs pnpm's dedupe itself, in either spelling.
- expect(takesLiveReading(maskComments(`spawnSync('pnpm', ['dedupe', '--check'], { cwd: repoRoot });`))).toBe(true);
- // Row 1, and the indirection through this repository's own script name —
- // a `run:` step reaches the same live reading by either spelling.
- expect(takesLiveReading(` run: node ${SCRIPT}`)).toBe(true);
- expect(takesLiveReading(' run: pnpm dedupe --check')).toBe(true);
- expect(takesLiveReading(' run: pnpm check:lockfile-dedupe')).toBe(true);
-
- // ⛔ And prose about the hazard is not the hazard: this file's own header
- // discusses both spellings at length, so without the mask the assertion
- // above could never be green and the next author would loosen it.
- expect(takesLiveReading(maskComments(`// never spawn ${SCRIPT} from here; run \`pnpm dedupe\` by hand`))).toBe(false);
- expect(takesLiveReading(maskComments(`/* ${SCRIPT} is the checker */`))).toBe(false);
- // Nor is the word this repository spends on UI dedupe keys everywhere.
- expect(takesLiveReading(maskComments('const dedupe = new Set();'))).toBe(false);
- });
-
- it('stays an OPTIONAL context, because the JOB still gates even though its verdict does not', () => {
- // ⚠️ The load-bearing assertion, and objectui#9562 did NOT move it: the
- // ruling relaxed the VERDICT, not the job. A failed checkout, a broken
- // `ci-setup-pnpm.sh` or a `--self-test` that stops passing still reds this
- // context and still stops a Dependabot auto-merge — so the classification
- // is live rather than vestigial, and moving this name to NOT_A_GATE remains
- // a separate decision about what may auto-merge, which fails here first.
- expect(Object.keys(OPTIONAL_CONTEXTS)).toContain(CONTEXT);
- expect(Object.keys(NOT_A_GATE)).not.toContain(CONTEXT);
- // And it may not be REQUIRED while the path filter stands (objectui#3523).
- expect(REQUIRED_CONTEXTS as readonly string[]).not.toContain(CONTEXT);
- });
-
- it('the classification says why it blocks where its neighbour does not', () => {
- const reason = OPTIONAL_CONTEXTS[CONTEXT as keyof typeof OPTIONAL_CONTEXTS];
- expect(reason).toBeTruthy();
- expect(reason).toContain('pnpm dedupe');
- // The neighbour's reservation must stay visible and untouched.
- expect(Object.keys(NOT_A_GATE)).toContain('Lockfile Integrity Check');
- });
-});
diff --git a/ui/scripts/__tests__/check-lockfile-integrity.test.ts b/ui/scripts/__tests__/check-lockfile-integrity.test.ts
index 109a7ebcc76..a299931fa9d 100644
--- a/ui/scripts/__tests__/check-lockfile-integrity.test.ts
+++ b/ui/scripts/__tests__/check-lockfile-integrity.test.ts
@@ -25,6 +25,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { lockfilePath } from '../workspace-root.mjs';
+import { jobBlock, rootCi, rootWorkflow, rootWorkflowFiles, uiRun, withoutComments } from './helpers/root-ci';
import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs';
import {
compareLockfiles,
@@ -38,7 +39,7 @@ const GATE = path.join(repoRoot, 'scripts/check-lockfile-integrity.mjs');
const LOCKFILE = lockfilePath();
const realLock = fs.readFileSync(LOCKFILE, 'utf8');
-/** The check-run name the workflow's job produces. */
+/** The check-run name objectui's own job produced, the name the Dependabot merge gate classifies. */
const CHECK_NAME = 'Lockfile Integrity Check';
describe('the gate runs its own self-test', () => {
@@ -191,15 +192,61 @@ describe('cause 2 — a declared dependency forks with no version moving (object
});
});
-describe('the gate is classified the way the dispatch reserved', () => {
- // `exists and runs the gate` and `checks out deep enough to have a merge
- // base` retired with objectstack#25712. They read objectui's
- // `lockfile-integrity.yml`, which runs nowhere under the monorepo: GitHub
- // runs the root `.github/workflows/` alone. That workflow is row 19 of the
- // objectstack#25697 workflow roster, "kept, follow-up port", re-aimed at the
- // one root `pnpm-lock.yaml`; the port brings both pins back against the root
- // job that runs the gate.
+describe('the gate is wired into the root CI (objectstack#26717)', () => {
+ // objectui ran it in its own `lockfile-integrity.yml`, which runs nowhere
+ // under the monorepo: GitHub runs the root `.github/workflows/` alone. Row 19
+ // of the objectstack#25697 workflow roster ported it as one step of root
+ // `ci.yml`'s `type-check-ui` job, re-aimed at the one root `pnpm-lock.yaml`.
+ // The two pins objectui kept on its workflow (`exists and runs the gate`,
+ // `checks out deep enough to have a merge base`) read that job now.
+ const JOB = 'type-check-ui';
+ const STEP_NAME = 'check:lockfile-integrity — ';
+ const COMMAND = `${uiRun('check:lockfile-integrity')} --base-ref "origin/\${BASE_REF}"`;
+
+ /** The step's own lines, from its `- name:` to the next step. Throws when it is gone. */
+ function step(): string {
+ const block = withoutComments(jobBlock(rootCi(), JOB));
+ const start = block.indexOf(`- name: ${STEP_NAME}`);
+ if (start === -1) throw new Error(`ci.yml job ${JOB} has no step named ${STEP_NAME}…`);
+ const next = block.indexOf('\n - ', start + 1);
+ return next === -1 ? block.slice(start) : block.slice(start, next);
+ }
+
+ it('exists and runs the gate against the pull request base', () => {
+ expect(step()).toContain(`run: ${COMMAND}`);
+ expect(step()).toContain('BASE_REF: ${{ github.base_ref }}');
+ expect(step()).toContain("if: github.event_name == 'pull_request'");
+ });
+
+ it('checks out deep enough to have a merge base', () => {
+ // A shallow clone has no merge base, and the script exits 2 rather than
+ // guess one: a reading that never measured the change.
+ const block = withoutComments(jobBlock(rootCi(), JOB));
+ const checkout = block.indexOf('uses: actions/checkout@');
+ expect(checkout).toBeGreaterThan(-1);
+ expect(block.indexOf('fetch-depth: 0', checkout)).toBeGreaterThan(checkout);
+ expect(block.indexOf('fetch-depth: 0', checkout)).toBeLessThan(block.indexOf(`- name: ${STEP_NAME}`));
+ });
+ it('⛔ stays an alarm in a required job: continue-on-error, before the install', () => {
+ // The step's finding must not fail Type Check UI; enrolling the gate is
+ // the maintainer's decision (the classification below). Before the
+ // install, because the script needs nothing installed.
+ expect(step()).toContain('continue-on-error: true');
+ const block = withoutComments(jobBlock(rootCi(), JOB));
+ expect(block.indexOf(`- name: ${STEP_NAME}`)).toBeLessThan(block.indexOf('run: pnpm install --frozen-lockfile'));
+ });
+
+ it('runs in that one place: no other root workflow carries it', () => {
+ const carriers = rootWorkflowFiles().filter((file) =>
+ withoutComments(rootWorkflow(file)).includes('check:lockfile-integrity'),
+ );
+ expect(carriers).toEqual(['ci.yml']);
+ expect(withoutComments(rootCi()).split('check:lockfile-integrity').length - 1).toBe(2); // the name and the run
+ });
+});
+
+describe('the gate is classified the way the dispatch reserved', () => {
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);
diff --git a/ui/scripts/__tests__/shadcn-check-report.test.ts b/ui/scripts/__tests__/shadcn-check-report.test.ts
deleted file mode 100644
index f1751cc5135..00000000000
--- a/ui/scripts/__tests__/shadcn-check-report.test.ts
+++ /dev/null
@@ -1,357 +0,0 @@
-import { afterEach, describe, expect, it } from 'vitest';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-
-// Plain-JS CI helper. Its types are INFERRED from the .mjs source by
-// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — see
-// objectui#3494.
-import {
- ESCALATION_THRESHOLD,
- MARKER_STEPS,
- alarmTitle,
- classifyAnalyze,
- classifyCheck,
- consecutiveUnreachable,
- countRegistryErrors,
- decideAlarm,
- main,
- readRegistryStreak,
- registryStateFromJobs,
- renderAlarmIssue,
- stripAnsi,
-} from '../shadcn-check-report.mjs';
-
-/**
- * objectui#3586 — the three gaps PR #3497 declared and left open.
- *
- * #3497 built the alarm channel and tested it by hand: five fixtures, run once,
- * results written into the PR body. Nothing re-runs that between weekly cron
- * fires, and the logic it covered lived in YAML where nothing could. This file
- * is the standing version of that dry run, and it exists because this card adds
- * two more classified inputs to the same decision.
- *
- * What each block pins:
- *
- * 1. #3497's three check classes, UNCHANGED. This card must not move the
- * tolerance ruling, so the ruling is asserted here as a regression fence
- * rather than described in a comment.
- * 2. Gap ③ — an `analyze` crash reaches the SAME channel. The discriminating
- * assertion is the reverse one: with the crash classified back to `ok`, the
- * identical run produces NO alarm. That is the "silently red on an unwatched
- * weekly job" state the card is closing, asserted from both sides.
- * 3. Gap ① — consecutive unreachability. Both directions again: runs 1..N-1
- * stay tolerated exactly as #3497 ruled (no alarm, no issue), and run N
- * escalates into the same channel.
- * 4. Gap ② and the cross-run contract, read out of the workflow YAML. The
- * marker step names are the one part of the mechanism a rename can break
- * SILENTLY — a renamed marker reads back as `unknown`, resets the streak,
- * and the escalation simply never fires again — so they are pinned against
- * the module's own constants rather than re-spelled. The workflow half
- * retired with objectstack#25712 (the describe at the end of this file says
- * which cases); the constants half stands.
- */
-
-/** A realistic `shadcn:check` capture: 46 components, every fetch refused. */
-const unreachableOutput = Array.from(
- { length: 46 },
- (_, i) => `✗ component-${i} Registry returned no usable file content`,
-).join('\n');
-
-const cleanOutput = ['✓ button Identical to upstream', 'Registry: 0 cached, 46 fetched'].join('\n');
-
-const patchOutput = [
- cleanOutput,
- 'DECLARED LOCAL PATCHES — FAILED',
- '✗ 1 component(s) with declared local patch failures',
-].join('\n');
-
-describe('classifyCheck — #3497 three classes, unchanged', () => {
- it('classifies a clean run as ok with no alarm', () => {
- const verdict = classifyCheck({ output: cleanOutput, exitCode: 0 });
- expect(verdict).toEqual({ checkClass: 'ok', registryErrors: 0, registryState: 'reachable' });
- expect(decideAlarm({ checkClass: 'ok', analyzeClass: 'ok', registryStreak: 0 }).alarm).toBe(false);
- });
-
- it('tolerates an unreachable registry: still ok, still exit 0, still no issue', () => {
- const verdict = classifyCheck({ output: unreachableOutput, exitCode: 0 });
- expect(verdict.checkClass).toBe('ok');
- expect(verdict.registryErrors).toBe(46);
- expect(verdict.registryState).toBe('unreachable');
- // The single-run ruling this card leaves untouched.
- expect(decideAlarm({ checkClass: 'ok', analyzeClass: 'ok', registryStreak: 1 }).alarm).toBe(false);
- });
-
- it('classifies a declared-patch failure as patch', () => {
- expect(classifyCheck({ output: patchOutput, exitCode: 1 }).checkClass).toBe('patch');
- });
-
- it('reads the verdict LINE before the exit code — the evidence outranks the policy', () => {
- // #3497's ordering ruling: a script revision that stopped exiting non-zero
- // for a patch failure must not silently downgrade the verdict to `ok`.
- expect(classifyCheck({ output: patchOutput, exitCode: 0 }).checkClass).toBe('patch');
- });
-
- it('classifies any other non-zero exit as broken', () => {
- expect(classifyCheck({ output: 'Error loading manifest', exitCode: 1 }).checkClass).toBe('broken');
- expect(decideAlarm({ checkClass: 'broken', analyzeClass: 'ok', registryStreak: 0 }).alarm).toBe(true);
- });
-
- it('counts both registry-failure shapes the sync script produces', () => {
- expect(
- countRegistryErrors(
- ['Error fetching from registry: HTTP 403', 'Registry returned no usable file content', 'fine'].join('\n'),
- ),
- ).toBe(2);
- });
-
- it('strips the ANSI the shadcn scripts emit unconditionally', () => {
- const esc = String.fromCharCode(0x1b);
- expect(stripAnsi(`${esc}[32m✓ button${esc}[0m`)).toBe('✓ button');
- });
-});
-
-describe('gap ③ — an analyze crash lands in the same channel, not silently red', () => {
- it('classifies a non-zero analyze exit as broken and alarms on it', () => {
- expect(classifyAnalyze({ exitCode: 1 })).toBe('broken');
- const { alarm, reasons } = decideAlarm({ checkClass: 'ok', analyzeClass: 'broken', registryStreak: 0 });
- expect(alarm).toBe(true);
- expect(reasons).toContain('analyze-broken');
- });
-
- it('REVERSE: the identical run with the crash classified ok produces no alarm at all', () => {
- // This is the pre-#3586 behaviour, stated as an assertion: `analyze` exits
- // non-zero, the job goes red on a weekly schedule, and nothing reaches the
- // triage queue. If a later change reverts the classification, this pair —
- // not the positive test above — is what catches it.
- expect(classifyAnalyze({ exitCode: 0 })).toBe('ok');
- expect(decideAlarm({ checkClass: 'ok', analyzeClass: 'ok', registryStreak: 0 }).alarm).toBe(false);
- });
-
- it('reports the crash in the SAME issue body, with the labels-bearing title of the existing channel', () => {
- const { title, body } = renderAlarmIssue({
- reasons: ['analyze-broken'],
- analyzeClass: 'broken',
- analyzeExit: 1,
- analysisLog: 'Fatal error: Cannot read properties of undefined',
- });
- // #3497's own "could not run" title — deliberately not a new one, because a
- // new title on a de-duplicated channel is how a second channel starts.
- expect(title).toBe('Shadcn sync: the weekly component check could not run');
- expect(body).toContain('The offline analysis step **crashed**');
- expect(body).toContain('Cannot read properties of undefined');
- });
-
- it('still names the patch failure first when both fail — one issue, both facts', () => {
- const { title, body } = renderAlarmIssue({ reasons: ['patch', 'analyze-broken'], checkExit: 1, analyzeExit: 1 });
- expect(title).toBe('Shadcn sync: declared local patches are failing');
- expect(body).toContain('declared local patch failure');
- expect(body).toContain('The offline analysis step **crashed**');
- });
-});
-
-describe('gap ① — consecutive unreachability escalates, single runs still do not', () => {
- const jobsWith = (stepName: string) => ({ jobs: [{ steps: [{ name: stepName, conclusion: 'success' }] }] });
- const unreachableRun = jobsWith(MARKER_STEPS.unreachable);
- const reachableRun = jobsWith(MARKER_STEPS.reachable);
- /** A run that died before the markers — neither name present. */
- const diedEarlyRun = { jobs: [{ steps: [{ name: 'Install dependencies', conclusion: 'failure' }] }] };
-
- const stubApi = (previous: object[]) => {
- const calls: string[] = [];
- return {
- calls,
- listRuns: async (perPage: number) => {
- calls.push(`runs:${perPage}`);
- return { workflow_runs: previous.map((_, i) => ({ id: 100 + i })) };
- },
- listJobs: async (runId: number) => {
- calls.push(`jobs:${runId}`);
- return previous[runId - 100];
- },
- };
- };
-
- it('reads three states out of a jobs payload, and "unknown" is not "reachable"', () => {
- expect(registryStateFromJobs(unreachableRun.jobs)).toBe('unreachable');
- expect(registryStateFromJobs(reachableRun.jobs)).toBe('reachable');
- expect(registryStateFromJobs(diedEarlyRun.jobs)).toBe('unknown');
- expect(registryStateFromJobs([])).toBe('unknown');
- });
-
- it('does not touch the API at all when this run reached the registry', async () => {
- const api = stubApi([unreachableRun, unreachableRun]);
- const result = await readRegistryStreak({ currentState: 'reachable', api });
- expect(result).toEqual({ streak: 0, readable: true, error: '' });
- expect(api.calls).toEqual([]);
- });
-
- it('run 1 of a new outage: streak 1, tolerated', async () => {
- const api = stubApi([reachableRun, reachableRun]);
- const { streak } = await readRegistryStreak({ currentState: 'unreachable', api });
- expect(streak).toBe(1);
- expect(decideAlarm({ checkClass: 'ok', analyzeClass: 'ok', registryStreak: streak }).alarm).toBe(false);
- });
-
- it('run 2: streak 2, STILL tolerated — the ruling holds up to N-1', async () => {
- const api = stubApi([unreachableRun, reachableRun]);
- const { streak } = await readRegistryStreak({ currentState: 'unreachable', api });
- expect(streak).toBe(2);
- expect(streak).toBeLessThan(ESCALATION_THRESHOLD);
- expect(decideAlarm({ checkClass: 'ok', analyzeClass: 'ok', registryStreak: streak }).alarm).toBe(false);
- });
-
- it('run 3: streak reaches the threshold and escalates into the same channel', async () => {
- const api = stubApi([unreachableRun, unreachableRun, unreachableRun]);
- const { streak } = await readRegistryStreak({ currentState: 'unreachable', api });
- expect(streak).toBe(ESCALATION_THRESHOLD);
-
- const { alarm, reasons } = decideAlarm({ checkClass: 'ok', analyzeClass: 'ok', registryStreak: streak });
- expect(alarm).toBe(true);
- expect(reasons).toEqual(['registry-blind']);
-
- const { title, body } = renderAlarmIssue({ reasons, registryStreak: streak, registryErrors: 46 });
- expect(title).toBe('Shadcn sync: the registry has been unreachable for 3 consecutive runs');
- expect(body).toContain('unreachable for **3 consecutive runs**');
- expect(body).toContain('proved nothing about upstream');
- });
-
- it('stops walking at the threshold — never more than N-1 job reads', async () => {
- const api = stubApi(Array.from({ length: 8 }, () => unreachableRun));
- await readRegistryStreak({ currentState: 'unreachable', api });
- expect(api.calls.filter((c) => c.startsWith('jobs:'))).toHaveLength(ESCALATION_THRESHOLD - 1);
- });
-
- it('a reachable run breaks the streak; an unknown one breaks it too', () => {
- expect(consecutiveUnreachable(['unreachable', 'unreachable', 'reachable', 'unreachable'])).toBe(2);
- expect(consecutiveUnreachable(['unreachable', 'unknown', 'unreachable'])).toBe(1);
- expect(consecutiveUnreachable(['reachable'])).toBe(0);
- });
-
- it('a failed cross-run read alarms rather than silently disabling the escalation', async () => {
- const api = {
- listRuns: async () => {
- throw new Error('HTTP 500');
- },
- listJobs: async () => ({ jobs: [] }),
- };
- const { readable, error } = await readRegistryStreak({ currentState: 'unreachable', api });
- expect(readable).toBe(false);
- expect(error).toContain('HTTP 500');
-
- const { alarm, reasons } = decideAlarm({
- checkClass: 'ok',
- analyzeClass: 'ok',
- registryStreak: 1,
- streakReadable: false,
- });
- expect(alarm).toBe(true);
- expect(reasons).toContain('streak-unreadable');
- expect(alarmTitle(reasons)).toBe('Shadcn sync: the weekly component check could not run');
- });
-});
-
-describe('main() — the wiring the workflow actually depends on', () => {
- const made: string[] = [];
-
- afterEach(() => {
- for (const dir of made.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
- });
-
- async function run(env: Record, files: Record) {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shadcn-check-report-'));
- made.push(dir);
- for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
-
- const saved = { ...process.env };
- Object.assign(process.env, {
- GITHUB_OUTPUT: path.join(dir, 'outputs.txt'),
- GITHUB_STEP_SUMMARY: path.join(dir, 'summary.md'),
- CHECK_RAW_LOG: path.join(dir, 'check.ansi.txt'),
- ANALYZE_RAW_LOG: path.join(dir, 'analysis.ansi.txt'),
- CHECK_LOG: path.join(dir, 'check.txt'),
- ANALYZE_LOG: path.join(dir, 'analysis.txt'),
- ALARM_BODY_FILE: path.join(dir, 'alarm-issue.md'),
- ...env,
- });
-
- try {
- const result = await main({ api: { listRuns: async () => ({ workflow_runs: [] }), listJobs: async () => ({ jobs: [] }) } });
- const outputs = Object.fromEntries(
- fs
- .readFileSync(path.join(dir, 'outputs.txt'), 'utf8')
- .split('\n')
- .filter(Boolean)
- .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]),
- );
- const alarmBody = fs.existsSync(path.join(dir, 'alarm-issue.md'))
- ? fs.readFileSync(path.join(dir, 'alarm-issue.md'), 'utf8')
- : null;
- return { result, outputs, alarmBody, dir };
- } finally {
- process.env = saved;
- }
- }
-
- it('a clean run: no alarm, no issue body, stripped logs written for the artifact', async () => {
- const esc = String.fromCharCode(0x1b);
- const { outputs, alarmBody, dir } = await run(
- { CHECK_EXIT_CODE: '0', ANALYZE_EXIT_CODE: '0' },
- { 'check.ansi.txt': `${esc}[32m${cleanOutput}${esc}[0m`, 'analysis.ansi.txt': 'Summary' },
- );
- expect(outputs.alarm).toBe('false');
- expect(outputs.check_class).toBe('ok');
- expect(outputs.analyze_class).toBe('ok');
- expect(outputs.registry_state).toBe('reachable');
- expect(alarmBody).toBeNull();
- // The artifact must carry the readable copy, not the escape-dense capture.
- expect(fs.readFileSync(path.join(dir, 'check.txt'), 'utf8')).not.toContain(esc);
- });
-
- it('an analyze crash alone drives the alarm outputs end to end', async () => {
- const { outputs, alarmBody } = await run(
- { CHECK_EXIT_CODE: '0', ANALYZE_EXIT_CODE: '1' },
- { 'check.ansi.txt': cleanOutput, 'analysis.ansi.txt': 'Fatal error: boom' },
- );
- expect(outputs.alarm).toBe('true');
- expect(outputs.alarm_reasons).toBe('analyze-broken');
- expect(outputs.issue_title).toBe('Shadcn sync: the weekly component check could not run');
- expect(alarmBody).toContain('boom');
- });
-
- it('an unreachable registry on run 1 stays tolerated but is recorded for the next run', async () => {
- const { outputs, alarmBody } = await run(
- { CHECK_EXIT_CODE: '0', ANALYZE_EXIT_CODE: '0' },
- { 'check.ansi.txt': unreachableOutput, 'analysis.ansi.txt': 'Summary' },
- );
- expect(outputs.alarm).toBe('false');
- expect(outputs.registry_state).toBe('unreachable');
- expect(outputs.registry_streak).toBe('1');
- expect(alarmBody).toBeNull();
- });
-});
-
-/**
- * `shadcn-check.yml — the gaps, read off the workflow itself` retired with
- * objectstack#25712, except the constants half of its marker case below: `gap
- * ②: the job declares a timeout`, `gap ③: the analyze step no longer hides its
- * exit code behind continue-on-error`, the workflow half of `gap ①: the marker
- * step names match the module constants exactly`, `gap ①: the cross-run read
- * has the permission it needs`, `the classification runs from the tested
- * module, not from a shell block` and `one channel: the alarm step is still the
- * single issue writer`. Each read objectui's `shadcn-check.yml`, which runs
- * nowhere under the monorepo: GitHub runs the root `.github/workflows/` alone,
- * and no root workflow runs this report. It is row 26 of the objectstack#25697
- * workflow roster, "kept, follow-up port" (a schedule-only maintenance report);
- * the port brings these pins back against its root workflow, by path, with the
- * marker step names it spells.
- */
-describe('the cross-run marker names', () => {
- it('gap ①: the module constants are the names the escalation reads back', () => {
- // Pinned, never re-spelled: a rename on one side only makes every future
- // run read `unknown`, which resets the streak, which means the escalation
- // never fires again — and nothing else would notice.
- expect(MARKER_STEPS.reachable).toBe('Cross-run marker: registry reachable');
- expect(MARKER_STEPS.unreachable).toBe('Cross-run marker: registry unreachable');
- });
-});
diff --git a/ui/scripts/check-lockfile-dedupe.mjs b/ui/scripts/check-lockfile-dedupe.mjs
deleted file mode 100644
index 039117dad1a..00000000000
--- a/ui/scripts/check-lockfile-dedupe.mjs
+++ /dev/null
@@ -1,515 +0,0 @@
-#!/usr/bin/env node
-/**
- * Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
- *
- * The committed `pnpm-lock.yaml` must already be DEDUPED: running
- * `pnpm dedupe` on it may not collapse anything (objectui#8333).
- *
- * Run: node scripts/check-lockfile-dedupe.mjs (`pnpm check:lockfile-dedupe`)
- * node scripts/check-lockfile-dedupe.mjs --report-only (what CI passes)
- * node scripts/check-lockfile-dedupe.mjs --self-test
- *
- * ## `--report-only`: the gate REPORTS on pull requests (objectui#9562)
- *
- * objectui#9562 measured this gate returning four green and one red on a
- * byte-identical `pnpm-lock.yaml` inside ninety minutes, the red printing a
- * confident `VERDICT not deduped` that no lockfile edit and no registry publish
- * explained — and then instructing the reader to commit a dedupe the tree did
- * not need, to a lockfile every open pull request shares. The maintainer ruled
- * (2026-09-17, letter A) that a gate which is non-deterministic by construction
- * does not BLOCK:
- *
- * - `--report-only` keeps every reading exactly as it is and changes only what
- * the process does with it: annotations become `::warning::`, the same text
- * is written to `$GITHUB_STEP_SUMMARY`, and the process exits 0. It is the
- * flag `lockfile-dedupe.yml` passes.
- * - The BARE script keeps its exit codes — 0 / 1 / 2 — for whoever runs it by
- * hand. ⛔ That is deliberate and not an oversight to tidy away: the hard
- * verdict is still how a human asks this question.
- * - ⛔ Report-only may never flatten the three answers into two. `clean` and
- * `cannot-run` both exit 0 under the flag, so the EXIT CODE no longer tells
- * them apart — the annotation does, and `cannot-run` carries one precisely
- * because of that. A report-only mode that cannot say "I could not look" is
- * worse than the blocking gate it replaces.
- * - The finding text, in BOTH modes, now says what the instrument is: re-run
- * before acting, and dedupe only when the split reproduces. ⛔ The old
- * unconditional "fix it HERE by running `pnpm dedupe` and committing the
- * lockfile" is gone from both — it is the sentence objectui#9562 reported.
- *
- * ## What this is for, and why it is not objectui#8326's gate
- *
- * objectui#8326's `check-lockfile-integrity.mjs` reports a DELTA: it compares a
- * pull request's lockfile with its merge base and names anything that gained a
- * physical copy. It answers "did THIS change duplicate something?".
- *
- * This gate answers a different question, about one tree and no base: "is the
- * lockfile in a state where `pnpm dedupe` would still have work to do?" The two
- * are independent — a tree can be delta-clean and still carry years of
- * accumulated duplication, which is exactly the state `main` was in until
- * objectui#9215 collapsed 47 identities out of it.
- *
- * ## The defect this closes, in the order it happens
- *
- * 1. A dependency bump re-resolves part of the peer graph and forks a package
- * that was single-copy. Measured on objectui#8333: bumping `better-auth`
- * alone splits `zod` into a third physical copy and pulls `@objectstack/spec`
- * and `ai` into a second peer context each. No declaration, range or override
- * anywhere in the workspace changed — it is partial-re-resolution debt.
- * 2. `Bundle Analysis` then reads a bundle that grew, and the growth is
- * attributed to the bump. It is not the bump: it is the fork.
- * 3. The remedy is `pnpm dedupe`. But `pnpm dedupe` is NOT SURGICAL — it
- * collapses every collapsible duplicate in the tree, not just the one the
- * bump created. Run inside a bump PR on a tree carrying old duplication, it
- * drags all of that along, and the reviewer reads the combined delta as the
- * bump's. That is the SAME misattribution, aimed at a different PR.
- *
- * ⇒ requiring the remedy is only honest once the tree is already deduped. That
- * is why objectui#8333's ruling was "B then A": pay the accumulated debt down in
- * its own dedicated pull request FIRST (objectui#9215, landed), and only then
- * require the remedy in bump PRs. This gate is the second half, and its whole
- * value depends on the first half staying true — which is precisely what it
- * enforces. Without it `main` drifts back to a duplicated tree and the next bump
- * PR's dedupe is expensive and unreadable again.
- *
- * ## Why the verdict is pnpm's exit code and not a parse of its output
- *
- * `pnpm dedupe --check` is pnpm's own contract: exit 0 when nothing would
- * change, non-zero when something would. This gate does not re-derive that. It
- * reads the exit code, and parses the output ONLY to NAME the packages in the
- * annotation. A parse that finds nothing still reds on a non-zero exit — the
- * names are a convenience and can never change the verdict, so a future change
- * to pnpm's report format degrades the message and not the judgement.
- *
- * The one thing the output IS load-bearing for is telling a FINDING apart from a
- * CRASH. Both exit non-zero, and "the registry was unreachable" is not a finding
- * about the diff. So a non-zero exit counts as a finding only when the output
- * carries pnpm's own remedy sentinel; otherwise this exits 2, "could not take a
- * reading", which is never a pass.
- *
- * ## ⚠️ This gate reads the REGISTRY, unlike its neighbour
- *
- * `check-lockfile-integrity.mjs` is one `node` call over two text files.
- * This one shells out to pnpm, which resolves against `registry.npmjs.org`, so
- * it costs a network round trip and can fail for reasons that are about the
- * world rather than the diff. That failure lands on exit 2 (see above) with a
- * message saying so, rather than being reported as a lockfile finding.
- *
- * ⚠️ Stated because it bounds what the green means: `pnpm dedupe` does NOT chase
- * the newest version admitted by a range — it only collapses copies that are
- * already in the tree. Measured on objectui#8333: `better-auth` is declared
- * `^1.7.2` and locked at `1.7.2` while the registry already serves `1.7.4`,
- * which that range admits, and this gate is green on that tree. So a publish to
- * the registry does not by itself turn this gate red, and it is not the
- * spontaneously-drifting check that a "resolve against the live registry" gate
- * sounds like. That is an argument from one measured instance plus pnpm's
- * documented behaviour, ⛔ not a proof over all future pnpm versions; the
- * instrument that re-derives it is this gate's own run history.
- */
-
-import { spawnSync } from 'node:child_process';
-import fs from 'node:fs';
-
-import { isEntrypoint } from './invoked-as.mjs';
-import { workspaceRoot } from './workspace-root.mjs';
-
-/**
- * pnpm's own remedy line, printed only when `--check` found something to
- * collapse. It is what separates a FINDING from a CRASH, both of which exit
- * non-zero. Captured verbatim from `pnpm@10.31.0`.
- */
-export const DEDUPE_SENTINEL = 'Run pnpm dedupe to apply the changes above.';
-
-export const EXIT_CLEAN = 0;
-export const EXIT_FINDINGS = 1;
-/** A reading that could not be taken is NOT a deduped lockfile. */
-export const EXIT_CANNOT_RUN = 2;
-
-/**
- * The one explicit flag objectui#9562's ruling grants, passed by
- * `lockfile-dedupe.yml`. ⛔ Not inferred from `process.env.CI` or from
- * `GITHUB_ACTIONS`: a mode that changes a verdict's consequences is a decision
- * the caller states, so that running the checker by hand cannot silently get a
- * different contract than the one being read here.
- */
-export const REPORT_ONLY_FLAG = '--report-only';
-
-/**
- * What this gate IS, said wherever it reports a finding — in both modes and on
- * both surfaces (log and step summary), from ONE copy so the two cannot drift.
- *
- * ⛔ This is not decoration. The defect objectui#9562 recorded is not that the
- * gate reds; it is that a confident red arrives with a remedy attached, and the
- * remedy writes to a file every open pull request shares.
- */
-export const INSTRUMENT_CAVEAT = Object.freeze([
- '⚠️ WHAT THIS GATE IS: a LIVE registry reading, and it can disagree with itself. objectui#9562',
- 'recorded four green and one red on a byte-identical `pnpm-lock.yaml` inside ninety minutes, the',
- 'red naming a split that no lockfile edit and no registry publish explained.',
- '⇒ RE-RUN this check before acting on it, and run `pnpm dedupe` and commit the lockfile ONLY when',
- 'the split reproduces. ⛔ One red is not by itself evidence that this tree needs a dedupe.',
-]);
-
-/**
- * The verdict, from pnpm's exit status plus the sentinel.
- *
- * `status === 0` with the sentinel present is contradictory — pnpm would be
- * saying both "nothing to do" and "run dedupe" — so it is reported as
- * `cannot-run` rather than being resolved in either direction.
- *
- * @param {{ status: number|null, output: string }} run
- * @returns {'clean'|'findings'|'cannot-run'}
- */
-export function classify({ status, output = '' }) {
- const sentinel = output.includes(DEDUPE_SENTINEL);
- if (status === 0) return sentinel ? 'cannot-run' : 'clean';
- return sentinel ? 'findings' : 'cannot-run';
-}
-
-/**
- * Best-effort: the package NAMES whose copies `pnpm dedupe` would collapse.
- *
- * `--check` lists the snapshot keys it would remove as `- ` lines,
- * where an identity is `name@version` optionally followed by a `(peer)` suffix.
- * Scoped names carry a leading `@`, so the version separator is the LAST `@`
- * before any peer suffix.
- *
- * ⛔ Never load-bearing: an empty result does not make a non-zero exit clean.
- *
- * @param {string} output
- * @returns {string[]} sorted, unique
- */
-export function namesFromOutput(output = '') {
- const names = new Set();
- for (const line of output.split('\n')) {
- const match = /^-\s+(\S.*?)\s*$/.exec(line);
- if (!match) continue;
- const base = match[1].split('(')[0];
- const at = base.lastIndexOf('@');
- if (at <= 0) continue;
- names.add(base.slice(0, at));
- }
- return [...names].sort();
-}
-
-/**
- * The line that tells the reader this job will not fail, printed ONLY under the
- * flag. Without it a `::warning::` on a green check reads as an unexplained
- * near-miss.
- *
- * @param {boolean} reportOnly
- * @returns {string[]}
- */
-function modeLine(reportOnly) {
- return reportOnly
- ? ['⚠️ REPORT ONLY — this job exits 0 and does not fail your pull request (objectui#9562).']
- : [];
-}
-
-/**
- * @param {{ outcome: 'clean'|'findings'|'cannot-run', names?: string[], output?: string, reportOnly?: boolean }} reading
- * @returns {string[]} lines to print
- */
-export function renderVerdict({ outcome, names = [], output = '', reportOnly = false }) {
- if (outcome === 'clean') {
- return ['VERDICT deduped — `pnpm dedupe` would collapse nothing in this lockfile.'];
- }
- if (outcome === 'cannot-run') {
- return [
- 'VERDICT could not take a reading — `pnpm dedupe --check` did not report a result.',
- 'This is a fact about the run (registry, toolchain, or a changed pnpm report format),',
- 'NOT a verdict on this change. Nothing here was judged.',
- ...modeLine(reportOnly),
- ...(output ? ['', '--- pnpm output ---', output.trimEnd()] : []),
- ];
- }
- const named = names.length ? names.join(', ') : '(pnpm named no removable identity — see its output above)';
- return [
- output.trimEnd(),
- '',
- `VERDICT not deduped — \`pnpm dedupe\` would still collapse duplicate copies: ${named}.`,
- ...modeLine(reportOnly),
- 'Two physical copies of one package are two real paths in the bundle, and a downstream',
- '`Bundle Analysis` reading then measures resolution collateral left behind by an earlier',
- 'change rather than measuring this one (objectui#8333).',
- '',
- ...INSTRUMENT_CAVEAT,
- '',
- '⛔ Do NOT satisfy this by pinning a version, adding a `pnpm.overrides` entry or widening a',
- 'range: those spend a declaration to fix a resolution artefact, and objectui#8333 rejected',
- 'both. `pnpm dedupe` changes no declaration at all.',
- ];
-}
-
-/**
- * The same reading, rendered for `$GITHUB_STEP_SUMMARY` instead of the log.
- *
- * Empty for a clean tree: a gate with nothing to report writes nothing, so the
- * presence of a block is itself the signal on the run summary page.
- *
- * @param {{ outcome: 'clean'|'findings'|'cannot-run', names?: string[], output?: string }} reading
- * @returns {string} markdown, or '' when there is nothing to say
- */
-export function renderStepSummary({ outcome, names = [], output = '' }) {
- if (outcome === 'clean') return '';
- if (outcome === 'cannot-run') {
- return [
- '### ⚠️ Lockfile dedupe — could not take a reading',
- '',
- '`pnpm dedupe --check` did not report a result, so **nothing here was judged**. That is a fact',
- 'about the run — registry, toolchain, or a changed pnpm report format — and not a verdict on',
- 'this change. ⛔ It is NOT a deduped lockfile either.',
- '',
- ...INSTRUMENT_CAVEAT,
- '',
- ].join('\n');
- }
- const named = names.length
- ? names.map((name) => `- \`${name}\``)
- : ['- _(pnpm named no removable identity — see the job log)_'];
- return [
- '### ⚠️ Lockfile dedupe — not deduped',
- '',
- '`pnpm dedupe` would still collapse duplicate copies of:',
- '',
- ...named,
- '',
- ...INSTRUMENT_CAVEAT,
- '',
- '```text',
- output.trimEnd(),
- '```',
- '',
- ].join('\n');
-}
-
-/**
- * Append a step-summary block, when the runner gave us somewhere to put it.
- *
- * ⛔ Never throws and never changes a verdict: a summary that could not be
- * written is a worse report, not a different reading.
- *
- * @param {string} text
- * @param {NodeJS.ProcessEnv} [env]
- * @returns {boolean} whether anything was written
- */
-export function writeStepSummary(text, env = process.env) {
- const target = env.GITHUB_STEP_SUMMARY;
- if (!target || !text) return false;
- try {
- fs.appendFileSync(target, `${text}\n`);
- return true;
- } catch (error) {
- console.error(
- `::warning title=Lockfile dedupe::could not write the step summary: ${error instanceof Error ? error.message : String(error)}`,
- );
- return false;
- }
-}
-
-/**
- * @param {string[]} argv
- * @returns {number} process exit code
- */
-export function main(argv = process.argv.slice(2)) {
- const reportOnly = argv.includes(REPORT_ONLY_FLAG);
- /** The annotation LEVEL is the whole difference: same message, same surface. */
- const annotate = (message) =>
- console.error(`::${reportOnly ? 'warning' : 'error'} title=Lockfile dedupe::${message}`);
-
- // At the workspace root: this repository's own, or the monorepo's one level up.
- const run = spawnSync('pnpm', ['dedupe', '--check'], {
- cwd: workspaceRoot(),
- encoding: 'utf8',
- maxBuffer: 64 * 1024 * 1024,
- });
-
- if (run.error) {
- annotate(`could not run \`pnpm dedupe --check\`: ${run.error.message}`);
- for (const line of renderVerdict({ outcome: 'cannot-run', reportOnly })) console.error(line);
- writeStepSummary(renderStepSummary({ outcome: 'cannot-run' }));
- return reportOnly ? EXIT_CLEAN : EXIT_CANNOT_RUN;
- }
-
- // pnpm writes its report across both streams; the verdict reads them together.
- const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
- const outcome = classify({ status: run.status, output });
- const names = outcome === 'findings' ? namesFromOutput(output) : [];
-
- const lines = renderVerdict({ outcome, names, output, reportOnly });
- const write = outcome === 'clean' ? console.log : console.error;
- for (const line of lines) write(line);
-
- if (outcome === 'clean') return EXIT_CLEAN;
-
- writeStepSummary(renderStepSummary({ outcome, names, output }));
-
- if (outcome === 'cannot-run') {
- // ⛔ Under the flag the exit code no longer separates "clean" from "could
- // not look" — both are 0 — so the ANNOTATION has to, and this is the only
- // place it can come from. Without it, report-only would answer a question
- // it never asked.
- if (!reportOnly) return EXIT_CANNOT_RUN;
- annotate(
- 'could not take a reading — `pnpm dedupe --check` did not report a result, so nothing here ' +
- 'was judged. ⛔ This is NOT a deduped verdict.',
- );
- return EXIT_CLEAN;
- }
-
- annotate(
- `${names.join(', ') || 'duplicate copies'} — a live registry reading says \`pnpm dedupe\` would collapse these. ` +
- 'Re-run before acting; dedupe and commit the lockfile only if the split reproduces (objectui#8333, objectui#9562).',
- );
- return reportOnly ? EXIT_CLEAN : EXIT_FINDINGS;
-}
-
-/**
- * Cases built from REAL `pnpm@10.31.0` output captured on objectui#8333: the
- * green leg is today's `main`, the red leg is the same tree with `better-auth`
- * bumped, which forks `zod`.
- *
- * @returns {number}
- */
-export function selfTest() {
- /** @type {{ name: string, ok: boolean }[]} */
- const cases = [];
- const t = (name, ok) => cases.push({ name, ok: Boolean(ok) });
-
- const RED = [
- '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.6.4))',
- '└── zod 4.4.3 → 4.6.4',
- '',
- '+ zod-validation-error@4.0.2(zod@4.6.4)',
- '- @ai-sdk/gateway@4.0.52(zod@4.4.3)',
- '- @objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))',
- '- ai@7.0.65(zod@4.4.3)',
- '- zod@4.4.3',
- '',
- DEDUPE_SENTINEL,
- '',
- ].join('\n');
- const GREEN = ' WARN 7 deprecated subdependencies found: glob@7.2.3\nProgress: resolved 1767, done\n';
-
- // ── the verdict comes from the exit code, disambiguated by the sentinel ────
- t('a zero exit with no sentinel is clean', classify({ status: 0, output: GREEN }) === 'clean');
- t('a non-zero exit carrying the sentinel is a finding', classify({ status: 1, output: RED }) === 'findings');
- t(
- 'a non-zero exit WITHOUT the sentinel is could-not-run, not a finding',
- classify({ status: 1, output: 'ERR_PNPM_FETCH_404 GET https://registry.npmjs.org/x' }) === 'cannot-run',
- );
- t(
- 'a crash with no output at all is could-not-run, never clean',
- classify({ status: 137, output: '' }) === 'cannot-run',
- );
- t(
- 'a contradictory zero-exit-with-sentinel is could-not-run, not silently clean',
- classify({ status: 0, output: RED }) === 'cannot-run',
- );
- t('a null status (killed by signal) is could-not-run', classify({ status: null, output: '' }) === 'cannot-run');
-
- // ── the names are a convenience; they never decide anything ───────────────
- const names = namesFromOutput(RED);
- t('names the unscoped package whose copy is removed', names.includes('zod'));
- t('names a SCOPED package without eating its leading @', names.includes('@objectstack/spec'));
- t('strips the peer suffix rather than reporting it as part of the name', names.includes('ai'));
- t('does not report the ADDED identity as removed', !names.includes('zod-validation-error'));
- t('reports each name once, sorted', JSON.stringify(names) === JSON.stringify([...new Set(names)].sort()));
- t('finds nothing in clean output', namesFromOutput(GREEN).length === 0);
-
- t(
- 'CONTROL — a red verdict still reds when the parse names nothing',
- renderVerdict({ outcome: 'findings', names: [], output: RED }).some((l) => l.startsWith('VERDICT not deduped')),
- );
-
- // ── the rendered verdicts say which of the three answers this was ─────────
- t('clean renders VERDICT deduped', renderVerdict({ outcome: 'clean' })[0].startsWith('VERDICT deduped'));
- t(
- 'a finding names the packages and points at `pnpm dedupe`',
- renderVerdict({ outcome: 'findings', names, output: RED }).join('\n').includes('`pnpm dedupe`'),
- );
- t(
- 'a finding forbids the rejected remedies by name',
- renderVerdict({ outcome: 'findings', names, output: RED }).join('\n').includes('pnpm.overrides'),
- );
- t(
- 'could-not-run says it judged nothing',
- renderVerdict({ outcome: 'cannot-run' }).join('\n').includes('Nothing here was judged'),
- );
-
- // ── objectui#9562: the finding text names the INSTRUMENT, in both modes ───
- for (const reportOnly of [false, true]) {
- const label = reportOnly ? 'report-only' : 'blocking';
- const finding = renderVerdict({ outcome: 'findings', names, output: RED, reportOnly }).join('\n');
- t(`${label} mode: the finding says to re-run before acting`, finding.includes('RE-RUN this check'));
- t(
- `${label} mode: ⛔ the unconditional instruction to commit a dedupe is GONE`,
- !finding.includes('Fix it HERE'),
- );
- t(`${label} mode: the finding still names the packages`, finding.includes('zod'));
- }
-
- t(
- 'only report-only mode says the job will not fail',
- renderVerdict({ outcome: 'findings', names, output: RED, reportOnly: true }).join('\n').includes('REPORT ONLY') &&
- !renderVerdict({ outcome: 'findings', names, output: RED }).join('\n').includes('REPORT ONLY'),
- );
- t(
- 'report-only could-not-run says it will not fail, and still says it judged nothing',
- renderVerdict({ outcome: 'cannot-run', reportOnly: true }).join('\n').includes('REPORT ONLY') &&
- renderVerdict({ outcome: 'cannot-run', reportOnly: true }).join('\n').includes('Nothing here was judged'),
- );
-
- // ── the step summary: same reading, other surface ────────────────────────
- t('a clean tree writes NO step summary — the block itself is the signal', renderStepSummary({ outcome: 'clean' }) === '');
- const summary = renderStepSummary({ outcome: 'findings', names, output: RED });
- t('the step summary names the split', names.every((name) => summary.includes(name)));
- t('the step summary carries pnpm\'s own output', summary.includes(DEDUPE_SENTINEL));
- t('the step summary carries the instrument caveat, from the one copy', summary.includes(INSTRUMENT_CAVEAT[0]));
- const cannotSummary = renderStepSummary({ outcome: 'cannot-run' });
- t(
- 'the could-not-run summary stays distinguishable from a deduped one',
- cannotSummary.includes('could not take a reading') && !cannotSummary.includes('not deduped'),
- );
- t(
- 'CONTROL — a finding summary still renders when the parse names nothing',
- renderStepSummary({ outcome: 'findings', names: [], output: RED }).includes('not deduped'),
- );
- t(
- 'the step summary is written only where the runner asked for one',
- writeStepSummary(summary, {}) === false && writeStepSummary('', { GITHUB_STEP_SUMMARY: '/dev/null' }) === false,
- );
-
- const failed = cases.filter((c) => !c.ok);
- for (const c of failed) console.error(` ✗ ${c.name}`);
- if (failed.length) {
- console.error(`✗ check-lockfile-dedupe self-test: ${failed.length} of ${cases.length} case(s) failed.`);
- return 1;
- }
- console.log(
- `✓ check-lockfile-dedupe self-test: ${cases.length} cases pass ` +
- '(the three verdicts, the crash-vs-finding split, the report-only rendering, and the controls ' +
- 'a name-parser-driven gate would fail).',
- );
- return 0;
-}
-
-if (isEntrypoint(import.meta.url)) {
- const argv = process.argv.slice(2);
- if (argv.includes('--self-test')) {
- process.exitCode = selfTest();
- } else {
- try {
- process.exitCode = main(argv);
- } catch (error) {
- // ⛔ The catch-all obeys the flag too. A mode that reports every measured
- // outcome and then reds on a crash of the reporter is still a gate that
- // fails a pull request for a reason that is not about it.
- const reportOnly = argv.includes(REPORT_ONLY_FLAG);
- console.error(
- `::${reportOnly ? 'warning' : 'error'}::check-lockfile-dedupe could not take a reading: ${error instanceof Error ? error.message : String(error)}`,
- );
- console.error('A reading that could not be taken is NOT a deduped lockfile.');
- process.exitCode = reportOnly ? EXIT_CLEAN : EXIT_CANNOT_RUN;
- }
- }
-}
diff --git a/ui/scripts/shadcn-check-report.mjs b/ui/scripts/shadcn-check-report.mjs
deleted file mode 100644
index f00702b61f5..00000000000
--- a/ui/scripts/shadcn-check-report.mjs
+++ /dev/null
@@ -1,611 +0,0 @@
-#!/usr/bin/env node
-/**
- * Classifies one run of `.github/workflows/shadcn-check.yml` and renders the
- * alarm issue body it may need to open.
- *
- * ## Why this is a file and not an inline `script:` block
- *
- * PR #3497 built the alarm channel this workflow has: the `check` step's exit
- * code stopped being swallowed, was sorted into three classes, and the two
- * classes worth waking someone for were routed into a tracking issue — because
- * "this workflow runs weekly on a schedule, and a red run on a page nobody
- * opens is not an alarm, an issue in the triage queue is".
- *
- * All of that logic lived in YAML, so none of it could be tested; #3497 checked
- * it by hand-running five fixtures once and wrote the result into its PR body.
- * objectui#3586 adds a fourth classified input (the `analyze` step's exit code)
- * and a fifth (cross-run registry reachability), which is more branching than a
- * shell block that nothing executes between weekly runs should carry. The
- * classification is therefore extracted here and covered by
- * `scripts/__tests__/shadcn-check-report.test.ts`, the same way
- * `scripts/render-budget-comment.mjs` was extracted for
- * `.github/workflows/performance-budget.yml`.
- *
- * ## The classification, and the three gaps objectui#3586 closes
- *
- * `check` keeps #3497's vocabulary exactly — the tolerance ruling is unchanged:
- *
- * patch the patch gate's own verdict line is present. ALARM.
- * ok exit 0 and no such verdict. Includes an unreachable registry,
- * which the script reports per component and still exits 0.
- * broken any other non-zero exit — the check could not run at all. ALARM.
- *
- * Extended, not replaced:
- *
- * analyze `ok` | `broken`. `component-analysis.js` has exactly one non-zero
- * exit — an uncaught crash in `main()`. #3497 left the step
- * `continue-on-error: true` because there was "no verdict here to
- * swallow"; that is true of the *drift* verdict and false of the
- * step itself, so a crash turned the job red on an unwatched weekly
- * schedule and reached nobody. `broken` now enters the SAME issue
- * channel — same labels, same de-duplication, no second channel.
- *
- * registry `reachable` | `unreachable`, plus how many consecutive runs have
- * been `unreachable`. A single unreachable run stays tolerated
- * exactly as #3497 ruled (exit 0 + `::warning::` + summary, no
- * issue). What was missing is that the tolerance never expired:
- * with the registry unreachable the upstream-anchor half of the
- * check does not execute, so N consecutive unreachable runs are N
- * consecutive weeks of proving nothing, and that never escalated.
- * At `ESCALATION_THRESHOLD` it does.
- *
- * The predicate for `unreachable` is deliberately the SAME one #3497 already
- * warns on (`registry_errors > 0`), not a new threshold. One tolerated
- * condition, one definition: the escalation says "we have now tolerated this N
- * runs running", and a single component whose URL moved for good is a real
- * finding by week three, not noise.
- */
-
-import fs from 'node:fs';
-import path from 'node:path';
-import { isEntrypoint } from './invoked-as.mjs';
-
-/**
- * How many consecutive unreachable runs escalate into the issue channel.
- *
- * The cron is weekly, so N is measured in weeks of blindness:
- *
- * N=2 (14 days) fires on any fortnight-long upstream or egress hiccup — a CDN
- * change or a runner-egress policy edit plausibly spans two Mondays, and
- * alarming on it is the noise #3497's single-run tolerance was ruled to
- * avoid.
- * N=3 (21 days) is well past "transient" for a registry that answered all 46
- * requests in ~1.7s as recently as run 31374857502, and still inside
- * every retention window the mechanism depends on (see
- * `readRegistryStreak`).
- * N=4 (28 days) is a month of an early-warning system warning about nothing,
- * and would sit outside the 30-day artifact retention had the artifact
- * mechanism been the one chosen.
- *
- * Three. Changing it is a one-line edit here; the workflow reads it from this
- * module rather than repeating it.
- */
-export const ESCALATION_THRESHOLD = 3;
-
-/**
- * The two step names that carry this run's registry reachability forward to the
- * next run.
- *
- * This is a CONTRACT BETWEEN RUNS, and the only one in the mechanism that a
- * rename can break silently: a run whose marker steps are named something else
- * reads back as `unknown`, which resets the streak, which means the escalation
- * quietly never fires — the exact silent-failure shape this card exists to
- * close. `scripts/__tests__/shadcn-check-report.test.ts` pins the workflow's
- * step names against these constants so the rename goes red instead.
- */
-export const MARKER_STEPS = Object.freeze({
- reachable: 'Cross-run marker: registry reachable',
- unreachable: 'Cross-run marker: registry unreachable',
-});
-
-/** How much of each captured log is quoted into the issue body. #3497's cap. */
-const LOG_EXCERPT_LIMIT = 5000;
-
-/**
- * The two shapes `scripts/shadcn-sync.js` produces when the registry cannot be
- * served: a rejected fetch, and a 2xx whose file content is unusable (proxy
- * error page, egress block, schema change). Same pair #3497 counted.
- */
-const REGISTRY_ERROR_LINE = /Registry returned no usable file content|Error fetching from registry:/;
-
-/** The patch gate's own verdict line — the evidence, tested before the exit code. */
-const PATCH_VERDICT_LINE = 'component(s) with declared local patch failures';
-
-/**
- * `ESC` is written as an escape sequence, never as the byte itself
- * (objectstack#4890, and `scripts/check-control-bytes.mjs`). Both shadcn
- * scripts colour every line unconditionally, with no TTY or NO_COLOR check, so
- * the raw capture is dense with escapes and an issue body quoting it verbatim
- * is unreadable.
- */
-const ANSI_SEQUENCE = /\u001b\[[0-9;]*[A-Za-z]/g;
-
-/** @param {string} text */
-export function stripAnsi(text) {
- return text.replace(ANSI_SEQUENCE, '');
-}
-
-/** @param {string} output ANSI-stripped `pnpm shadcn:check` output */
-export function countRegistryErrors(output) {
- return output.split('\n').filter((line) => REGISTRY_ERROR_LINE.test(line)).length;
-}
-
-/**
- * #3497's three classes, unchanged, plus the registry state derived from the
- * same output.
- *
- * The verdict line is tested BEFORE the exit code on purpose, and that ordering
- * is #3497's ruling, quoted: "the message is the evidence, the exit code is a
- * policy that a later change to the script could revise without touching this
- * workflow".
- *
- * @param {{ output?: string, exitCode?: number|string }} input
- */
-export function classifyCheck({ output = '', exitCode = 0 } = {}) {
- const status = Number(exitCode);
- const registryErrors = countRegistryErrors(output);
-
- let checkClass;
- if (output.includes(PATCH_VERDICT_LINE)) {
- checkClass = 'patch';
- } else if (status === 0) {
- checkClass = 'ok';
- } else {
- checkClass = 'broken';
- }
-
- return {
- checkClass,
- registryErrors,
- registryState: registryErrors > 0 ? 'unreachable' : 'reachable',
- };
-}
-
-/**
- * `component-analysis.js` is offline and reads only local files; its single
- * non-zero exit is the `main().catch()` at the bottom of the file. So there are
- * exactly two states, and `broken` alarms.
- *
- * @param {{ exitCode?: number|string }} input
- */
-export function classifyAnalyze({ exitCode = 0 } = {}) {
- return Number(exitCode) === 0 ? 'ok' : 'broken';
-}
-
-/**
- * Read one previous run's registry state out of its `/actions/runs/{id}/jobs`
- * payload.
- *
- * Three-valued on purpose. `unknown` is not a synonym for `reachable`: it means
- * the run never reached a verdict (it died at checkout or install), or it ran a
- * version of the workflow older than the markers. Both break the streak, which
- * under-alarms rather than over-alarms — the honest direction for a mechanism
- * whose whole job is to eventually open an issue.
- *
- * Every job is searched rather than the one named in the workflow: the job name
- * is not part of this contract, and matching on it would add a second string a
- * rename could silently break.
- *
- * @param {{ steps?: Array<{ name?: string, conclusion?: string }> }[]} jobs
- */
-export function registryStateFromJobs(jobs = []) {
- for (const job of jobs) {
- for (const step of job?.steps ?? []) {
- if (step?.conclusion !== 'success') continue;
- if (step.name === MARKER_STEPS.unreachable) return 'unreachable';
- if (step.name === MARKER_STEPS.reachable) return 'reachable';
- }
- }
- return 'unknown';
-}
-
-/**
- * Length of the current unreachable streak, current run included.
- *
- * @param {string[]} states newest-first, current run at index 0
- */
-export function consecutiveUnreachable(states = []) {
- let streak = 0;
- for (const state of states) {
- if (state !== 'unreachable') break;
- streak += 1;
- }
- return streak;
-}
-
-/**
- * The alarm decision. Every reason routes to ONE channel (see
- * `renderAlarmIssue`); this function only says whether to open it and why.
- *
- * `streak-unreadable` alarms, and that is #3497's rule applied to the new
- * moving part: "a check that cannot report is not a passing check". If the
- * cross-run read breaks for good, the escalation can never fire again, and a
- * silently dead alarm channel is the whole bug family this workflow keeps
- * closing. It costs a false alarm on a hard GitHub API outage, which is rare,
- * loud and self-explaining — the cheap direction to be wrong in.
- *
- * @param {{ checkClass: string, analyzeClass: string, registryStreak: number,
- * streakReadable?: boolean, threshold?: number }} input
- */
-export function decideAlarm({
- checkClass,
- analyzeClass,
- registryStreak = 0,
- streakReadable = true,
- threshold = ESCALATION_THRESHOLD,
-}) {
- const reasons = [];
- if (checkClass === 'patch') reasons.push('patch');
- if (checkClass === 'broken') reasons.push('check-broken');
- if (analyzeClass === 'broken') reasons.push('analyze-broken');
- if (registryStreak >= threshold) reasons.push('registry-blind');
- if (!streakReadable) reasons.push('streak-unreadable');
- return { alarm: reasons.length > 0, reasons };
-}
-
-/**
- * The issue title. Only ever used when there is no open `shadcn-sync` issue —
- * an existing one is commented on instead, which is why the title is chosen
- * from the most actionable reason rather than trying to name all of them.
- *
- * @param {string[]} reasons
- * @param {number} streak
- */
-export function alarmTitle(reasons, streak = 0) {
- if (reasons.includes('patch')) return 'Shadcn sync: declared local patches are failing';
- if (reasons.includes('check-broken') || reasons.includes('analyze-broken') || reasons.includes('streak-unreadable')) {
- return 'Shadcn sync: the weekly component check could not run';
- }
- if (reasons.includes('registry-blind')) {
- return `Shadcn sync: the registry has been unreachable for ${streak} consecutive runs`;
- }
- return 'Shadcn sync: the weekly component check needs attention';
-}
-
-const excerpt = (text) => text.slice(0, LOG_EXCERPT_LIMIT);
-
-/**
- * Renders the issue body. One body for every reason: the channel is shared, so
- * a reader who opens it for a patch failure and finds the registry has also
- * been blind for three weeks learns both facts at once.
- *
- * @param {object} input
- */
-export function renderAlarmIssue({
- reasons = [],
- checkClass = 'ok',
- checkExit = 0,
- analyzeClass = 'ok',
- analyzeExit = 0,
- registryErrors = 0,
- registryStreak = 0,
- streakReadable = true,
- streakError = '',
- threshold = ESCALATION_THRESHOLD,
- runUrl = '',
- analysisLog = '',
- checkLog = '',
-} = {}) {
- const title = alarmTitle(reasons, registryStreak);
- let body = '## Shadcn Components Status Report\n\n';
-
- if (reasons.includes('patch')) {
- body += 'The weekly component sync check found a **declared local patch failure**: ';
- body += 'either a required edit is missing from the file on disk, or upstream moved ';
- body += 'the anchor it is applied to, so the next `pnpm shadcn:update` would refuse ';
- body += 'to write rather than drop it. Details in the check output below.\n\n';
- }
- if (reasons.includes('check-broken')) {
- body += 'The weekly component sync check **could not complete**: `pnpm shadcn:check` ';
- body += 'exited `' + checkExit + '` without reaching a patch verdict. ';
- body += 'Until this is fixed the weekly upstream early-warning is not running.\n\n';
- }
- if (reasons.includes('analyze-broken')) {
- body += 'The offline analysis step **crashed**: `pnpm shadcn:analyze` exited `' + analyzeExit + '`. ';
- body += 'Its only non-zero exit is an uncaught error, so this is a defect in ';
- body += '`scripts/component-analysis.js` or in what it reads — the crash output is ';
- body += 'quoted below.\n\n';
- }
- if (reasons.includes('registry-blind')) {
- body += 'The registry has been unreachable for **' + registryStreak + ' consecutive runs** ';
- body += '(threshold: ' + threshold + '). Each run on its own was tolerated by design and exited 0, ';
- body += 'but with the registry unreachable the upstream-anchor half of the check never ';
- body += 'executes: those runs proved nothing about upstream, and the tolerance has now ';
- body += 'been extended long enough to be worth a look.\n\n';
- }
- if (reasons.includes('streak-unreadable')) {
- body += 'The cross-run registry state **could not be read**' + (streakError ? ' (' + streakError + ')' : '') + '. ';
- body += 'Consecutive-unreachability can no longer escalate until this is fixed, so it is ';
- body += 'reported here rather than tolerated.\n\n';
- }
-
- body += '- `pnpm shadcn:check` exit code: `' + checkExit + '` (class: `' + checkClass + '`)\n';
- body += '- `pnpm shadcn:analyze` exit code: `' + analyzeExit + '` (class: `' + analyzeClass + '`)\n';
- body += '- Components the registry could not serve: ' + registryErrors + '\n';
- body += '- Consecutive runs with an unreachable registry: ' +
- (streakReadable ? registryStreak + ' (escalates at ' + threshold + ')' : 'unknown — cross-run read failed') + '\n';
- if (runUrl) body += '- Run: ' + runUrl + '\n';
- body += '\n';
-
- if (analysisLog) {
- body += '### Offline Analysis\n\n';
- body += '```\n' + excerpt(analysisLog) + '\n```\n\n';
- }
- if (checkLog) {
- body += '### Online Check Results\n\n';
- body += '```\n' + excerpt(checkLog) + '\n```\n\n';
- }
-
- body += '### Next Steps\n\n';
- if (reasons.includes('patch')) {
- body += '1. Read the `DECLARED LOCAL PATCHES` section above — it names the patch id, its tracking issue and the reason\n';
- body += '2. Marker missing from disk: restore it with `pnpm shadcn:update `\n';
- body += '3. Anchor no longer found upstream: re-target `find`/`occurrences` in `scripts/shadcn-local-patches.mjs`\n';
- } else if (reasons.includes('registry-blind') && reasons.length === 1) {
- body += '1. Check whether the registry URLs in `packages/components/shadcn-components.json` still resolve\n';
- body += '2. Reproduce locally with `pnpm shadcn:check --no-cache` — a local success points at runner egress\n';
- body += '3. If upstream moved for good, re-point the sources; the check has proven nothing for ' +
- registryStreak + ' runs\n';
- } else {
- body += '1. Open the workflow run linked above and read the failure\n';
- body += '2. Reproduce locally with `pnpm shadcn:analyze` / `pnpm shadcn:check`\n';
- }
- body += '4. See [SHADCN_SYNC.md](../blob/main/docs/SHADCN_SYNC.md) for detailed guide\n\n';
- body += '> This issue was automatically created by the Shadcn Components Check workflow.\n';
-
- return { title, body };
-}
-
-// ── Cross-run state ─────────────────────────────────────────────────────────
-
-/**
- * The Actions REST surface this mechanism needs, injectable so the tests never
- * touch the network.
- *
- * The workflow file name is DERIVED from `GITHUB_WORKFLOW_REF`
- * (`owner/repo/.github/workflows/shadcn-check.yml@refs/heads/main`) rather than
- * spelled out: a hard-coded copy is a second source of truth that a file rename
- * would break silently, and silence is the failure mode being closed.
- */
-export function createActionsApi({
- token = process.env.GITHUB_TOKEN ?? '',
- apiUrl = process.env.GITHUB_API_URL ?? 'https://api.github.com',
- repository = process.env.GITHUB_REPOSITORY ?? '',
- workflowRef = process.env.GITHUB_WORKFLOW_REF ?? '',
- fetchImpl = globalThis.fetch,
-} = {}) {
- const workflowFile = path.basename((workflowRef.split('@')[0] ?? '').trim());
- if (!workflowFile) throw new Error('GITHUB_WORKFLOW_REF did not yield a workflow file name');
- if (!repository) throw new Error('GITHUB_REPOSITORY is not set');
-
- const get = async (route) => {
- const res = await fetchImpl(`${apiUrl}${route}`, {
- headers: {
- accept: 'application/vnd.github+json',
- 'x-github-api-version': '2022-11-28',
- ...(token ? { authorization: `Bearer ${token}` } : {}),
- },
- });
- if (!res.ok) throw new Error(`GET ${route} -> HTTP ${res.status}`);
- return res.json();
- };
-
- return {
- listRuns: (perPage) =>
- get(`/repos/${repository}/actions/workflows/${workflowFile}/runs?status=completed&per_page=${perPage}`),
- listJobs: (runId) => get(`/repos/${repository}/actions/runs/${runId}/jobs?per_page=100`),
- };
-}
-
-/**
- * How many consecutive runs — this one included — found the registry
- * unreachable.
- *
- * ## Why the Actions API and not the alternatives (objectui#3586, measured)
- *
- * The card named two candidates and asked for the cheaper honest one. Both were
- * costed, and two more were found on the way:
- *
- * previous run's `conclusion` 1 call, and structurally BLIND. #3497's
- * tolerance ruling makes an unreachable run exit 0, so its conclusion is
- * `success` — byte-identical to a clean run (run 31374857502: `success`,
- * 0 registry errors). It cannot express the distinction at any price.
- *
- * previous runs' STEP conclusions 1 + (N-1) calls = 3 at N=3, `actions:
- * read`, no new artifacts, and the state lives as long as the run itself
- * (90 days by default, ~13 weekly runs). Emitting it costs two no-op steps.
- * CHOSEN.
- *
- * previous runs' artifacts the existing `shadcn-analysis` artifact already
- * carries `check.txt`, so nothing new is emitted — but reading it is list +
- * download + unzip per run (1 + 2(N-1) calls plus transfers), and retention
- * is 30 days ≈ 4 weekly runs, which leaves no headroom above N=3.
- *
- * previous runs' annotations 1 + 2(N-1) = 5 calls at N=3, and the state is
- * the `::warning::` PROSE, re-parsed a week later.
- *
- * comments on the alarm issue 1-2 calls with the already-granted
- * `issues: write`, and disqualified on design before cost: a tolerated
- * unreachable run has no issue to comment on (`label:shadcn-sync` has
- * matched 0 issues, ever). Recording there means either opening the issue on
- * run 1 — which IS the alarm, contradicting the single-run tolerance ruling
- * this card leaves untouched — or a private state issue, i.e. the second
- * channel the card forbids.
- *
- * `actions/cache` ~0 API cost, and disqualified: GitHub evicts entries 7
- * days after last access and the cron is exactly 7 days, so the state sits
- * on the eviction boundary. A silently reset streak is an escalation that
- * never fires — the failure shape being closed, rebuilt inside the fix.
- *
- * At most `threshold - 1` job reads happen: the walk stops at the first run
- * that was reachable or unknown, and never needs to look past the threshold.
- *
- * @param {{ currentState: string, threshold?: number, currentRunId?: string|number, api?: object }} input
- */
-export async function readRegistryStreak({
- currentState,
- threshold = ESCALATION_THRESHOLD,
- currentRunId = process.env.GITHUB_RUN_ID ?? '',
- api,
-}) {
- if (currentState !== 'unreachable') return { streak: 0, readable: true, error: '' };
-
- const states = ['unreachable'];
- try {
- const client = api ?? createActionsApi();
- const { workflow_runs: runs = [] } = await client.listRuns(threshold + 2);
- const previous = runs.filter((run) => String(run.id) !== String(currentRunId));
-
- for (const run of previous) {
- if (states.length >= threshold) break;
- const { jobs = [] } = await client.listJobs(run.id);
- const state = registryStateFromJobs(jobs);
- states.push(state);
- if (state !== 'unreachable') break;
- }
- } catch (error) {
- return { streak: consecutiveUnreachable(states), readable: false, error: String(error?.message ?? error) };
- }
-
- return { streak: consecutiveUnreachable(states), readable: true, error: '' };
-}
-
-// ── Workflow entry point ────────────────────────────────────────────────────
-
-const readIfPresent = (file) => {
- try {
- return fs.readFileSync(file, 'utf8');
- } catch {
- return '';
- }
-};
-
-const appendTo = (envVar, text) => {
- const target = process.env[envVar];
- if (target) fs.appendFileSync(target, text.endsWith('\n') ? text : `${text}\n`);
-};
-
-/**
- * Classifies the run, records the verdict where the workflow can read it, and
- * writes the alarm body when there is one.
- *
- * Exits 0 in every classified case, including every alarm: the alarm is the
- * issue, not the job colour — #3497's ruling, and the reason gap ③ existed at
- * all. The one thing that still turns the job red is this file itself throwing,
- * which is the irreducible bottom of the mechanism and the reason the logic was
- * moved somewhere it can be unit tested.
- */
-export async function main({ api } = {}) {
- const checkExit = Number(process.env.CHECK_EXIT_CODE ?? 0);
- const analyzeExit = Number(process.env.ANALYZE_EXIT_CODE ?? 0);
-
- const checkLog = stripAnsi(readIfPresent(process.env.CHECK_RAW_LOG ?? 'check.ansi.txt'));
- const analysisLog = stripAnsi(readIfPresent(process.env.ANALYZE_RAW_LOG ?? 'analysis.ansi.txt'));
- fs.writeFileSync(process.env.CHECK_LOG ?? 'check.txt', checkLog);
- fs.writeFileSync(process.env.ANALYZE_LOG ?? 'analysis.txt', analysisLog);
-
- const { checkClass, registryErrors, registryState } = classifyCheck({ output: checkLog, exitCode: checkExit });
- const analyzeClass = classifyAnalyze({ exitCode: analyzeExit });
- const { streak, readable, error } = await readRegistryStreak({ currentState: registryState, api });
- const { alarm, reasons } = decideAlarm({
- checkClass,
- analyzeClass,
- registryStreak: streak,
- streakReadable: readable,
- });
-
- const runUrl = process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
- ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
- : '';
-
- const { title, body } = renderAlarmIssue({
- reasons,
- checkClass,
- checkExit,
- analyzeClass,
- analyzeExit,
- registryErrors,
- registryStreak: streak,
- streakReadable: readable,
- streakError: error,
- runUrl,
- analysisLog,
- checkLog,
- });
-
- if (alarm) fs.writeFileSync(process.env.ALARM_BODY_FILE ?? 'alarm-issue.md', body);
-
- appendTo(
- 'GITHUB_OUTPUT',
- [
- `check_exit_code=${checkExit}`,
- `check_class=${checkClass}`,
- `analyze_exit_code=${analyzeExit}`,
- `analyze_class=${analyzeClass}`,
- `registry_errors=${registryErrors}`,
- `registry_state=${registryState}`,
- `registry_streak=${streak}`,
- `streak_readable=${readable}`,
- `alarm=${alarm}`,
- `alarm_reasons=${reasons.join(',')}`,
- `issue_title=${title}`,
- ].join('\n'),
- );
-
- const summary = [
- '### Shadcn component check',
- '',
- `- \`pnpm shadcn:analyze\` exit code: \`${analyzeExit}\` (class: \`${analyzeClass}\`)`,
- `- \`pnpm shadcn:check\` exit code: \`${checkExit}\` (class: \`${checkClass}\`)`,
- `- components the registry could not serve: ${registryErrors}`,
- ];
-
- if (analyzeClass === 'broken') {
- console.log(`::error::pnpm shadcn:analyze crashed (exit ${analyzeExit}). Opening/updating the tracking issue.`);
- summary.push('- Verdict: the offline analysis step crashed. Tracking issue opened or updated.');
- }
- if (checkClass === 'patch') {
- console.log(`::error::Declared local patches are failing (exit ${checkExit}). Opening/updating the tracking issue.`);
- summary.push('- Verdict: a declared local patch failed. Tracking issue opened or updated.');
- } else if (checkClass === 'broken') {
- console.log(
- `::error::shadcn:check exited ${checkExit} without a patch verdict — the check itself could not run. Opening/updating the tracking issue.`,
- );
- summary.push('- Verdict: the check could not run. Tracking issue opened or updated.');
- } else if (registryErrors > 0) {
- if (reasons.includes('registry-blind')) {
- console.log(
- `::error::The registry has been unreachable for ${streak} consecutive runs (threshold ${ESCALATION_THRESHOLD}) — the upstream-anchor check has not run in that time. Opening/updating the tracking issue.`,
- );
- summary.push(
- `- Verdict: unreachable for ${streak} consecutive runs — tolerance exhausted. Tracking issue opened or updated.`,
- );
- } else {
- // Tolerated, but never reported as a clean bill of health: with the
- // registry unreachable the upstream-anchor half of the check did not
- // execute, so this run proved nothing about upstream. #3497's ruling for
- // runs 1..N-1, unchanged — only the counter is new.
- console.log(
- `::warning::${registryErrors} component(s) could not be fetched from the registry, so the upstream-anchor check did not run. Tolerated by design — no issue opened (consecutive unreachable runs: ${streak} of ${ESCALATION_THRESHOLD}).`,
- );
- summary.push(
- `- Verdict: no patch failure, but the online half did not run (registry unreachable ${streak} run(s) running, escalates at ${ESCALATION_THRESHOLD}). Tolerated, no issue opened.`,
- );
- }
- } else if (analyzeClass === 'ok') {
- summary.push('- Verdict: all declared local patches still apply to current upstream.');
- }
-
- if (!readable) {
- console.log(`::error::The cross-run registry state could not be read (${error}). Opening/updating the tracking issue.`);
- summary.push('- Verdict: the cross-run state read failed — escalation is blind. Tracking issue opened or updated.');
- }
-
- appendTo('GITHUB_STEP_SUMMARY', summary.join('\n'));
- return { alarm, reasons, checkClass, analyzeClass, registryState, streak };
-}
-
-const invokedDirectly = isEntrypoint(import.meta.url);
-if (invokedDirectly) {
- await main();
-}