Skip to content

Commit c2bc76e

Browse files
fix(cli): os serve names an unloaded devPlugins entry by the list it was written in (#31356)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1zG3Xn4JonaKvDLLr5gnjL)_ Refs #28284 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`os serve --dev` 因可能提供加密提供者的项没能加载而拒绝启动时,`devPlugins` 里的项现在点名为 `devPlugins[i]`,不再按它排在 `plugins` 之后的位置点名。判断是否拒绝的 `providesServices` 读取改为与 `cryptoProviderPlugins` 共用同一个判定,docstring 也写明了「声明其它服务或什么都没声明的项被排除」。 - **为什么改**:#31263 契约复审的 should-fix 2 与 3(should-fix 1 的 docstring 部分)。 - **风险与回滚**:只改 `@objectstack/cli`(patch)。哪些项会被拒绝不变,只改报错里的名称。回滚即 revert 本 PR。 - **先看**:`packages/cli/src/commands/serve.ts` 的 `declaresCryptoProvider`、`refuseUnloadedCryptoProviderPlugins` 的 label,以及栈插件循环 catch 里的 `list: 'devPlugins'`。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/serve-host-crypto-provider.test.ts test/serve-crypto-provider-plugin-load-failure.integration.test.ts`,预期 34/34。 - **需要你做的**:无,席位复审后落地。 ## What changes - **Refusal label.** `os serve` refuses to boot when an entry that may provide the crypto provider (`crypto-provider`) does not load. Under `--dev`, a `devPlugins` entry is now named `devPlugins[i]`, where it used to be named by its position after the stack's `plugins` (`plugins[plugins.length + i]`). - `UnloadedStackPlugin` gains an optional `list` (`'plugins' | 'devPlugins'`; absent means `plugins`). - The boot loop records it from the authored `plugins` length. - **One reading of the declaration.** `refuseUnloadedCryptoProviderPlugins` reads `providesServices` through the predicate `cryptoProviderPlugins` filters by (`declaresCryptoProvider`), not through its own copy. - **Docstring.** It now states the rule the code applies: an entry that declares other services, or declares none, is ruled out. Which entries are refused is unchanged. ## Why Follow-up to the contract review of #31263 (should-fixes 2 and 3, and the docstring half of 1). ## Verification Readings on head `279bb7dca` (one commit on `main` at `9447f9552`). <details><summary>Pins, red-first and restore</summary> - `serve-crypto-provider-plugin-load-failure.integration.test.ts`: a fifth real boot, `os serve --dev` over a host config whose `plugins[1]` and `devPlugins[0]` are specifiers that do not import. It is refused, and names `devPlugins[0] ('…')` and `plugins[1] ('…')`. - `serve-host-crypto-provider.test.ts`: - a unit case for the `devPlugins` label; - a case that refusal and `cryptoProviderPlugins` agree on a declaring entry, an entry declaring another service, and an entry declaring none; - the source pin now locates the record by `unloadedStackPlugins.push(`. - Red-first: with `serve.ts` set back to its `main` blob, the two files read 2 failed | 32 passed (34). The two red cases were the `devPlugins` label unit case and the `--dev` boot. Restored (blob equal to HEAD): 34 passed (34). </details> <details><summary>Package suite and typecheck</summary> - `tsc --noEmit` in `packages/cli`: exit 0. - `pnpm --filter @objectstack/cli test` under the verify lock: Test Files 608 passed | 1 skipped (609), Tests 7538 passed | 9 skipped (7547), `VERDICT command-exit 0`. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 72 families on this diff, every one run and exit 0 (`check:type-check-debt` under the verify lock). `--ran` reconciliation: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `eslint --no-inline-config` on the three changed source files: exit 0. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the maintainer summary the lane's PRs carry. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f62c170 commit c2bc76e

4 files changed

Lines changed: 85 additions & 21 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os serve` names an unloaded `devPlugins` entry by the list it was written in
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
When `os serve --dev` refuses to boot because an entry that may provide the crypto provider (`crypto-provider`) did not load, a `devPlugins` entry is now named `devPlugins[i]` instead of by its position after the stack's `plugins`. Which entries are refused is unchanged: the refusal reads an entry's `providesServices` through the same predicate as the rest of the crypto-provider checks.

‎packages/cli/src/commands/serve-host-crypto-provider.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
6161

6262
import {
6363
createHostCryptoProvider,
64+
cryptoProviderPlugins,
6465
HostCryptoProviderRefusal,
6566
refuseTwoCryptoProviderPlugins,
6667
refuseUnloadedCryptoProviderPlugins,
@@ -486,6 +487,26 @@ describe('a plugins entry that did not load refuses the boot unless it is ruled
486487
expect(refusalOf([])).toBeUndefined();
487488
});
488489

490+
it('a devPlugins entry is named by the list it was written in', () => {
491+
const refusal = refusalOf([
492+
{ list: 'devPlugins', index: 0, entry: '@acme/dev-kms', error: new Error('a') },
493+
{ index: 1, entry: '@acme/kms', error: new Error('b') },
494+
]);
495+
expect(refusal!.message).toContain("devPlugins[0] ('@acme/dev-kms') did not load (a)");
496+
expect(refusal!.message).toContain("plugins[1] ('@acme/kms') did not load (b)");
497+
});
498+
499+
it('reads a declaration as cryptoProviderPlugins does: an entry it counts as the provider is refused, one it does not is ruled out', () => {
500+
const declaring = providerPlugin('com.example.kms-crypto', kmsProvider().provider);
501+
const other = { name: 'com.example.widgets', providesServices: ['widgets'], init: async () => {} };
502+
const none = { name: 'com.example.plain', init: async () => {} };
503+
for (const candidate of [declaring, other, none]) {
504+
const counted = cryptoProviderPlugins([candidate]).length > 0;
505+
const refused = refusalOf([{ index: 0, entry: candidate, error: new Error('x') }]) !== undefined;
506+
expect(refused, (candidate as { name: string }).name).toBe(counted);
507+
}
508+
});
509+
489510
it('every refused entry is named, and the ruled-out ones are not', () => {
490511
const refusal = refusalOf([
491512
{ index: 0, entry: '@acme/one', error: new Error('a') },
@@ -663,7 +684,7 @@ describe('the wiring inside run()', () => {
663684
});
664685

665686
it('refuses an unloaded entry that may provide the crypto provider right after the plugins loop, before the host provider is built', () => {
666-
const record = SERVE.indexOf('unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });');
687+
const record = SERVE.indexOf('unloadedStackPlugins.push(');
667688
const check = SERVE.indexOf('refuseUnloadedCryptoProviderPlugins(unloadedStackPlugins);');
668689
const host = SERVE.indexOf('hostCrypto = createHostCryptoProvider(() => localCryptoProvider.resolve());');
669690
expect(record).toBeGreaterThan(0);

‎packages/cli/src/commands/serve.ts‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3307,6 +3307,10 @@ export default class Serve extends Command {
33073307
// `stackBootPlugins` [#22371], the list `os migrate
33083308
// security-catalog-overlays --dev` composes too.
33093309
let plugins: any[] = stackBootPlugins(config, flags.dev);
3310+
// Where `devPlugins` sit in that list (after the stack's own `plugins`),
3311+
// so an entry that does not load is named by the list it was written in.
3312+
const authoredPluginCount = stackBootPlugins(config, false).length;
3313+
const bootPluginCount = plugins.length;
33103314

33113315
// 1. Auto-register ObjectQL Plugin if objects define but plugins missing
33123316
// [#15006] The whole gate — the `objects` read AND the already-composed
@@ -4765,7 +4769,11 @@ export default class Serve extends Command {
47654769
trackPlugin(pluginName);
47664770
} catch (e: any) {
47674771
console.error(chalk.red(` ✗ Failed to load plugin: ${e.message}`));
4768-
unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });
4772+
unloadedStackPlugins.push(
4773+
index >= authoredPluginCount && index < bootPluginCount
4774+
? { list: 'devPlugins', index: index - authoredPluginCount, entry: plugin, materialized, error: e }
4775+
: { index, entry: plugin, materialized, error: e },
4776+
);
47694777
}
47704778
}
47714779
}
@@ -6390,13 +6398,20 @@ export function cryptoProviderPlugins(composed: Iterable<unknown>): Array<{ name
63906398
if (typeof name === 'string') byName.set(name, candidate as object);
63916399
}
63926400
return [...byName]
6393-
.filter(([, plugin]) => {
6394-
const provides = (plugin as { providesServices?: unknown }).providesServices;
6395-
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
6396-
})
6401+
.filter(([, plugin]) => declaresCryptoProvider(plugin))
63976402
.map(([name, plugin]) => ({ name, plugin }));
63986403
}
63996404

