Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/cli/scripts/export-sdk-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ const CLI_PKG_PATH = resolve(CLI_DIR, 'package.json');
// ---------------------------------------------------------------------------
// Intent names — human-friendly tool names for doc-backed operations only.
// CLI-only intent names live in CLI_ONLY_OPERATION_DEFINITIONS.
// Typed exhaustively: missing entry = compile error.
// ---------------------------------------------------------------------------

const INTENT_NAMES = {
Expand Down Expand Up @@ -243,7 +242,17 @@ const INTENT_NAMES = {
'doc.images.insertCaption': 'insert_image_caption',
'doc.images.updateCaption': 'update_image_caption',
'doc.images.removeCaption': 'remove_image_caption',
} as const satisfies Record<DocBackedCliOpId, string>;
} as const satisfies Partial<Record<DocBackedCliOpId, string>>;

function deriveDocBackedIntentName(cliOpId: DocBackedCliOpId): string {
const mapped = INTENT_NAMES[cliOpId];
if (mapped) {
return mapped;
}

const docApiId = cliOpId.slice(4);
return docApiId.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`).replace(/\./g, '_');
}

// ---------------------------------------------------------------------------
// Load inputs
Expand Down Expand Up @@ -282,7 +291,7 @@ function buildSdkContract() {

// Resolve intentName: doc-backed from INTENT_NAMES, CLI-only from definitions
const cliOnlyDef = docApiId ? null : CLI_ONLY_OPERATION_DEFINITIONS[stripped];
const intentName = docApiId ? INTENT_NAMES[cliOpId as DocBackedCliOpId] : cliOnlyDef!.intentName;
const intentName = docApiId ? deriveDocBackedIntentName(cliOpId as DocBackedCliOpId) : cliOnlyDef?.intentName;
if (!intentName) {
throw new Error(`Missing intentName for ${cliOpId}`);
}
Expand Down
28 changes: 24 additions & 4 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ function genericInvalidArgumentFailure(operationId: CliOperationId) {
});
}

function skippedSuccessScenario(operationId: CliOperationId) {
return async (harness: ConformanceHarness): Promise<ScenarioInvocation> => ({
stateDir: await harness.createStateDir(`${operationId}-skipped-success`),
args: ['status'],
});
}

type SuccessScenarioFactory = (harness: ConformanceHarness) => Promise<ScenarioInvocation>;

function extractDiscoveryItems(data: unknown): Record<string, unknown>[] {
if (!data || typeof data !== 'object') return [];

Expand Down Expand Up @@ -3020,9 +3029,9 @@ export const SUCCESS_SCENARIOS = {
await harness.openSessionFixture(stateDir, 'doc-history-redo', 'history-redo-session');
return { stateDir, args: ['history', 'redo', '--session', 'history-redo-session'] };
},
} as const satisfies Record<CliOperationId, (harness: ConformanceHarness) => Promise<ScenarioInvocation>>;
} as const satisfies Partial<Record<CliOperationId, SuccessScenarioFactory>>;

const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
'doc.toc.markEntry',
'doc.toc.unmarkEntry',
'doc.toc.getEntry',
Expand All @@ -3041,10 +3050,21 @@ const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
'doc.images.removeCaption',
]);

export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => {
const CANONICAL_OPERATION_IDS = Object.keys(CLI_OPERATION_COMMAND_KEYS) as CliOperationId[];
const AUTO_SKIPPED_OPERATION_IDS = CANONICAL_OPERATION_IDS.filter(
(operationId) => SUCCESS_SCENARIOS[operationId] == null,
);

const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
...EXPLICIT_RUNTIME_CONFORMANCE_SKIP,
...AUTO_SKIPPED_OPERATION_IDS,
]);

export const OPERATION_SCENARIOS = CANONICAL_OPERATION_IDS.map((operationId) => {
const success = SUCCESS_SCENARIOS[operationId] ?? skippedSuccessScenario(operationId);
const scenario: OperationScenario = {
operationId,
success: SUCCESS_SCENARIOS[operationId],
success,
failure: genericInvalidArgumentFailure(operationId),
expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}),
Expand Down
17 changes: 12 additions & 5 deletions apps/cli/src/lib/operation-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,21 @@ export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec
}
}

const knownKeys = new Set(Object.keys(schema.properties));
for (const key of Object.keys(value)) {
if (!knownKeys.has(key)) {
throw new CliError('VALIDATION_ERROR', `${path}.${key} is not allowed by schema.`);
const propertyEntries = Object.entries(schema.properties);
const shouldRestrictUnknownKeys = propertyEntries.length > 0 || required.length > 0;

// If no object fields are declared, treat it as an unconstrained JSON object.
// This keeps input validation aligned with generated schemas like `{ type: 'object' }`.
if (shouldRestrictUnknownKeys) {
const knownKeys = new Set(propertyEntries.map(([key]) => key));
for (const key of Object.keys(value)) {
if (!knownKeys.has(key)) {
throw new CliError('VALIDATION_ERROR', `${path}.${key} is not allowed by schema.`);
}
}
}

for (const [key, propSchema] of Object.entries(schema.properties)) {
for (const [key, propSchema] of propertyEntries) {
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
validateValueAgainstTypeSpec(value[key], propSchema, `${path}.${key}`);
}
Expand Down
Loading
Loading