From fba1706081d854ed159bbc24fb0b7bc0ff18c0f9 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Wed, 14 Oct 2026 13:43:38 -0700 Subject: [PATCH] fix(packs): allow safe local candidate smoke tests Signed-off-by: Lucas Braum --- .changeset/local-candidate-smoke.md | 5 + CONTRIBUTING.md | 11 +- README.md | 4 +- docs/commands/check.md | 9 + docs/commands/fix.md | 9 + docs/packs.md | 8 +- packages/cli/src/alternate-lead.test.ts | 44 +++- packages/cli/src/cli.test.ts | 34 +++ packages/cli/src/cli.ts | 32 ++- packages/cli/src/format-check.test.ts | 10 + packages/cli/src/format-check.ts | 7 +- packages/cli/src/jobs.ts | 2 + .../core/src/action/candidate-entry.test.ts | 32 +++ packages/core/src/action/engine.ts | 4 + packages/core/src/action/run.test.ts | 43 ++++ packages/core/src/action/run.ts | 14 +- packages/core/src/check/check.ts | 19 +- packages/core/src/domain/report.ts | 2 + packages/core/src/fix/isolate.ts | 5 +- packages/core/src/fix/peer-preflight.ts | 2 +- packages/core/src/fix/publish.test.ts | 11 + packages/core/src/fix/publish.ts | 1 + packages/core/src/fix/range-preflight.ts | 2 +- packages/core/src/fix/report.ts | 13 +- packages/core/src/fix/run.ts | 27 ++- packages/core/src/fix/types.ts | 2 + packages/core/src/packs/candidate.test.ts | 219 ++++++++++++++++++ packages/core/src/packs/index.ts | 42 +++- packages/core/src/run-report/build.ts | 4 +- scripts/action.ts | 11 + 30 files changed, 589 insertions(+), 39 deletions(-) create mode 100644 .changeset/local-candidate-smoke.md create mode 100644 packages/core/src/action/candidate-entry.test.ts create mode 100644 packages/core/src/packs/candidate.test.ts diff --git a/.changeset/local-candidate-smoke.md b/.changeset/local-candidate-smoke.md new file mode 100644 index 0000000..bcf0f02 --- /dev/null +++ b/.changeset/local-candidate-smoke.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +Allow local check and fix smoke tests of registered candidate packs with --allow-candidate, with an explicit unverified banner. Reject candidate opt-in in the Action and with publication flags, and prevent stored candidate runs from being published. Label pack-claimed assisted sites by rule id even with --no-llm. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04a0131..6cbf3ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,7 +140,16 @@ open a **Pack request** issue, and say you are writing it. ``` Every false positive among breaking findings fails it. Fix the rule, not the ground truth. A false negative is a site to cover or a known gap to say in the pull request. -7. **Open the pull request** with the pack template (`?template=pack.md`), with the +7. **Smoke-test locally** without editing `verification.json`: + ```sh + pnpm uptide check --allow-candidate --cwd /path/to/local-fixture + pnpm uptide fix --allow-candidate --no-llm --cwd /path/to/local-fixture + ``` + Only registered packs with a recorded `candidate` status qualify. The output, stored + report and PR body preview say **candidate pack, not verified**. The Action rejects this + flag, as do `--pr` and `--yes`; candidate runs cannot be published later with `uptide pr`. + Verified packs behave as usual. Do not mark a pack verified to smoke-test it. +8. **Open the pull request** with the pack template (`?template=pack.md`), with the `pack test` output pasted in, and a changeset. `pnpm uptide` runs the CLI from source, so nothing needs building while you work. diff --git a/README.md b/README.md index e0361ce..8cb30df 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,8 @@ The full statement and the isolation model: [privacy](docs/privacy.md) and [SECU ## Contributing -Setup, the rules that do not bend, how to write a migration pack and how to sign off: -[CONTRIBUTING.md](CONTRIBUTING.md). Security problems go through [SECURITY.md](SECURITY.md). +[CONTRIBUTING.md](CONTRIBUTING.md) covers setup, safety, pack authoring and sign-off. Smoke-test packs with `check --allow-candidate` or `fix --allow-candidate`: reports say “candidate pack, not verified”. +The flag is local only: the Action, `--pr` and `--yes` reject it. Security problems: [SECURITY.md](SECURITY.md). ## License diff --git a/docs/commands/check.md b/docs/commands/check.md index b849560..ccbcdc1 100644 --- a/docs/commands/check.md +++ b/docs/commands/check.md @@ -137,3 +137,12 @@ an npm package, without a repository. Tarballs, surfaces and runtime probes are changes found at your call sites; **2** bad arguments, unsupported repository, no network, or nothing breaking was found but a dependency failed to analyze (the report still lists the others). See [concepts](../concepts.md#exit-codes). + +### Candidate pack smoke tests + +`--allow-candidate` opts in to registered packs whose verification record says `candidate`. +The output and report carry “candidate pack, not verified”; verified packs are unchanged, +and unregistered or blocked packs keep their normal behavior. This flag is for local pack +development only: the Action rejects it, and `fix` refuses it with `--pr` or `--yes`. +The generated PR body is a local preview; a stored candidate run cannot be published. +See [Write a pack](../../CONTRIBUTING.md#write-a-pack). diff --git a/docs/commands/fix.md b/docs/commands/fix.md index fa98ccc..98b65f7 100644 --- a/docs/commands/fix.md +++ b/docs/commands/fix.md @@ -296,3 +296,12 @@ root are ever removed. **0** migration verified: no new type errors, tests pass; **1** verification failed or sites remain for manual work; **2** uptide could not answer. See [concepts](../concepts.md#exit-codes). + +### Candidate pack smoke tests + +`--allow-candidate` opts in to registered packs whose verification record says `candidate`. +The output and report carry “candidate pack, not verified”; verified packs are unchanged, +and unregistered or blocked packs keep their normal behavior. This flag is for local pack +development only: the Action rejects it, and `fix` refuses it with `--pr` or `--yes`. +The generated PR body is a local preview; a stored candidate run cannot be published. +See [Write a pack](../../CONTRIBUTING.md#write-a-pack). diff --git a/docs/packs.md b/docs/packs.md index d2bf7aa..1ad07b7 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -269,8 +269,12 @@ false positive among its breaking findings there (precision 100%), with at least breaking finding predicted. Anything else is a **candidate**: - it ships in the tree, and `pack test` scores it; -- `check`, `list` and `fix` treat the dependency as generic: no `verified` label, the +- by default, `check`, `list` and `fix` treat the dependency as generic: no `verified` label, the generic tier's evidence rule, the agent alone in `fix`. +- local `check --allow-candidate` and `fix --allow-candidate` opt in only to registered packs + with an explicit candidate record. Reports and the PR body preview say **candidate pack, + not verified**. The Action, `--pr` and `--yes` reject this flag; stored candidate runs + cannot be published. Never edit `verification.json` just to smoke-test a pack. The CLI reads the status from `verification.json`, which `pack test --write` writes and which records the digest of the ground-truth file it was measured against. CI runs @@ -280,7 +284,7 @@ record no longer matches the evidence, so the label cannot outlive what supports ## The registry, the queue, CI - `packages/core/src/packs/registry.ts` is generated: `pack new` renders it again with the - new entry. Every pack in it is scored by CI; only verified ones are used by the CLI. + new entry. Every pack in it is scored by CI; only verified ones are used by default. - `packs/queue.json` ranks the next packages worth a pack (`pnpm packs:queue`) by direct use × log2(1 + breaking changes). Direct use is how many sample repositories (`fixtures/corpus.json` and the pinned public applications in diff --git a/packages/cli/src/alternate-lead.test.ts b/packages/cli/src/alternate-lead.test.ts index e48b40c..45861dc 100644 --- a/packages/cli/src/alternate-lead.test.ts +++ b/packages/cli/src/alternate-lead.test.ts @@ -13,7 +13,19 @@ vi.mock('@uptide/core', async (original) => { releaseGroup: [{ name: 'router-dom', source }], alternateLeads: [{ name: 'router-dom', source }], }); - return { ...core, activePacks: () => [...core.activePacks(), router] }; + const candidate = { + ...router, + name: 'candidate-router', + alternateLeads: [{ name: 'candidate-dom', source }], + }; + return { + ...core, + activePacks: (allowCandidate = false) => [ + ...core.activePacks(allowCandidate), + router, + ...(allowCandidate ? [candidate] : []), + ], + }; }); /** Declares `router-dom` only, as most React Router 6 applications declare `react-router-dom`. */ @@ -57,3 +69,33 @@ describe('a pack lead asked for, or declared, through its alternate lead', () => expect(io.stderr()).toContain('router is not a dependency'); }); }); + +it.each(['check', 'fix'])( + 'candidate alternate leads pass dependency preflight for %s', + async (command) => { + const e = fakeEngine({ + installed: async () => new Map([['candidate-dom', '1.0.0']]), + declared: async () => new Map([['candidate-dom', '^1.0.0']]), + }); + const cwd = pnpmGitRepo({ + 'package.json': JSON.stringify({ + name: 'shop', + packageManager: 'pnpm@10.17.1', + dependencies: { 'candidate-dom': '^1.0.0' }, + }), + 'node_modules/candidate-dom/package.json': '{"name":"candidate-dom","version":"1.0.0"}', + }); + const io = memoryIo({ cwd }); + expect( + await run( + [command, 'candidate-dom', '--allow-candidate', ...(command === 'fix' ? ['--no-llm'] : [])], + io, + e, + ), + ).toBe(0); + expect(e.calls[0]).toMatchObject({ + allowCandidate: true, + only: command === 'check' ? ['candidate-router'] : 'candidate-router', + }); + }, +); diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index ee86741..e2af5fc 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -895,3 +895,37 @@ it('exits 1 when pr refuses a tree that was not verified', async () => { expect(await run(['pr', '--branch', 'uptide/zod-4.6.5', '--yes'], io, engine)).toBe(1); expect(io.stderr()).toContain('nothing pushed'); }); + +it.each([['--pr'], ['--yes'], ['--pr', '--yes']])( + 'refuses --allow-candidate with publication flags %j before the engine', + async (...flags) => { + const engine = fakeEngine(); + const io = memoryIo({ cwd: '/does-not-exist' }); + expect(await run(['fix', 'zod', '--allow-candidate', ...flags], io, engine)).toBe(2); + expect(io.stderr()).toContain('--allow-candidate cannot be used with --pr or --yes'); + expect(engine.calls).toEqual([]); + }, +); + +it('forwards the local candidate opt-in for check and fix', async () => { + const engine = fakeEngine(); + await run(['check', 'zod', '--allow-candidate'], memoryIo({ cwd: npmRepo() }), engine); + await run( + ['fix', 'zod', '--allow-candidate', '--no-llm'], + memoryIo({ cwd: pnpmGitRepo() }), + engine, + ); + expect(engine.calls).toHaveLength(2); + expect(engine.calls).toEqual([ + expect.objectContaining({ allowCandidate: true }), + expect.objectContaining({ allowCandidate: true }), + ]); +}); + +it.each(['check', 'fix'])('rejects --allow-candidate in GitHub Actions for %s', async (command) => { + const engine = fakeEngine(); + const io = memoryIo({ cwd: '/does-not-exist', env: { GITHUB_ACTIONS: 'true' } }); + expect(await run([command, 'zod', '--allow-candidate'], io, engine)).toBe(2); + expect(io.stderr()).toContain('--allow-candidate is not available in the Uptide Action'); + expect(engine.calls).toEqual([]); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d79c58e..6690cb8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -239,9 +239,10 @@ export async function run( const withAlternates = async ( repo: Repo, deps: Awaited>, + allowCandidate = false, ): Promise>> => { const alternates = (name: string) => - (packLeading(name, activePacks())?.alternateLeads ?? []).map((a) => a.name); + (packLeading(name, activePacks(allowCandidate))?.alternateLeads ?? []).map((a) => a.name); const wanted = deps.filter((d) => d.workspaces.length === 0).flatMap((d) => alternates(d.name)); if (!wanted.length) return deps; const found = await locateDependencies(repo, engine, wanted); @@ -258,6 +259,7 @@ export async function run( repo: Repo, only: string[] | undefined, progress: Progress, + allowCandidate = false, ): Promise => progress.phase( 'Dependencies', @@ -270,7 +272,7 @@ export async function run( if (only) { // Asked for by name and absent is an error, not an empty answer, unless a pack's // alternate lead is declared instead (`react-router-dom` for `react-router`). - const present = await withAlternates(repo, deps); + const present = await withAlternates(repo, deps, allowCandidate); const absent = present.filter((d) => d.workspaces.length === 0).map((d) => d.name); if (absent.length > 0) throw notADependency(repo, absent); requireInstalled(repo, present); @@ -453,6 +455,7 @@ ${PRIVACY}`, shared( program .command('check [packages...]') + .option('--allow-candidate', 'use registered candidate packs for local smoke tests only') .description('Which of your call sites an upgrade breaks, and what fixing them costs') .option('--only ', 'deprecated alias for positional package names') .option('--group ', 'check every member of a group shown by uptide list') @@ -504,6 +507,7 @@ ${EXIT_CODES( flags: Shared & { only?: string; group?: string; + allowCandidate?: boolean; target?: string[]; html?: string | true; open?: boolean; @@ -520,6 +524,8 @@ ${EXIT_CODES( act( flags, async ({ ui, progress, cwd }) => { + if (flags.allowCandidate && io.env.GITHUB_ACTIONS === 'true') + throw new CliError('--allow-candidate is not available in the Uptide Action'); if (flags.open && !flags.html) throw new CliError('--open requires --html'); const started = io.now(); const quiet = !flags.verbose; @@ -527,7 +533,7 @@ ${EXIT_CODES( // An alternate lead's name (`react-router-dom`) asks for its pack's lead. const only = requested.includes('all') ? requested - : leadNames(requested, activePacks()); + : leadNames(requested, activePacks(flags.allowCandidate)); if ((!flags.group && only.length === 0) || only.includes('all')) throw new CliError( 'check requires one or more package names. Start with uptide list.', @@ -582,7 +588,7 @@ ${EXIT_CODES( // Named after what was asked for; an alternate lead's target is its lead's. const targets = Object.fromEntries( Object.entries(parseTargets(flags.target, [...new Set([...requested, ...only])])).map( - ([name, version]) => [leadName(name, activePacks()), version], + ([name, version]) => [leadName(name, activePacks(flags.allowCandidate)), version], ), ); const htmlReport = async (report: CheckReport): Promise => { @@ -628,7 +634,7 @@ ${EXIT_CODES( }; // Without a terminal there is no live line: say what started, then how long it took. if (quiet && !ui.interactive) io.err(`uptide check · ${repoLine(headerOf(repo, 0))}\n`); - await requireDependencies(repo, only, progress); + await requireDependencies(repo, only, progress, flags.allowCandidate); const report = await progress.phase( only ? `Analysis of ${only.join(', ')}` @@ -644,6 +650,7 @@ ${EXIT_CODES( compile: flags.compile ?? true, runtime: flags.runtime ?? true, allDeps: flags.allDeps, + ...(flags.allowCandidate ? { allowCandidate: true } : {}), ...(flags.workspaces ? { workspaceConcurrency: Number(flags.workspaces) } : {}), @@ -855,6 +862,10 @@ ${EXIT_CODES('a plan was made', 'not used', ';\n or a dependency failed to a ).choices(['patch', 'sdk']), ) .option('--debug', 'keep the temporary clone and print every phase (--keep-temp --verbose)') + .option( + '--allow-candidate', + 'use registered candidate packs locally; never with --pr or --yes', + ) .option('--pr', 'open a draft PR after verification passes; requires --yes') .option( '--base ', @@ -907,6 +918,7 @@ ${PRIVACY}`, only?: string; target?: string; allowPeer?: string[]; + allowCandidate?: boolean; includeDeprecated?: boolean; pinCurrentApi?: boolean; llm?: boolean; @@ -930,6 +942,10 @@ ${PRIVACY}`, flags, async ({ ui, progress, cwd }) => { const started = io.now(); + if (flags.allowCandidate && (flags.pr || flags.yes)) + throw new CliError('--allow-candidate cannot be used with --pr or --yes'); + if (flags.allowCandidate && io.env.GITHUB_ACTIONS === 'true') + throw new CliError('--allow-candidate is not available in the Uptide Action'); const quiet = !flags.verbose && !flags.debug; const loaded = settings(cwd, flags.config); if (packageName && flags.only && packageName !== flags.only) @@ -946,7 +962,7 @@ ${PRIVACY}`, namedOutside(loaded, [requested]); // An alternate lead's name (`react-router-dom`) fixes its pack's lead, and a target // named after the alternate is the lead's. - const only = leadName(requested, activePacks()); + const only = leadName(requested, activePacks(flags.allowCandidate)); const target = only === requested ? flags.target @@ -967,7 +983,7 @@ ${PRIVACY}`, }); // No pack, so no rule: without the agent there is nothing this command can do, // and it says so before it touches anything. - const hasPack = activePacks().some((pack) => pack.name === only); + const hasPack = activePacks(flags.allowCandidate).some((pack) => pack.name === only); if (!hasPack && (!llmOn || !selection.available)) throw noAgentForGeneric(only, !llmOn); if (flags.pinCurrentApi && only !== 'stripe') throw new CliError('--pin-current-api applies to stripe only', { @@ -993,6 +1009,7 @@ ${PRIVACY}`, const deps = await withAlternates( found, await locateDependencies(found, engine, [only]), + flags.allowCandidate, ); if (deps.every((d) => d.workspaces.length === 0)) throw notADependency(found, [requested]); @@ -1071,6 +1088,7 @@ ${PRIVACY}`, only, target: target, includeDeprecated: flags.includeDeprecated, + ...(flags.allowCandidate ? { allowCandidate: true } : {}), ...(peers.length ? { also: peers } : {}), ...(flags.allowPeer?.length ? { allowPeer: flags.allowPeer } : {}), ...(flags.pinCurrentApi ? { pinCurrentApi: true } : {}), diff --git a/packages/cli/src/format-check.test.ts b/packages/cli/src/format-check.test.ts index 69aae4a..d462c95 100644 --- a/packages/cli/src/format-check.test.ts +++ b/packages/cli/src/format-check.test.ts @@ -639,3 +639,13 @@ it('says which importing files the memory cap left out, per workspace, in the su expect(details).toContain(' apps/web/src/sdk.ts'); expect(formatCheck(storefront, shown)).not.toContain('memory cap'); }); + +it.each([false, true])('shows the candidate banner in check output (details=%s)', (details) => { + const report = structuredClone(storefront); + const first = report.packages[0]; + if (!first) throw new Error('missing fixture package'); + first.packWarning = 'candidate pack, not verified'; + expect(formatCheck(report, { ...shown, details })).toContain( + `${first.name}: candidate pack, not verified`, + ); +}); diff --git a/packages/cli/src/format-check.ts b/packages/cli/src/format-check.ts index a09b96f..274c022 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -483,6 +483,8 @@ export function formatCheck(report: CheckReport, opts: FormatCheckOptions = {}): lines.push(terminalHeader('check', opts.header, opts.color ?? true), ''); } const rows = checkRows(report, colors); + for (const p of report.packages) + if (p.packWarning) lines.push(colors.yellow(`${p.name}: ${p.packWarning}`), ''); if (opts.details) { lines.push(formatCheckDetails(report, { color: opts.color, all: opts.all }).trimEnd(), ''); } else { @@ -503,7 +505,10 @@ export function formatCheck(report: CheckReport, opts: FormatCheckOptions = {}): text: move(row), tone: row.bump.startsWith('major') ? ('yellow' as const) : ('dim' as const), }, - { text: row.p.tier === 'verified' ? 'verified' : '', tone: 'green' as const }, + { + text: row.p.packWarning ? 'candidate' : row.p.tier === 'verified' ? 'verified' : '', + tone: 'green' as const, + }, { text: row.verdict.replace(COLOR, '') }, { text: row.by, tone: 'dim' as const }, ]), diff --git a/packages/cli/src/jobs.ts b/packages/cli/src/jobs.ts index f496aa9..cfcb63d 100644 --- a/packages/cli/src/jobs.ts +++ b/packages/cli/src/jobs.ts @@ -22,9 +22,11 @@ export type CheckRequest = Pick< | 'allDeps' | 'workspaceConcurrency' | 'maxTimeMs' + | 'allowCandidate' > & { checkResults?: CheckReport }; export interface FixRequest { + allowCandidate?: boolean; provider?: string; model?: string; cwd: string; diff --git a/packages/core/src/action/candidate-entry.test.ts b/packages/core/src/action/candidate-entry.test.ts new file mode 100644 index 0000000..ecc8f37 --- /dev/null +++ b/packages/core/src/action/candidate-entry.test.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; + +it.each([ + { args: ['--allow-candidate'], env: {} }, + { args: ['--allow-candidate=true'], env: {} }, + { args: [], env: { UPTIDE_ALLOW_CANDIDATE: 'true' } }, + { args: [], env: { 'INPUT_ALLOW-CANDIDATE': 'true' } }, +])('the Action entry refuses candidate opt-in before secrets or IO: %j', ({ args, env }) => { + const root = fileURLToPath(new URL('../../../../', import.meta.url)); + const result = spawnSync(process.execPath, ['--import', 'tsx', 'scripts/action.ts', ...args], { + cwd: root, + encoding: 'utf8', + env: { PATH: process.env.PATH, ...env }, + timeout: 10000, + }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain( + '--allow-candidate is not available in the Uptide Action', + ); +}); + +it.each(['--allow-candidate', '--allow-candidate=true'])( + 'the Action engine refuses to forward %s', + async (flag) => { + const { engineFromBuild } = await import('./engine.js'); + await expect(engineFromBuild('/does-not-exist')(['check', 'zod', flag], {})).rejects.toThrow( + '--allow-candidate is not available in the Uptide Action', + ); + }, +); diff --git a/packages/core/src/action/engine.ts b/packages/core/src/action/engine.ts index cd593b7..1d1ab85 100644 --- a/packages/core/src/action/engine.ts +++ b/packages/core/src/action/engine.ts @@ -47,6 +47,10 @@ export function engineEnv( export function engineFromBuild(actionPath: string): RunEngine { return (args, env) => new Promise((resolve, reject) => { + if (args.some((arg) => arg === '--allow-candidate' || arg.startsWith('--allow-candidate='))) { + reject(new Error('--allow-candidate is not available in the Uptide Action')); + return; + } const child = spawn( process.execPath, [join(actionPath, 'packages/cli/dist/index.js'), ...args], diff --git a/packages/core/src/action/run.test.ts b/packages/core/src/action/run.test.ts index 7e25ccf..3890d79 100644 --- a/packages/core/src/action/run.test.ts +++ b/packages/core/src/action/run.test.ts @@ -652,3 +652,46 @@ describe('the Uptide Action run', () => { expect(calls[0]?.args).not.toContain('--config'); }); }); + +it.each(['list', 'check', 'fix'])( + 'rejects candidate opt-in in the Action runner (%s), never forwarding it', + async (mode) => { + const { s, calls, published } = services({ list: listReport([]) }); + const report = await runAction(inputs('/does-not-exist', { mode, allowCandidate: true }), s); + expect(report.error).toContain('--allow-candidate is not available in the Uptide Action'); + expect(reportProblems(report)).toEqual([]); + expect(calls).toEqual([]); + expect(published).toEqual([]); + }, +); + +it('never publishes a candidate accidentally returned by the engine and keeps the v1 schema', async () => { + const fixed = storefrontFix(); + fixed.packWarning = 'candidate pack, not verified'; + fixed.verification.passed = true; + const { s, published, calls } = services({ + list: listReport([dep('zod', '3.25.76', '4.6.5')]), + fix: () => fixRun(fixed), + }); + const report = await runAction(inputs(workspace()), s); + expect(published).toEqual([]); + expect(calls.every((c) => !c.args.includes('--allow-candidate'))).toBe(true); + expect(report.upgrades[0]?.error).toBe('candidate pack, not verified'); + expect(reportProblems(report)).toEqual([]); +}); + +it('preserves the candidate check banner in the existing v1 error field', async () => { + const checked = storefrontCheck() as CheckReport; + const first = checked.packages[0]; + if (!first) throw new Error('missing fixture'); + first.packWarning = 'candidate pack, not verified'; + const { s } = services({ + list: listReport([dep(first.name, first.installed, first.target)]), + check: checked, + }); + const report = await runAction(inputs(workspace(), { mode: 'check' }), s); + expect(report.upgrades.find((u) => u.package === first.name)?.error).toBe( + 'candidate pack, not verified', + ); + expect(reportProblems(report)).toEqual([]); +}); diff --git a/packages/core/src/action/run.ts b/packages/core/src/action/run.ts index 54b9022..6afb355 100644 --- a/packages/core/src/action/run.ts +++ b/packages/core/src/action/run.ts @@ -25,6 +25,8 @@ import { type EngineRun, parseJson } from './engine.js'; import { type CommitIdentity, FALLBACK_IDENTITY, pullNumber } from './github.js'; export interface ActionInputs { + /** Rejected, never forwarded; candidates are local CLI smoke tests only. */ + allowCandidate?: boolean; /** The repository's checkout (`GITHUB_WORKSPACE`). */ workspace: string; /** `config`: the settings file, relative to the workspace. */ @@ -212,6 +214,11 @@ export async function runAction( llmKey: apiKey ? 'present' : 'missing', ...(error ? { error: sentence(error) } : {}), }); + if ( + inputs.allowCandidate || + [inputs.mode, inputs.package, inputs.config].some((v) => v?.includes('--allow-candidate')) + ) + return report('--allow-candidate is not available in the Uptide Action'); // The key before anything else, in every mode: without the LLM, Uptide's answers are worse, // and no run beats a poor one. Nothing is read, listed, checked or fixed. if (!apiKey) return report(LLM_KEY_MISSING); @@ -389,7 +396,12 @@ export async function runAction( let pr: { number: number; url: string } | null = null; let error: string | undefined; // `uptide pr` refuses these too; asking it would only turn the reason into a failure. - if (fixed.verification.passed && !fixed.exportError && !noPackBlockers(fixed).length) { + if ( + !fixed.packWarning && + fixed.verification.passed && + !fixed.exportError && + !noPackBlockers(fixed).length + ) { try { const url = await services.publish(fixed.branch, await services.token(), author); const number = pullNumber(url); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index 162de00..4c16f41 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -33,7 +33,7 @@ import { heldBackText } from '../fetch/release-age.js'; import { planPackage } from '../fix/plan.js'; import { scanImports } from '../list/scan.js'; import { packFixability } from '../packs/fixability.js'; -import { activePacks } from '../packs/index.js'; +import { activePacks, CANDIDATE_WARNING, candidatePack } from '../packs/index.js'; import { packInventory } from '../packs/inventory.js'; import { leadName, leadNames } from '../packs/leads.js'; import type { MigrationPack } from '../packs/types.js'; @@ -69,13 +69,15 @@ import { compareVersions, majorsBehind, parseVersion } from './version.js'; /** * Packs `check` can plan with; the same ones `fix` runs: the verified packs, unless the caller - * names its own (`uptide pack test` scores a candidate with it). + * opts in to registered candidates locally or names its own (`uptide pack test`). */ -function packsOf(opts: Pick): readonly MigrationPack[] { - return opts.packs ?? activePacks(); +function packsOf(opts: Pick): readonly MigrationPack[] { + return opts.packs ?? activePacks(opts.allowCandidate); } export interface CheckOptions { + /** Local smoke tests: opt in to registered, recorded candidate packs. */ + allowCandidate?: boolean; onProgress?: ProgressListener; cwd: string; adapter?: LanguageAdapter; @@ -679,6 +681,7 @@ export function mergeAcrossWorkspaces( p.unanalyzed.map((u) => ({ ...u, file: prefixed(p, u.file) })), ), notes: [...new Set(list.flatMap((p) => p.notes))], + ...(list.some((p) => p.packWarning) ? { packWarning: CANDIDATE_WARNING } : {}), ...(list.find((p) => p.skipReason)?.skipReason ? { skipReason: list.find((p) => p.skipReason)?.skipReason } : {}), @@ -2278,6 +2281,14 @@ async function checkGroup( status = 'no-types'; const report = base(status); report.tier = tier; + if ( + candidatePack( + packsOf(ctx.opts).find( + (p) => p.name === name && p.supports(report.installed, report.target), + ), + ) + ) + report.packWarning = CANDIDATE_WARNING; const viaTypes = prepared.filter((p) => p.types); if (viaTypes.length > 0) { report.typesVia = viaTypes diff --git a/packages/core/src/domain/report.ts b/packages/core/src/domain/report.ts index dac3ef5..e8e14f4 100644 --- a/packages/core/src/domain/report.ts +++ b/packages/core/src/domain/report.ts @@ -117,6 +117,8 @@ export interface PlanGroup { } export interface PackageReport { + /** A local candidate smoke run; never eligible for publication. */ + packWarning?: 'candidate pack, not verified'; skipReason?: ErrorCode; /** * Set when the newest release is younger than the repository's release-age policy: why the diff --git a/packages/core/src/fix/isolate.ts b/packages/core/src/fix/isolate.ts index 6c6868a..af374e7 100644 --- a/packages/core/src/fix/isolate.ts +++ b/packages/core/src/fix/isolate.ts @@ -18,7 +18,7 @@ import { isMainThread } from 'node:worker_threads'; import { workspacePackagesOf } from '../adapters/typescript/repo.js'; import { UptideError } from '../errors.js'; import { ACCEPTED_KEYS, selectLlm } from '../llm/config.js'; -import { activePack } from '../packs/index.js'; +import { activePack, assertCandidateLocal } from '../packs/index.js'; import { uptideVersionInfo } from '../version.js'; import { assertNpmLockSync } from './managers/npm-sync.js'; import { peerPreflight } from './peer-preflight.js'; @@ -530,6 +530,7 @@ export async function isolatedFix( options: FixOptions & PreflightOptions & { keep?: boolean }, services?: FixServices, ): Promise { + assertCandidateLocal(options); const selected = selectLlm(options.cwd, options); if ( options.maxCostUsd !== undefined && @@ -538,7 +539,7 @@ export async function isolatedFix( throw new Error('--max-cost must be a positive finite amount in USD'); if ( !options.fixer && - !activePack(options.only) && + !activePack(options.only, options.allowCandidate) && !options.pack?.rules.length && (options.fixer === null || !selected.available) ) diff --git a/packages/core/src/fix/peer-preflight.ts b/packages/core/src/fix/peer-preflight.ts index 5230992..276b4c1 100644 --- a/packages/core/src/fix/peer-preflight.ts +++ b/packages/core/src/fix/peer-preflight.ts @@ -114,7 +114,7 @@ export async function peerPreflight( ): Promise { if (options.pinCurrentApi) return; const { installed } = fixDependencies(options.cwd, read); - const pack = options.pack ?? activePack(options.only); + const pack = options.pack ?? activePack(options.only, options.allowCandidate); const deps = [ ...installed.values(), ...standInDependencies(options.cwd, pack, [...installed.values()]), diff --git a/packages/core/src/fix/publish.test.ts b/packages/core/src/fix/publish.test.ts index 3c65e78..e834a27 100644 --- a/packages/core/src/fix/publish.test.ts +++ b/packages/core/src/fix/publish.test.ts @@ -386,3 +386,14 @@ it('reports a tree mismatch even when a successful run also came from a dirty bu ).rejects.toMatchObject({ code: 'VERIFIED_TREE_MISMATCH' }); expect(m.calls.some((call) => call[0] === 'gh' || call.includes('push'))).toBe(false); }); + +it.each([false, true])( + 'refuses publication of a stored candidate run, including --yes=%s', + async (yes) => { + const m = mock(); + await expect( + publish({ ...report, packWarning: 'candidate pack, not verified' }, yes, m.io), + ).rejects.toThrow('candidate pack, not verified'); + expect(m.calls.some((c) => c[0] === 'gh' || c.includes('push'))).toBe(false); + }, +); diff --git a/packages/core/src/fix/publish.ts b/packages/core/src/fix/publish.ts index 840c39f..1551db9 100644 --- a/packages/core/src/fix/publish.ts +++ b/packages/core/src/fix/publish.ts @@ -22,6 +22,7 @@ export function publicationBlockers( tool: { uptideDirty: boolean } = uptideVersionInfo(), ): string[] { const blockers: string[] = []; + if (report.packWarning) blockers.push(report.packWarning); if (report.exportError) blockers.push(`export failed: ${report.exportError}`); const v = report.verification; if (v.buildRequired && (!v.build?.length || v.build.some((b) => b.status !== 'passed'))) diff --git a/packages/core/src/fix/range-preflight.ts b/packages/core/src/fix/range-preflight.ts index b1ed07e..85f1f67 100644 --- a/packages/core/src/fix/range-preflight.ts +++ b/packages/core/src/fix/range-preflight.ts @@ -30,7 +30,7 @@ export async function rangePreflight( }; const { installed, names } = fixDependencies(root, read); const fetcher = createNpmFetcher({ config: loadRegistryConfig({ cwd: root }) }); - const pack = options.pack ?? activePack(options.only); + const pack = options.pack ?? activePack(options.only, options.allowCandidate); const { version: target } = await resolveTarget( pack ?? { name: options.only, defaultTarget: '' }, options.target, diff --git a/packages/core/src/fix/report.ts b/packages/core/src/fix/report.ts index 4b938f7..0acc483 100644 --- a/packages/core/src/fix/report.ts +++ b/packages/core/src/fix/report.ts @@ -315,6 +315,7 @@ export function migrationRisk(report: FixReport): { return { level: 'Low', reason: 'no behaviour change: the explicit pin equals the SDK default' }; const g = groups(report); const high = [ + ...(report.packWarning ? [report.packWarning] : []), ...(report.peerConflicts?.length ? ['peer compatibility is not verified'] : []), ...(report.sites.some((s) => s.outcome === 'manual') ? ['manual sites left'] : []), ...(!report.verification.passed || @@ -525,8 +526,9 @@ export function apiChangeSummary(c: NonNullable): strin export function verifiedBy(report: FixReport): string { if (report.verificationPending) return '⚠️ nothing yet: verification pending'; const v = report.verification; - const pack = - report.mode === 'pin' || !report.tier + const pack = report.packWarning + ? [`⚠️ ${report.packWarning}`] + : report.mode === 'pin' || !report.tier ? [] : report.tier === 'verified' ? [`✅ the ${report.package} migration pack`] @@ -541,7 +543,9 @@ export function verifiedBy(report: FixReport): string { const build = !v.build ? report.tier === 'generic' || v.buildRequired ? '⚠️ build not run' - : 'build not required (pack-verified migration)' + : report.packWarning + ? 'build not required by this candidate pack' + : 'build not required (pack-verified migration)' : !v.build.length || v.build.some((b) => b.status === 'missing') ? '⚠️ build not run: no build script' : v.build.some((b) => failing(b.status)) @@ -678,6 +682,7 @@ export function renderMigration( report.mode === 'pin' ? [`## Pin the Stripe API version to ${report.apiVersion}`, ''] : [`## Upgrade ${report.package} ${from} → ${report.target}`, '']; + if (report.packWarning) lines.push(`> ⚠️ ${report.packWarning}`, ''); // Broken before the upgrade: said first, so no reader takes the comparison for proof. const unhealthy = baselineHeadline(baselineHealthOf(report)); if (mode === 'full' && unhealthy && !report.verificationPending) @@ -981,7 +986,7 @@ export function renderMigration( `Branch: \`${report.branch}\``, ...(report.tier ? [ - `Tier: ${report.tier} (${report.tier === 'verified' ? 'a migration pack covers this upgrade' : 'no migration pack: nothing analyzed the upgrade; only the checks under Verified by vouch for it'})`, + `Tier: ${report.packWarning ? 'candidate' : report.tier} (${report.tier === 'verified' ? 'a migration pack covers this upgrade' : 'no migration pack: nothing analyzed the upgrade; only the checks under Verified by vouch for it'})`, ] : []), ...(report.verifiedAt diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index 706bce2..2e61074 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -13,7 +13,13 @@ import { ACCEPTED_KEYS, selectLlm } from '../llm/config.js'; import { DEFAULT_MAX_COST_USD, providerFixer } from '../llm/fixer.js'; import { decisionInventory, packDecisions } from '../packs/decisions.js'; import { genericPack } from '../packs/generic.js'; -import { activePack, activePacks } from '../packs/index.js'; +import { + activePack, + activePacks, + assertCandidateLocal, + CANDIDATE_WARNING, + candidatePack, +} from '../packs/index.js'; import { leadName } from '../packs/leads.js'; import { payloadApiVersions, stripeUsageContext } from '../packs/stripe/relevance.js'; import type { MigrationPack, PackContext } from '../packs/types.js'; @@ -76,6 +82,8 @@ import { } from './versions.js'; export interface FixOptions { + /** Use registered candidates locally; incompatible with publication. */ + allowCandidate?: boolean; onProgress?: ProgressListener; cwd: string; provider?: string; @@ -203,8 +211,12 @@ export async function fix( services: FixServices = defaults, ): Promise { try { + assertCandidateLocal(options); // Asked for by an alternate lead's name (`react-router-dom`), the pack's lead is fixed. - const only = leadName(options.only, options.pack ? [options.pack] : activePacks()); + const only = leadName( + options.only, + options.pack ? [options.pack] : activePacks(options.allowCandidate), + ); return await fixPackage(only === options.only ? options : { ...options, only }, services); } catch (err) { resetSharedState(); @@ -238,7 +250,8 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise; } export interface FixReport { + /** A local candidate smoke run; never eligible for publication. */ + packWarning?: 'candidate pack, not verified'; peerConflicts?: PeerBlockerGroup[]; /** From the check `fix` ran: importing files the memory cap left out, per workspace. */ importersNotAnalyzed?: ImportersNotAnalyzed[]; diff --git a/packages/core/src/packs/candidate.test.ts b/packages/core/src/packs/candidate.test.ts new file mode 100644 index 0000000..e82ae58 --- /dev/null +++ b/packages/core/src/packs/candidate.test.ts @@ -0,0 +1,219 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, expect, it, vi } from 'vitest'; +import { check } from '../check/check.js'; +import { isolatedFix } from '../fix/isolate.js'; +import { git } from '../fix/process.js'; +import { publicationBlockers } from '../fix/publish.js'; +import { formatFix, prBody } from '../fix/report.js'; +import { fix } from '../fix/run.js'; +import { zodFixture } from '../fix/test-fixture.js'; +import { fromChecked, fromFix } from '../run-report/build.js'; +import { ruleFor } from './contract.js'; +import { activePack, activePacks, registeredPacks } from './index.js'; +import type { MigrationPack } from './types.js'; + +vi.mock('./registry.js', async () => { + const { zodPack } = await import('./zod/index.js'); + const record = { + status: 'candidate', + truth: '', + repos: 0, + breaking: { precision: 0, recall: 0, falsePositives: 0 }, + }; + return { + REGISTRY: [ + { dir: 'zod', pack: zodPack, verification: record }, + { + dir: 'verified', + pack: { ...zodPack, name: 'verified' }, + verification: { ...record, status: 'verified', repos: 2 }, + }, + { + dir: 'blocked', + pack: { ...zodPack, name: 'blocked' }, + verification: { ...record, status: 'blocked' }, + }, + { dir: 'unrecorded', pack: { ...zodPack, name: 'unrecorded' } }, + { + dir: 'inconsistent', + pack: { ...zodPack, name: 'inconsistent' }, + verification: { ...record, status: 'verified' }, + }, + ], + }; +}); + +const scratch = mkdtempSync(join(tmpdir(), 'uptide-candidate-')); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); +const banner = 'candidate pack, not verified'; + +it('opts in only to registered packs with an explicitly recorded candidate status', () => { + expect(activePacks().map((p) => p.name)).toEqual(['verified']); + expect(activePacks(true).map((p) => p.name)).toEqual(['zod', 'verified']); + expect(activePack('zod', true)).toBe(registeredPacks()[0]?.pack); + expect(activePack('missing', true)).toBeUndefined(); + expect(activePack('blocked', true)).toBeUndefined(); + expect(activePack('unrecorded', true)).toBeUndefined(); + expect(activePack('inconsistent', true)).toBeUndefined(); + expect(activePack('verified', true)).toBe(activePack('verified')); +}); + +it.each([{ pr: true }, { yes: true }, { forPublication: true }])( + 'refuses candidate publication before any work: %j', + async (flags) => { + const options = { cwd: '/does-not-exist', only: 'zod', allowCandidate: true, ...flags }; + await expect(fix(options)).rejects.toThrow('--allow-candidate'); + await expect(isolatedFix(options)).rejects.toThrow('--allow-candidate'); + }, +); + +it('runs a candidate locally, persists the banner and prevents later publication', async () => { + const { root, services } = zodFixture(scratch); + const checked = vi.fn(services.check); + const result = await fix( + { cwd: root, only: 'zod', allowCandidate: true, fixer: null }, + { ...services, check: checked }, + ); + expect(checked.mock.calls[0]?.[0].allowCandidate).toBe(true); + expect(result.sites.some((s) => s.outcome === 'mechanical')).toBe(true); + expect(formatFix(result)).toContain(banner); + expect(prBody(result)).toContain(banner); + expect(readFileSync(result.prBody, 'utf8')).toContain(banner); + const stored = JSON.parse(readFileSync(join(root, '.uptide/report.json'), 'utf8')); + expect(stored.packWarning).toBe(banner); + expect(publicationBlockers(stored, { uptideDirty: false })).toContain(banner); + expect(fromFix(result, { pr: null, durationMs: 1 }).error).toContain(banner); +}); + +it('check activates the candidate and labels its report without changing the default', async () => { + const { root } = zodFixture(scratch); + // Use the real checker with synthetic declarations; no registry, repository scripts or LLM. + const target = join(scratch, 'target'); + mkdirSync(target); + writeFileSync( + join(target, 'package.json'), + JSON.stringify({ name: 'zod', version: '4.6.5', types: 'index.d.ts' }), + ); + writeFileSync( + join(target, 'index.d.ts'), + 'export declare const z: { string(options: {error?: string}): string };', + ); + const options = { + cwd: root, + only: ['zod'], + runtime: false, + workspaceConcurrency: 1, + cache: { get: async () => undefined, set: async () => {} }, + fetcher: { + resolve: async () => '4.6.5', + release: async () => {}, + fetch: async (name: string, version: string) => ({ name, version, dir: target }), + }, + }; + const normal = await check(options); + const candidate = await check({ ...options, allowCandidate: true }); + expect(normal.packages[0]?.packWarning).toBeUndefined(); + expect(candidate.packages[0]?.packWarning).toBe(banner); + expect(candidate.packages[0]?.plan?.some((p) => p.rule === 'error-params')).toBe(true); + const checked = candidate.packages[0]; + if (!checked) throw new Error('missing candidate report'); + expect(fromChecked(checked).error).toBe(banner); +}); + +it('labels no-llm tool-call union sites with the claiming rule (PR 130 smoke regression)', async () => { + const { root, services } = zodFixture(scratch); + // A stand-in SDK and consumer, not the smoke repository or any pack truth. + const source = + "import type { ChatCompletionMessageToolCall } from 'openai';\nexport function use(tc: ChatCompletionMessageToolCall) {\n return [tc.function.name, tc.function.arguments];\n}\n"; + for (const file of ['package.json', 'pnpm-lock.yaml']) + writeFileSync( + join(root, file), + readFileSync(join(root, file), 'utf8') + .replaceAll('zod', 'openai') + .replaceAll('3.25.76', '4.104.0'), + ); + writeFileSync(join(root, 'index.ts'), source); + mkdirSync(join(root, 'node_modules/openai')); + const sdk = (version: string) => { + writeFileSync( + join(root, 'node_modules/openai/package.json'), + JSON.stringify({ name: 'openai', version, types: 'index.d.ts' }), + ); + writeFileSync( + join(root, 'node_modules/openai/index.d.ts'), + 'export type ChatCompletionMessageToolCall = { type: "function"; function: { name: string; arguments: string } }' + + (version.startsWith('7') ? ' | { type: "custom"; custom: { name: string } };' : ';'), + ); + }; + sdk('4.104.0'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'synthetic union consumer'); + const report = await services.check({ cwd: root }); + const pkg = report.packages[0]; + if (!pkg) throw new Error('missing fixture package'); + pkg.name = 'openai'; + pkg.installed = '4.104.0'; + pkg.target = '7.33.0'; + const original = pkg.findings[0]; + if (!original) throw new Error('missing fixture finding'); + pkg.findings = [14, 32].map((column) => ({ + ...original, + change: { + ...original.change, + package: 'openai', + from: '4.104.0', + to: '7.33.0', + kind: 'type', + path: 'ChatCompletionMessageToolCall.function', + }, + usage: { + ...original.usage, + line: 3, + column, + compileCode: 2339, + compileError: "Property 'function' does not exist on type 'ChatCompletionMessageToolCall'.", + }, + fixability: 'assisted', + })); + const rules = [ + { + id: 'tool-call-union', + kinds: ['type'] as const, + symbols: /ChatCompletionMessageToolCall/, + guide: 'Narrow the tool-call union before reading function.', + }, + ]; + const pack: MigrationPack = { + name: 'openai', + defaultTarget: '7.33.0', + supports: () => true, + rules, + ruleOf: (f) => ruleFor(rules, f)?.id, + transform: (text) => ({ text, applied: false, reason: 'needs narrowing' }), + guide: () => 'Narrow the tool-call union before reading function.', + reviewNotes: () => [], + }; + const result = await fix( + { cwd: root, only: 'openai', fixer: null, pack }, + { + ...services, + check: async () => report, + resolve: async () => '7.33.0', + install: async () => { + sdk('7.33.0'); + writeFileSync( + join(root, 'pnpm-lock.yaml'), + readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8').replaceAll('4.104.0', '7.33.0'), + ); + }, + }, + ); + expect(result.sites).toHaveLength(2); + expect(result.sites.map((s) => [s.outcome, s.rule])).toEqual([ + ['manual', 'tool-call-union'], + ['manual', 'tool-call-union'], + ]); + expect(prBody(result)).toContain('tool-call-union'); +}); diff --git a/packages/core/src/packs/index.ts b/packages/core/src/packs/index.ts index 20b3821..ce16f6a 100644 --- a/packages/core/src/packs/index.ts +++ b/packages/core/src/packs/index.ts @@ -1,3 +1,4 @@ +import { UptideError } from '../errors.js'; import { type PackStatus, type RegisteredPack, recordedStatus } from './contract.js'; import { REGISTRY } from './registry.js'; import type { MigrationPack } from './types.js'; @@ -12,14 +13,41 @@ export function packStatus(entry: RegisteredPack): PackStatus { } /** - * The packs `check`, `list` and `fix` use: verified ones only. A candidate ships in the tree - * and `pack test` scores it, but until its ground truth meets the gate the dependency is - * analyzed and migrated as generic. + * Verified packs by default. Local `check`/`fix` smoke tests may explicitly opt in to + * registered packs whose record says candidate. Missing, blocked or inconsistent records + * do not qualify; `list` and the Action keep the verified-only default. */ -export function activePacks(): readonly MigrationPack[] { - return REGISTRY.filter((e) => packStatus(e) === 'verified').map((e) => e.pack); +export function activePacks(allowCandidate = false): readonly MigrationPack[] { + return REGISTRY.filter( + (e) => + packStatus(e) === 'verified' || (allowCandidate && e.verification?.status === 'candidate'), + ).map((e) => e.pack); } -export function activePack(name: string): MigrationPack | undefined { - return activePacks().find((p) => p.name === name); +export function activePack(name: string, allowCandidate = false): MigrationPack | undefined { + return activePacks(allowCandidate).find((p) => p.name === name); +} + +/** Persisted on local smoke runs, so later publication cannot lose the safety boundary. */ +export const CANDIDATE_WARNING = 'candidate pack, not verified' as const; + +export function candidatePack(pack: MigrationPack | undefined): boolean { + return ( + pack !== undefined && + REGISTRY.some((entry) => entry.pack === pack && entry.verification?.status === 'candidate') + ); +} + +/** Refuse before reading a checkout, cloning, resolving packages or requesting credentials. */ +export function assertCandidateLocal(options: { + allowCandidate?: boolean; + pr?: boolean; + yes?: boolean; + forPublication?: boolean; +}): void { + if (options.allowCandidate && (options.pr || options.yes || options.forPublication)) + throw new UptideError( + 'PUBLICATION_REFUSED', + '--allow-candidate cannot be used with --pr or --yes; candidate packs are for local smoke tests only', + ); } diff --git a/packages/core/src/run-report/build.ts b/packages/core/src/run-report/build.ts index a7c4103..7bf4bf2 100644 --- a/packages/core/src/run-report/build.ts +++ b/packages/core/src/run-report/build.ts @@ -209,6 +209,7 @@ export function fromChecked(pkg: PackageReport, durationMs = 0): Upgrade { status: failed ? 'failed' : upToDate ? 'up_to_date' : 'available', ...blank(durationMs), breaking: breakingSites(pkg.findings ?? []), + ...(pkg.packWarning ? { error: pkg.packWarning } : {}), ...(!failed && !upToDate && ANALYZED.has(pkg.status) && analysisOf(pkg.tier) ? { analysis: analysisOf(pkg.tier) } : {}), @@ -231,7 +232,8 @@ export function fromFix( ): Upgrade { const unanalyzed = outcome.pr ? [] : noPackBlockers(report); const major = unanalyzed.length > 0 && isMajorUpgrade(report.from, report.target); - const failure = major ? undefined : (outcome.error ?? verificationFailure(report)); + const failure = + report.packWarning ?? (major ? undefined : (outcome.error ?? verificationFailure(report))); const notOpened = major ? NO_PACK_YET : !failure && unanalyzed.length diff --git a/scripts/action.ts b/scripts/action.ts index a34b6ca..d61defb 100644 --- a/scripts/action.ts +++ b/scripts/action.ts @@ -17,6 +17,17 @@ import { PublishFailure, runAction } from '../packages/core/src/action/run.ts'; import { assertSafeReport } from '../packages/core/src/run-report/safe.ts'; import { renderSummary } from '../packages/core/src/run-report/summary.ts'; +// No opt-in channel in the Action, even if an input or invocation is added later. +if ( + process.argv + .slice(2) + .some((arg) => arg === '--allow-candidate' || arg.startsWith('--allow-candidate=')) || + ['UPTIDE_ALLOW_CANDIDATE', 'INPUT_ALLOW-CANDIDATE', 'INPUT_ALLOW_CANDIDATE'].some( + (key) => process.env[key] !== undefined, + ) +) + throw new Error('--allow-candidate is not available in the Uptide Action'); + const env = { ...process.env }; const apiKey = env.UPTIDE_ANTHROPIC_API_KEY?.trim() || undefined; // Read once; from here on no environment, ours or a child's, carries it.