6405+
/**
6406+
* Does this plugin (or entry) declare, in `providesServices` (ADR-0116), that
6407+
* it registers {@link CRYPTO_PROVIDER_SERVICE}? The one predicate behind
6408+
* {@link cryptoProviderPlugins} and {@link refuseUnloadedCryptoProviderPlugins}.
6409+
*/
6410+
function declaresCryptoProvider(plugin: object): boolean {
6411+
const provides = (plugin as { providesServices?: unknown }).providesServices;
6412+
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
6413+
}
6414+
64006415
/**
64016416
* Refuse a composition in which more than one plugin declares, in
64026417
* `providesServices` (ADR-0116), that it registers {@link CRYPTO_PROVIDER_SERVICE}.
@@ -6421,7 +6436,9 @@ export function refuseTwoCryptoProviderPlugins(composed: Iterable<unknown>): voi
64216436

64226437
/** A `plugins` entry `os serve` could not load: {@link refuseUnloadedCryptoProviderPlugins}. */
64236438
export interface UnloadedStackPlugin {
6424-
/** Its position in the stack's `plugins`. */
6439+
/** The list the entry was written in; `plugins` when absent. */
6440+
list?: 'plugins' | 'devPlugins';
6441+
/** Its position in that list. */
64256442
index: number;
64266443
/** The entry as the config wrote it. */
64276444
entry: unknown;
@@ -6438,12 +6455,13 @@ export interface UnloadedStackPlugin {
64386455
* setting, and the host chooses it once at `kernel:ready`
64396456
* ({@link createHostCryptoProvider}). A boot that goes on without the plugin
64406457
* that provides it would choose another one, so it does not go on. An entry is
6441-
* ruled out only by what it says it provides: the plugin it materialized to
6442-
* (or the entry itself, when it is an object) has a `providesServices`
6443-
* (ADR-0116) that does not name the provider. An entry that never
6444-
* materialized (a package specifier that does not resolve or import) says
6445-
* nothing, and is refused. Every other unloaded entry keeps the boot's
6446-
* existing handling: it is printed and the boot goes on without it.
6458+
* judged by what it says it provides, read as {@link cryptoProviderPlugins}
6459+
* reads it: the plugin it materialized to (or the entry itself, when it is an
6460+
* object) is refused when its `providesServices` (ADR-0116) names the
6461+
* provider, and ruled out when it declares other services or declares none.
6462+
* An entry that never materialized (a package specifier that does not resolve
6463+
* or import) says nothing, and is refused. Every ruled-out entry keeps the
6464+
* boot's existing handling: it is printed and the boot goes on without it.
64476465
*
64486466
* The one-shot secret commands keep the same rule over the same host config.
64496467
*/
@@ -6453,14 +6471,13 @@ export function refuseUnloadedCryptoProviderPlugins(unloaded: readonly UnloadedS
64536471
? materialized
64546472
: entry && typeof entry === 'object' ? entry : undefined;
64556473
if (!said) return true;
6456-
const provides = (said as { providesServices?: unknown }).providesServices;
6457-
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
6474+
return declaresCryptoProvider(said);
64586475
});
64596476
if (refused.length === 0) return;
6460-
const label = ({ index, entry, materialized }: UnloadedStackPlugin): string => {
6461-
if (typeof entry === 'string') return `plugins[${index}] ('${entry}')`;
6477+
const label = ({ list = 'plugins', index, entry, materialized }: UnloadedStackPlugin): string => {
6478+
if (typeof entry === 'string') return `${list}[${index}] ('${entry}')`;
64626479
const name = (materialized as { name?: unknown } | null | undefined)?.name ?? (entry as { name?: unknown } | null)?.name;
6463-
return typeof name === 'string' ? `plugins[${index}] ('${name}')` : `plugins[${index}]`;
6480+
return typeof name === 'string' ? `${list}[${index}] ('${name}')` : `${list}[${index}]`;
64646481
};
64656482
const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error));
64666483
throw new HostCryptoProviderRefusal(

‎packages/cli/test/serve-crypto-provider-plugin-load-failure.integration.test.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2-
// test-budget: 40 — spawns `os serve` (src, via runServe) once per single-cause fixture: four boots, two refused and two that reach the ready banner; budget to be re-measured
2+
// test-budget: 40 — spawns `os serve` (src, via runServe) once per single-cause fixture: five boots, three refused and two that reach the ready banner; budget to be re-measured
33

44
/**
55
* `os serve` refuses to boot when a `plugins` entry fails to load and it cannot
@@ -14,6 +14,8 @@
1414
* nothing says what it provides, so the boot is refused;
1515
* - an entry that materializes, declares the crypto provider in
1616
* `providesServices`, and is refused by the kernel: refused;
17+
* - under `--dev`, a `devPlugins` entry that does not materialize: refused,
18+
* named by the list it was written in (`devPlugins[0]`);
1719
* - CONTROL: an entry that materializes, declares nothing of the kind, and is
1820
* refused by the kernel: the boot goes on, as before, and says so;
1921
* - CONTROL: a config whose every entry loads boots.
@@ -62,6 +64,12 @@ const CONFIGS = {
6264
}],
6365
${OBJECT}
6466
};
67+
`,
68+
devUnresolvable: `export default {
69+
plugins: [{ name: 'com.acme.host', version: '1.0.0', init: async () => {} }, '@acme/not-installed-crypto-provider'],
70+
devPlugins: ['@acme/not-installed-dev-crypto-provider'],
71+
${OBJECT}
72+
};
6573
`,
6674
allLoad: `export default {
6775
plugins: [{ name: 'com.acme.fine', version: '1.0.0', init: async () => {} }],
@@ -84,8 +92,8 @@ afterAll(() => {
8492
for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true });
8593
});
8694

87-
const boot = async (key: keyof typeof CONFIGS) => {
88-
const run = await runServe(dirs[key]!, [], { waitFor: SETTLED, timeoutMs: 150_000 });
95+
const boot = async (key: keyof typeof CONFIGS, args: string[] = []) => {
96+
const run = await runServe(dirs[key]!, args, { waitFor: SETTLED, timeoutMs: 150_000 });
8997
return { ...run, output: run.stdout + run.stderr };
9098
};
9199

@@ -107,6 +115,14 @@ describe('os serve — a plugins entry that fails to load and may provide the cr
107115
expect(port).toBeUndefined();
108116
}, 180_000);
109117

118+
it('under --dev, a devPlugins entry that does not materialize is refused, named by its own list', async () => {
119+
const { output, port } = await boot('devUnresolvable', ['--dev']);
120+
expect(output, output).toMatch(REFUSED);
121+
expect(output).toContain("devPlugins[0] ('@acme/not-installed-dev-crypto-provider') did not load");
122+
expect(output).toContain("plugins[1] ('@acme/not-installed-crypto-provider') did not load");
123+
expect(port).toBeUndefined();
124+
}, 180_000);
125+
110126
it('CONTROL: an entry that declares no crypto provider and is refused by the kernel: the boot goes on', async () => {
111127
const { output, port } = await boot('nonDeclaringRefused');
112128
expect(output).toContain('Failed to load plugin');

0 commit comments

Comments
 (0)