Skip to content

Commit d77eff4

Browse files
authored
refactor(daemon): cut the last edges into the CLI schema layer (#2543) (#2562)
* refactor(command-registry): own command defaults; cut the daemon's CLI-schema edges The daemon resolved routes through the command registry but still reached two symbols across the layer boundary — recordedFlagKeys and applyCommandDefaults — both imported from src/cli-schema/command-schema.ts, and the apps filter default was declared a second time in the apps facet. The registry is now the single source of command defaults: a COMMAND_DEFAULTS table typed on CommandFlags plus applyCommandDefaults sit in registry.ts, where every other command default (the defaultValue descriptors) is already declared. The CLI parser and the daemon request scope both import it from @agent-device/command-registry/registry, the one module already in both callers' eager closure. Registry and CLI closure are unchanged: the table adds a literal, not a new import, so no entry grows. The apps facet default and CommandSchema.defaults go away; DEFAULT_APPS_FILTER collapses into resolveAppsFilter's provider fallback and stops being a public export. session-action-recorder reads the recorded-flag vocabulary straight from @agent-device/command-registry/flag-registry, and removing that import also drops command-schema's now-unconsumed recordedFlagKeys re-export. Part of #2545 / #2543. * chore(gates): forbid the daemon importing the CLI schema layer R10 (scripts/layering/daemon-modularity) now rejects any daemon import of src/cli-schema/, value or type. The last two value edges into command-schema.ts are gone, so a total boundary is available, and an explicit rule beats a rank that would let the next daemon module reach the CLI schema layer again. Part of #2545 / #2543.
1 parent 4f5a87b commit d77eff4

13 files changed

Lines changed: 143 additions & 33 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { applyCommandDefaults } from '../registry.ts';
4+
5+
test('applyCommandDefaults fills the apps filter from the single registry table', () => {
6+
const flags: Record<string, unknown> = {};
7+
assert.equal(applyCommandDefaults('apps', flags), true);
8+
assert.equal(flags.appsFilter, 'user-installed');
9+
});
10+
11+
test('applyCommandDefaults never overwrites a filter the caller supplied', () => {
12+
const flags: Record<string, unknown> = { appsFilter: 'all' };
13+
assert.equal(applyCommandDefaults('apps', flags), false);
14+
assert.equal(flags.appsFilter, 'all');
15+
});
16+
17+
test('applyCommandDefaults is a no-op for commands and non-commands without a declared default', () => {
18+
for (const command of ['tap', 'snapshot', 'not-a-command', null]) {
19+
const flags: Record<string, unknown> = {};
20+
assert.equal(applyCommandDefaults(command, flags), false, `command ${command}`);
21+
assert.deepEqual(flags, {});
22+
}
23+
});

‎packages/command-registry/src/registry.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// The typed-flags request from contracts/, not the daemon's server-side refinement: these
22
// descriptors read `command`, `positionals` and `flags` and never touch `internal`.
3-
import type { DispatchedCommand } from '@agent-device/contracts/command';
3+
import type { CommandFlags, DispatchedCommand } from '@agent-device/contracts/command';
44
import type {
55
RuntimeUseStep,
66
RuntimeUseStepSelector,
@@ -1970,3 +1970,28 @@ export function listCommandResponseDataTransformFieldNames(): string[] {
19701970
),
19711971
].sort();
19721972
}
1973+
1974+
// The flag values a command applies when the caller omits them — the single source for both the
1975+
// CLI parser and the daemon request scope. It lives in the registry (not a command facet, not a
1976+
// new module) because the registry is already in both callers' eager closure and declares every
1977+
// other command default here as a literal (see the `defaultValue` descriptors above).
1978+
const COMMAND_DEFAULTS: Partial<Record<DescriptorCliCommandName, Partial<CommandFlags>>> = {
1979+
apps: { appsFilter: 'user-installed' },
1980+
};
1981+
1982+
export function applyCommandDefaults(
1983+
command: string | null,
1984+
flags: Record<string, unknown>,
1985+
): boolean {
1986+
if (!command || !isCliCommandName(command)) return false;
1987+
const defaults = COMMAND_DEFAULTS[command];
1988+
if (!defaults) return false;
1989+
let changed = false;
1990+
for (const key of Object.keys(defaults) as Array<keyof CommandFlags>) {
1991+
if (flags[key] === undefined) {
1992+
flags[key] = defaults[key];
1993+
changed = true;
1994+
}
1995+
}
1996+
return changed;
1997+
}

