Skip to content

Commit 7dc8517

Browse files
fix(build): gate the artifact routed CLI on the component capability judgment; changeset patch
Codex review on #419: emission, the AB4765 warning, and the registration invariant now use `componentCapabilities ?? capabilities` — the same judgment `inspect` reports — via the new `TargetRegistry.componentCapabilityState` / `hostsComponent` accessors, so a component override can never disagree with what the artifact ships. The changeset is `patch` per the pre-1.0 policy (minor is reserved for breaking changes).
1 parent 67a2160 commit 7dc8517

6 files changed

Lines changed: 74 additions & 7 deletions

File tree

‎.changeset/387-artifact-routed-cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
"agent-bundle": minor
2+
"agent-bundle": patch
33
---
44

55
Emit the routed CLI (`src/cli/**`) into every host artifact as

‎packages/agent-bundle/src/adapters/registry.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,14 @@ const snapshotArtifactLayout = (
185185
// A supported `cli` capability promises a home for the compiled routed CLI,
186186
// and the compiler emits it at exactly one place (`bin/<name>.mjs`), so the
187187
// promise is checked before any early return and against that fixed layout.
188-
const cliSupported = capabilityIsSupported(adapter.capabilities[cliBinCapability]);
188+
// The judgment is the component one (`componentCapabilities ?? capabilities`)
189+
// because that is what decides emission; malformed declarations are
190+
// reported by the capability validators, not here.
191+
const componentCapabilities = adapter.componentCapabilities === undefined
192+
? undefined
193+
: record(adapter.componentCapabilities);
194+
const cliJudgment = (componentCapabilities ?? adapter.capabilities)[cliBinCapability];
195+
const cliSupported = isCapabilityState(cliJudgment) && capabilityIsSupported(cliJudgment);
189196
const missingCliBinLayout = (): Error =>
190197
new Error(`Target adapter "${adapter.name}" declares a supported ${cliBinCapability} capability without a routed CLI bin layout.`);
191198
const declaredLayout = adapter.artifactLayout;
@@ -673,10 +680,20 @@ export class TargetRegistry implements NormalizationTargetRegistry {
673680
return this.#adapters.get(name)?.capabilities[capability];
674681
}
675682

683+
componentCapabilityState(name: string, capability: string): CapabilityState | undefined {
684+
const adapter = this.#adapters.get(name);
685+
return adapter === undefined ? undefined : (adapter.componentCapabilities ?? adapter.capabilities)[capability];
686+
}
687+
676688
supports(name: string, capability: string): boolean {
677689
return capabilityIsSupported(this.capabilityState(name, capability));
678690
}
679691

692+
/** True when the target emits components needing `capability`, by the same judgment `inspect` reports. */
693+
hostsComponent(name: string, capability: string): boolean {
694+
return capabilityIsSupported(this.componentCapabilityState(name, capability));
695+
}
696+
680697
names(): readonly string[] {
681698
return Object.freeze([...this.#adapters.keys()]);
682699
}

‎packages/agent-bundle/src/build/cli-bins.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,13 @@ export const cliBinWorkerArtifactPath = (name: string): string => `${cliBinDirec
4141
export const routedCliBins = (model: NormalizedPlugin): readonly NormalizedBinEntry[] =>
4242
Object.freeze((model.packageBuild?.bins ?? []).filter((bin) => bin.generatedCli !== undefined));
4343

44-
/** True when the target's adapter admits the routed CLI bin into its artifact. */
44+
/**
45+
* True when the target's adapter admits the routed CLI bin into its artifact —
46+
* by the component judgment (`componentCapabilities ?? capabilities`), so
47+
* emission and `inspect` accounting can never disagree.
48+
*/
4549
export const targetHostsCliBin = (registry: TargetRegistry, target: string): boolean =>
46-
registry.supports(target, cliBinCapability);
50+
registry.hostsComponent(target, cliBinCapability);
4751

4852
export interface CompiledCliBin extends CompiledEntry {
4953
readonly id: string;

‎packages/agent-bundle/src/config/validate.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
22
import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
33

4-
import { cliBinCapability } from '../adapters/capability-state.ts';
4+
import { capabilityIsSupported, cliBinCapability } from '../adapters/capability-state.ts';
55
import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts';
6+
import type { CapabilityState } from '../core/capabilities.ts';
67
import { toPosixRelative } from '../core/paths.ts';
78
import { isPlainRecord, isRecord } from '../core/strict-json.ts';
89
import type { Diagnostic } from '../core/diagnostics.ts';
@@ -2146,11 +2147,28 @@ const routedCliBinTargetDiagnostics = (
21462147
registry: NormalizationTargetRegistry,
21472148
): Diagnostic[] => {
21482149
const diagnostics: Diagnostic[] = [];
2150+
// The judgment must be the one emission and `inspect` use: the adapter's
2151+
// component override when published, otherwise its plain capabilities. A
2152+
// registry exposing neither accessor falls back to its boolean view.
2153+
const judgmentFor = (target: string): { readonly known: true; readonly state: CapabilityState | undefined } | { readonly known: false } => {
2154+
if (registry.componentCapabilityState !== undefined) {
2155+
return { known: true, state: registry.componentCapabilityState(target, cliBinCapability) };
2156+
}
2157+
if (registry.capabilityState !== undefined) {
2158+
return { known: true, state: registry.capabilityState(target, cliBinCapability) };
2159+
}
2160+
return { known: false };
2161+
};
21492162
for (const bin of model.packageBuild?.bins ?? []) {
21502163
if (bin.generatedCli === undefined) continue;
21512164
for (const target of model.targets) {
2152-
if (!registry.has(target.name) || registry.supports(target.name, cliBinCapability)) continue;
2153-
const capability = registry.capabilityState?.(target.name, cliBinCapability);
2165+
if (!registry.has(target.name)) continue;
2166+
const judged = judgmentFor(target.name);
2167+
const supported = judged.known
2168+
? capabilityIsSupported(judged.state)
2169+
: registry.supports(target.name, cliBinCapability);
2170+
if (supported) continue;
2171+
const capability = judged.known ? judged.state : undefined;
21542172
let judgment: string;
21552173
if (capability === undefined) {
21562174
judgment = `the target publishes no ${cliBinCapability} capability row`;

‎packages/agent-bundle/src/core/types.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,13 @@ export interface NormalizationTargetRegistry {
705705
targetNames: readonly string[],
706706
): readonly NormalizationHostBinSource[];
707707
capabilityState?(name: string, capability: string): CapabilityState | undefined;
708+
/**
709+
* The judgment that decides component emission for one target: the
710+
* adapter's `componentCapabilities` override when it publishes one (a key
711+
* it omits reads as no row), otherwise its `capabilities`. Emission and
712+
* `inspect` accounting must consult the same judgment.
713+
*/
714+
componentCapabilityState?(name: string, capability: string): CapabilityState | undefined;
708715
configExtensions(): readonly NormalizationConfigExtension[];
709716
defaultTargetNames(): readonly string[];
710717
has(name: string): boolean;

‎packages/agent-bundle/tests/adapter-capability-states.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,27 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i
938938
...cursor,
939939
artifactLayout: { ...layoutWithoutBin, cliBin: { allowedSuffixes: ['.js', '.mjs'], directory: 'bin' } },
940940
})).not.toThrow();
941+
942+
// Emission follows the component judgment `inspect` reports
943+
// (`componentCapabilities ?? capabilities`), so an override that withdraws
944+
// `cli` hosts no bin (and needs no layout), while an override that grants it
945+
// needs the layout even if the top-level row is absent.
946+
const withdrawn = new TargetRegistry().register({
947+
...cursor,
948+
artifactLayout: layoutWithoutBin,
949+
componentCapabilities: { ...cursor.componentCapabilities, cli: unavailableCapability('withdrawn for this host') },
950+
});
951+
expect(withdrawn.supports('cursor', 'cli')).toBe(true);
952+
expect(withdrawn.hostsComponent('cursor', 'cli')).toBe(false);
953+
expect(withdrawn.componentCapabilityState('cursor', 'cli')).toEqual({ reason: 'withdrawn for this host', state: 'unavailable' });
954+
expect(() => new TargetRegistry().register({
955+
...cursor,
956+
artifactLayout: layoutWithoutBin,
957+
capabilities: capabilitiesWithoutCli,
958+
componentCapabilities: { cli: cursor.capabilities.cli! },
959+
})).toThrow(/supported cli capability without a routed CLI bin layout/u);
960+
expect(registry.hostsComponent('cursor', 'cli')).toBe(true);
961+
expect(registry.hostsComponent('unknown-target', 'cli')).toBe(false);
941962
});
942963

943964
it('rejects a malformed inspection component capability when the adapter registers', () => {

0 commit comments

Comments
 (0)