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(); -}