,
+ default: 'warn' as RuleSeverity,
+ },
+ format: {
+ description: 'Use a specific output format.',
+ choices: ['stylish', 'json', 'markdown', 'html'] as ReadonlyArray<
+ 'stylish' | 'json' | 'markdown' | 'html'
+ >,
+ default: 'stylish' as const,
+ },
+ output: {
+ description: 'Write the diff report to a file.',
+ type: 'string',
+ alias: 'o',
+ },
+ 'fail-on': {
+ description: 'Exit with a non-zero code when changes of this level are found.',
+ choices: ['breaking', 'warning', 'none'] as ReadonlyArray<
+ 'breaking' | 'warning' | 'none'
+ >,
+ default: 'breaking' as const,
+ },
+ }),
+ (argv) => {
+ commandWrapper(handleDiff)(argv);
+ }
+ )
+```
+
+This mirrors exactly how the `stats` registration invokes `commandWrapper(handleStats)(argv)`. If TypeScript complains about the argv type, compare with the `stats` block in the same file and align the option typings (`as ReadonlyArray<...>` / `as const` casts) the same way.
+
+- [ ] **Step 7: Typecheck and run CLI tests**
+
+Run: `npm run typecheck && VITEST_SUITE=unit npx vitest run packages/cli/src/commands/diff --coverage.enabled=false`
+Expected: no type errors; serializer tests PASS.
+
+- [ ] **Step 8: Smoke-run the command manually**
+
+```bash
+cat > /tmp/diff-base.yaml <<'EOF'
+openapi: 3.1.0
+info: { title: T, version: '1.0' }
+paths:
+ /pets:
+ get:
+ responses:
+ '200': { description: OK }
+EOF
+cat > /tmp/diff-rev.yaml <<'EOF'
+openapi: 3.1.0
+info: { title: T, version: '1.0' }
+paths:
+ /pets:
+ get:
+ responses:
+ '200': { description: Pets }
+EOF
+npm run cli -- diff /tmp/diff-base.yaml /tmp/diff-rev.yaml; echo "exit=$?"
+npm run cli -- diff /tmp/diff-base.yaml /tmp/diff-base.yaml --format json; echo "exit=$?"
+```
+
+Expected: first run prints one non-breaking change + summary, `exit=0`; second prints `"changes": []` JSON, `exit=0`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add packages/cli/src/commands/diff packages/cli/src/index.ts
+git commit -m "feat(cli): add diff command with stylish and json formats"
+```
+
+---
+
+### Task 12: Markdown and HTML serializers
+
+**Files:**
+
+- Create: `packages/cli/src/commands/diff/serializers/markdown.ts`
+- Create: `packages/cli/src/commands/diff/serializers/html.ts`
+- Modify: `packages/cli/src/commands/diff/index.ts` (wire real serializers into `SERIALIZERS`)
+- Test: `packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts`
+
+**Interfaces:**
+
+- Produces: `markdownDiff(result: DiffResult): string`, `htmlDiff(result: DiffResult): string`.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// packages/cli/src/commands/diff/__tests__/serializers-rich.test.ts
+import { htmlDiff } from '../serializers/html.js';
+import { markdownDiff } from '../serializers/markdown.js';
+
+import type { DiffResult } from '../engine/types.js';
+
+const RESULT: DiffResult = {
+ version: '1',
+ specVersions: { base: 'oas3_1', revision: 'oas3_1' },
+ summary: { breaking: 1, warning: 0, nonBreaking: 0 },
+ changes: [
+ {
+ pointer: '#/paths/~1pets/get',
+ kind: 'removed',
+ typeName: 'Operation',
+ base: { pointer: '#/paths/~1pets/get', value: { summary: '' } },
+ compat: 'breaking',
+ ruleIds: ['operation-removed'],
+ message: 'Operation was removed.',
+ },
+ ],
+};
+
+describe('markdownDiff', () => {
+ it('renders a summary and a table row per change', () => {
+ const output = markdownDiff(RESULT);
+ expect(output).toContain('| Impact | Change | Location | Details |');
+ expect(output).toContain('operation-removed');
+ expect(output).toContain('`#/paths/~1pets/get`');
+ expect(output).toContain('**1** breaking');
+ });
+});
+
+describe('htmlDiff', () => {
+ it('renders a self-contained page with escaped values', () => {
+ const output = htmlDiff(RESULT);
+ expect(output).toContain('
+
+
+API diff
+
+ ${breaking} breaking
+ ${warning} warning
+ ${nonBreaking} non-breaking
+ ${escapeHtml(result.specVersions.base)} → ${escapeHtml(
+ result.specVersions.revision
+ )}
+
+${result.changes.map(renderChange).join('\n')}
+
+