Skip to content

Commit 1315c61

Browse files
sorlen008claude
andcommitted
feat(contract): add API schema drift detection (closes #50)
Add contract testing module that captures JSON response schemas, compares them against saved baselines, and reports structural drift (fields added, removed, or type-changed). New CLI subcommands: - `opencli contract snapshot <site> <command>` — save baseline schema - `opencli contract check <site> <command>` — diff against baseline - `opencli contract list` — list saved contracts Schema storage at ~/.opencli/contracts/<site>/<command>.json. Includes 25 unit tests covering schema capture, diffing, formatting, and end-to-end drift detection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5e2e1df commit 1315c61

3 files changed

Lines changed: 771 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,124 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
246246
printCompletionScript(shell);
247247
});
248248

249+
// ── Built-in: contract (API schema drift detection) ──────────────────────
250+
251+
const contractCmd = program.command('contract').description('API schema drift detection');
252+
253+
contractCmd
254+
.command('snapshot')
255+
.description('Run a command and save its response schema as baseline')
256+
.argument('<site>', 'Site name (e.g. hackernews)')
257+
.argument('<command>', 'Command name (e.g. top)')
258+
.argument('[args...]', 'Extra arguments forwarded to the command')
259+
.action(async (site: string, command: string, extraArgs: string[]) => {
260+
const { captureSchema, saveContract, formatSchemaTree } = await import('./contract.js');
261+
const { getRegistry } = await import('./registry.js');
262+
const { executeCommand } = await import('./execution.js');
263+
264+
const key = `${site}/${command}`;
265+
const cmd = getRegistry().get(key);
266+
if (!cmd) {
267+
console.error(chalk.red(`Command not found: ${key}`));
268+
process.exitCode = 1;
269+
return;
270+
}
271+
272+
// Parse extra args as --key value pairs
273+
const kwargs: Record<string, string> = {};
274+
for (let i = 0; i < extraArgs.length; i++) {
275+
const arg = extraArgs[i];
276+
if (arg.startsWith('--') && i + 1 < extraArgs.length) {
277+
kwargs[arg.slice(2)] = extraArgs[++i];
278+
}
279+
}
280+
281+
try {
282+
const result = await executeCommand(cmd, kwargs);
283+
const schema = captureSchema(result);
284+
const filePath = saveContract(site, command, schema);
285+
console.log(chalk.green(`Schema snapshot saved: ${filePath}`));
286+
console.log(formatSchemaTree(schema));
287+
} catch (err: any) {
288+
console.error(chalk.red(`Error executing ${key}: ${err.message}`));
289+
process.exitCode = 1;
290+
}
291+
});
292+
293+
contractCmd
294+
.command('check')
295+
.description('Run a command and diff its response schema against the saved baseline')
296+
.argument('<site>', 'Site name')
297+
.argument('<command>', 'Command name')
298+
.argument('[args...]', 'Extra arguments forwarded to the command')
299+
.action(async (site: string, command: string, extraArgs: string[]) => {
300+
const { captureSchema, loadContract, diffSchema, formatDiff } = await import('./contract.js');
301+
const { getRegistry } = await import('./registry.js');
302+
const { executeCommand } = await import('./execution.js');
303+
304+
const key = `${site}/${command}`;
305+
const cmd = getRegistry().get(key);
306+
if (!cmd) {
307+
console.error(chalk.red(`Command not found: ${key}`));
308+
process.exitCode = 1;
309+
return;
310+
}
311+
312+
const baseline = loadContract(site, command);
313+
if (!baseline) {
314+
console.error(chalk.red(`No baseline found for ${key}. Run 'opencli contract snapshot ${site} ${command}' first.`));
315+
process.exitCode = 1;
316+
return;
317+
}
318+
319+
const kwargs: Record<string, string> = {};
320+
for (let i = 0; i < extraArgs.length; i++) {
321+
const arg = extraArgs[i];
322+
if (arg.startsWith('--') && i + 1 < extraArgs.length) {
323+
kwargs[arg.slice(2)] = extraArgs[++i];
324+
}
325+
}
326+
327+
try {
328+
const result = await executeCommand(cmd, kwargs);
329+
const currentSchema = captureSchema(result);
330+
const diffs = diffSchema(baseline.schema, currentSchema);
331+
332+
if (diffs.length === 0) {
333+
console.log(chalk.green(`No schema drift detected for ${key} (baseline from ${baseline.capturedAt})`));
334+
} else {
335+
console.log(chalk.yellow(formatDiff(diffs)));
336+
console.log();
337+
console.log(chalk.dim(`Baseline captured: ${baseline.capturedAt}`));
338+
process.exitCode = 1;
339+
}
340+
} catch (err: any) {
341+
console.error(chalk.red(`Error executing ${key}: ${err.message}`));
342+
process.exitCode = 1;
343+
}
344+
});
345+
346+
contractCmd
347+
.command('list')
348+
.description('List saved contract baselines')
349+
.action(async () => {
350+
const { listContracts } = await import('./contract.js');
351+
const contracts = listContracts();
352+
if (contracts.length === 0) {
353+
console.log(chalk.dim(' No saved contracts. Use "opencli contract snapshot <site> <command>" to create one.'));
354+
return;
355+
}
356+
console.log();
357+
console.log(chalk.bold(' Saved contract baselines'));
358+
console.log();
359+
for (const c of contracts) {
360+
console.log(` ${chalk.cyan(`${c.site}/${c.command}`)} ${chalk.dim(`captured ${c.capturedAt}`)}`);
361+
}
362+
console.log();
363+
console.log(chalk.dim(` ${contracts.length} contract(s)`));
364+
console.log();
365+
});
366+
249367
// ── Plugin management ──────────────────────────────────────────────────────
250368

251369
const pluginCmd = program.command('plugin').description('Manage opencli plugins');

0 commit comments

Comments
 (0)