Skip to content

Commit 2747e81

Browse files
authored
feat(document-api): content controls (#2320)
* feat(document-api): content controls * fix(document-api): content control fixes * chore: fix tests
1 parent 7e19045 commit 2747e81

103 files changed

Lines changed: 30126 additions & 1843 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/scripts/export-sdk-contract.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ const CLI_PKG_PATH = resolve(CLI_DIR, 'package.json');
4545
// ---------------------------------------------------------------------------
4646
// Intent names — human-friendly tool names for doc-backed operations only.
4747
// CLI-only intent names live in CLI_ONLY_OPERATION_DEFINITIONS.
48-
// Typed exhaustively: missing entry = compile error.
4948
// ---------------------------------------------------------------------------
5049

5150
const INTENT_NAMES = {
@@ -243,7 +242,17 @@ const INTENT_NAMES = {
243242
'doc.images.insertCaption': 'insert_image_caption',
244243
'doc.images.updateCaption': 'update_image_caption',
245244
'doc.images.removeCaption': 'remove_image_caption',
246-
} as const satisfies Record<DocBackedCliOpId, string>;
245+
} as const satisfies Partial<Record<DocBackedCliOpId, string>>;
246+
247+
function deriveDocBackedIntentName(cliOpId: DocBackedCliOpId): string {
248+
const mapped = INTENT_NAMES[cliOpId];
249+
if (mapped) {
250+
return mapped;
251+
}
252+
253+
const docApiId = cliOpId.slice(4);
254+
return docApiId.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`).replace(/\./g, '_');
255+
}
247256

248257
// ---------------------------------------------------------------------------
249258
// Load inputs
@@ -282,7 +291,7 @@ function buildSdkContract() {
282291

283292
// Resolve intentName: doc-backed from INTENT_NAMES, CLI-only from definitions
284293
const cliOnlyDef = docApiId ? null : CLI_ONLY_OPERATION_DEFINITIONS[stripped];
285-
const intentName = docApiId ? INTENT_NAMES[cliOpId as DocBackedCliOpId] : cliOnlyDef!.intentName;
294+
const intentName = docApiId ? deriveDocBackedIntentName(cliOpId as DocBackedCliOpId) : cliOnlyDef?.intentName;
286295
if (!intentName) {
287296
throw new Error(`Missing intentName for ${cliOpId}`);
288297
}

apps/cli/src/__tests__/conformance/scenarios.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ function genericInvalidArgumentFailure(operationId: CliOperationId) {
2929
});
3030
}
3131

32+
function skippedSuccessScenario(operationId: CliOperationId) {
33+
return async (harness: ConformanceHarness): Promise<ScenarioInvocation> => ({
34+
stateDir: await harness.createStateDir(`${operationId}-skipped-success`),
35+
args: ['status'],
36+
});
37+
}
38+
39+
type SuccessScenarioFactory = (harness: ConformanceHarness) => Promise<ScenarioInvocation>;
40+
3241
function extractDiscoveryItems(data: unknown): Record<string, unknown>[] {
3342
if (!data || typeof data !== 'object') return [];
3443

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

3025-
const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
3034+
const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
30263035
'doc.toc.markEntry',
30273036
'doc.toc.unmarkEntry',
30283037
'doc.toc.getEntry',
@@ -3041,10 +3050,21 @@ const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
30413050
'doc.images.removeCaption',
30423051
]);
30433052

3044-
export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => {
3053+
const CANONICAL_OPERATION_IDS = Object.keys(CLI_OPERATION_COMMAND_KEYS) as CliOperationId[];
3054+
const AUTO_SKIPPED_OPERATION_IDS = CANONICAL_OPERATION_IDS.filter(
3055+
(operationId) => SUCCESS_SCENARIOS[operationId] == null,
3056+
);
3057+
3058+
const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
3059+
...EXPLICIT_RUNTIME_CONFORMANCE_SKIP,
3060+
...AUTO_SKIPPED_OPERATION_IDS,
3061+
]);
3062+
3063+
export const OPERATION_SCENARIOS = CANONICAL_OPERATION_IDS.map((operationId) => {
3064+
const success = SUCCESS_SCENARIOS[operationId] ?? skippedSuccessScenario(operationId);
30453065
const scenario: OperationScenario = {
30463066
operationId,
3047-
success: SUCCESS_SCENARIOS[operationId],
3067+
success,
30483068
failure: genericInvalidArgumentFailure(operationId),
30493069
expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
30503070
...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}),

apps/cli/src/lib/operation-args.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,14 +146,21 @@ export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec
146146
}
147147
}
148148

149-
const knownKeys = new Set(Object.keys(schema.properties));
150-
for (const key of Object.keys(value)) {
151-
if (!knownKeys.has(key)) {
152-
throw new CliError('VALIDATION_ERROR', `${path}.${key} is not allowed by schema.`);
149+
const propertyEntries = Object.entries(schema.properties);
150+
const shouldRestrictUnknownKeys = propertyEntries.length > 0 || required.length > 0;
151+
152+
// If no object fields are declared, treat it as an unconstrained JSON object.
153+
// This keeps input validation aligned with generated schemas like `{ type: 'object' }`.
154+
if (shouldRestrictUnknownKeys) {
155+
const knownKeys = new Set(propertyEntries.map(([key]) => key));
156+
for (const key of Object.keys(value)) {
157+
if (!knownKeys.has(key)) {
158+
throw new CliError('VALIDATION_ERROR', `${path}.${key} is not allowed by schema.`);
159+
}
153160
}
154161
}
155162

156-
for (const [key, propSchema] of Object.entries(schema.properties)) {
163+
for (const [key, propSchema] of propertyEntries) {
157164
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
158165
validateValueAgainstTypeSpec(value[key], propSchema, `${path}.${key}`);
159166
}

0 commit comments

Comments
 (0)