‎packages/contracts/src/app-inventory.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { AppError } from '@agent-device/kernel/errors';
22

33
export type AppsFilter = 'user-installed' | 'all';
44

5-
export const DEFAULT_APPS_FILTER: AppsFilter = 'user-installed';
5+
// Provider runtimes call `resolveAppsFilter` with an optional filter that never passed through the
6+
// command registry, so they need a fallback of their own. The command default itself lives in the
7+
// command registry's `COMMAND_DEFAULTS`, applied by the CLI parser and the daemon alike.
8+
const DEFAULT_APPS_FILTER: AppsFilter = 'user-installed';
69

710
export function resolveAppsFilter(value: AppsFilter | undefined): AppsFilter {
811
return value ?? DEFAULT_APPS_FILTER;

‎packages/contracts/src/facades/device.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
export type { TriggerAppEventCommandResult } from '../app-events.ts';
2-
export {
3-
DEFAULT_APPS_FILTER,
4-
assertResolvedAppsFilter,
5-
resolveAppsFilter,
6-
} from '../app-inventory.ts';
2+
export { assertResolvedAppsFilter, resolveAppsFilter } from '../app-inventory.ts';
73
export type { AppsFilter } from '../app-inventory.ts';
84
export type { AppStateCommandResult } from '../app-state.ts';
95
export {

‎scripts/layering/daemon-modularity.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,38 @@ test('R10 banks an R7 shrink with no edit anywhere', () => {
106106
);
107107
});
108108

109+
test('R10 forbids the daemon importing the CLI schema layer, value or type', () => {
110+
const valueEdge: ResolvedImportEdge = {
111+
...importEdge('src/daemon/request-execution-scope.ts', 'src/cli-schema/command-schema.ts'),
112+
typeOnly: false,
113+
};
114+
const typeEdge = importEdge(
115+
'src/daemon/session-action-recorder.ts',
116+
'src/cli-schema/command-schema.ts',
117+
);
118+
const violations = checkDaemonModularityRatchets(
119+
[...baselineEdges(), valueEdge, typeEdge],
120+
REFERENCE,
121+
REFERENCE,
122+
);
123+
assert.deepEqual(
124+
violations.map(({ rule, file }) => ({ rule, file })),
125+
[
126+
{ rule: 'R10 daemon-modularity', file: 'src/daemon/request-execution-scope.ts' },
127+
{ rule: 'R10 daemon-modularity', file: 'src/daemon/session-action-recorder.ts' },
128+
],
129+
);
130+
assert.match(violations[0]!.message, /must not import src\/cli-schema\/command-schema\.ts/);
131+
});
132+
133+
test('R10 permits a non-daemon importer of the CLI schema layer', () => {
134+
const cliEdge = importEdge('src/cli/parser/args.ts', 'src/cli-schema/command-schema.ts');
135+
assert.deepEqual(
136+
checkDaemonModularityRatchets([...baselineEdges(), cliEdge], REFERENCE, REFERENCE),
137+
[],
138+
);
139+
});
140+
109141
test('external daemon request/session-state importer membership changes require the baseline to change', () => {
110142
const edges = resolveImportEdges(
111143
new Map([

‎scripts/layering/daemon-modularity.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,35 @@ export function checkDaemonModularityRatchets(
7272
...checkSessionStateBaseline(measured.sessionState, reference.sessionState),
7373
...checkTypeCycleBaseline(measured.largestTypeCycle, reference.largestTypeCycle),
7474
...checkDaemonTypesImporters(edges),
75+
...checkDaemonCliSchemaBoundary(edges),
7576
...checkLogicalModuleImports(edges),
7677
];
7778
}
7879

80+
// The daemon resolves command routes through the registry and fills request defaults through the
81+
// command registry, so it never needs the CLI schema layer. #2543 cut the last two value edges
82+
// (`request-execution-scope.ts` and `session-action-recorder.ts`); this pins that decoupling so a
83+
// future daemon module cannot reach `src/cli-schema/` again and re-pull the parser closure.
84+
const DAEMON_FORBIDDEN_CLI_SCHEMA_ROOT = 'src/cli-schema/';
85+
86+
function checkDaemonCliSchemaBoundary(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
87+
return edges
88+
.filter(
89+
(edge) =>
90+
edge.file.startsWith('src/daemon/') &&
91+
matchesDeclaredRoot(edge.target, DAEMON_FORBIDDEN_CLI_SCHEMA_ROOT),
92+
)
93+
.map((edge) => ({
94+
rule: 'R10 daemon-modularity',
95+
file: edge.file,
96+
line: edge.line,
97+
message:
98+
`${edge.file} must not import ${edge.target}: the daemon resolves routes through ` +
99+
'@agent-device/command-registry and must not load the CLI schema layer. Declare the shared ' +
100+
'shape in the command registry or contracts, not in src/cli-schema/.',
101+
}));
102+
}
103+
79104
export function checkRetiredSessionLifecyclePaths(
80105
sourceFiles: readonly string[],
81106
): LayeringViolation[] {

‎src/cli-schema/command-schema.ts‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
getFlagDefinition,
77
getFlagDefinitions,
88
projectConfigFlagKeys,
9-
recordedFlagKeys,
109
} from '@agent-device/command-registry/flag-registry';
1110
import {
1211
COMMON_COMMAND_SUPPORTED_FLAG_KEYS,
@@ -24,7 +23,6 @@ export {
2423
getFlagDefinitions,
2524
GLOBAL_FLAG_KEYS,
2625
projectConfigFlagKeys,
27-
recordedFlagKeys,
2826
};
2927

3028
// Bases hold only the flags every command supports; prose arrives with the facet's schema,
@@ -73,19 +71,3 @@ function readCommandSchema(command: string): CommandSchema | undefined {
7371
if (!base || !override) return undefined;
7472
return { ...base, ...override };
7573
}
76-
77-
export function applyCommandDefaults(
78-
command: string | null,
79-
flags: Record<string, unknown>,
80-
): boolean {
81-
const commandSchema = getCommandSchema(command);
82-
if (!commandSchema?.defaults) return false;
83-
let changed = false;
84-
for (const [key, value] of Object.entries(commandSchema.defaults) as Array<[FlagKey, unknown]>) {
85-
if (flags[key] === undefined) {
86-
flags[key] = value;
87-
changed = true;
88-
}
89-
}
90-
return changed;
91-
}

‎src/cli-schema/types.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { FlagKey } from '@agent-device/command-registry/flag-types';
2-
import type { CliFlags } from '@agent-device/contracts/command';
32
import type { CommandText } from '@agent-device/command-registry/command-text';
43

54
/**
@@ -12,7 +11,6 @@ export type CommandSchema = {
1211
allowsExtraPositionals?: boolean;
1312
allowedFlags?: readonly FlagKey[];
1413
supportedFlags?: readonly FlagKey[];
15-
defaults?: Partial<CliFlags>;
1614
/**
1715
* Replaces the generated synopsis grammar in `--help`, for shapes the generator cannot express.
1816
* The flag tail after it stays generated from `usageFlags`, so this string never restates the

‎src/cli/parser/args.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { CliFlags } from '@agent-device/contracts/command';
22
import { AppError } from '@agent-device/kernel/errors';
33
import { mergeDefinedFlags } from '../../cli-schema/merge-flags.ts';
44
import {
5-
applyCommandDefaults,
65
assertCommandPositionalArity,
76
getCommandSchema,
87
getFlagDefinition,
@@ -11,6 +10,7 @@ import {
1110
type FlagKey,
1211
} from '../../cli-schema/command-schema.ts';
1312
import { isFlagSupportedForCommand } from '../../cli-schema/option-schema.ts';
13+
import { applyCommandDefaults } from '@agent-device/command-registry/registry';
1414
import { isKnownCliCommandName } from '@agent-device/command-registry/catalog';
1515
import {
1616
cliCommandAlias,

‎src/commands/management/app.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog';
22
import type { AppCloseOptions, AppOpenOptions } from '@agent-device/contracts/client';
3-
import { DEFAULT_APPS_FILTER } from '@agent-device/contracts/device';
43
import { SESSION_SURFACES } from '@agent-device/contracts/session';
54
import type { CommandSchemaOverride } from '../../cli-schema/types.ts';
65
import { assertResolvedAppsFilter } from './app-inventory-contract.ts';
@@ -108,7 +107,6 @@ function toAppOpenOptions(
108107

109108
const appsCliSchema = {
110109
allowedFlags: ['appsFilter'],
111-
defaults: { appsFilter: DEFAULT_APPS_FILTER },
112110
} as const satisfies CommandSchemaOverride;
113111

114112
const openCliSchema = {

0 commit comments

Comments
 (0)