From c2c06681c909b87de8ccb752cc0d2c7aee279dca Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Tue, 24 Mar 2026 16:04:31 +0800 Subject: [PATCH] feat: add daily contract check for API schema drift detection (#50) Add a CI workflow that runs 25 public API commands daily, extracts schema snapshots (field names, types, presence rates), and diffs against the previous baseline to detect structural changes. - Four drift types: field_added, field_removed, type_changed, presence_dropped - Snapshots stored as CI artifacts (90-day retention), not committed to repo - Drift preserves old baseline until adapter is fixed - Atomic file writes to prevent CI cancel corruption - Total outage (0 passed) triggers CI failure - 22 unit tests covering extraction, diff, and reporting --- .github/workflows/contract-check.yml | 64 +++++++ .gitignore | 1 + tests/contract/checker.ts | 246 +++++++++++++++++++++++++++ tests/contract/schema.test.ts | 213 +++++++++++++++++++++++ tests/contract/schema.ts | 224 ++++++++++++++++++++++++ 5 files changed, 748 insertions(+) create mode 100644 .github/workflows/contract-check.yml create mode 100644 tests/contract/checker.ts create mode 100644 tests/contract/schema.test.ts create mode 100644 tests/contract/schema.ts diff --git a/.github/workflows/contract-check.yml b/.github/workflows/contract-check.yml new file mode 100644 index 00000000..d981cd81 --- /dev/null +++ b/.github/workflows/contract-check.yml @@ -0,0 +1,64 @@ +name: Contract Check + +on: + schedule: + - cron: '0 8 * * *' + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: contract-check + cancel-in-progress: true + +jobs: + contract-check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npm ci + - run: npm run build + + # Download previous snapshot (first run: skipped) + - uses: dawidd6/action-download-artifact@20319c5641d495c8a52e688b7dc5fada6c3a9fbc + with: + name: contract-snapshots + path: contract-snapshots + workflow: contract-check.yml + branch: main + workflow_conclusion: '' + if_no_artifact_found: ignore + + # Run schema unit tests before checker + - run: npx vitest run tests/contract/ + + # Run contract checker + - id: checker + run: npx tsx tests/contract/checker.ts + env: + SNAPSHOT_DIR: contract-snapshots + + # Always upload snapshots + failure metadata + - uses: actions/upload-artifact@v7 + if: always() + with: + name: contract-snapshots + path: | + contract-snapshots/*.json + contract-snapshots/_failures/*.json + retention-days: 90 + overwrite: true + + # Upload drift report only when checker detected drift (not on other failures) + - uses: actions/upload-artifact@v7 + if: failure() && hashFiles('contract-snapshots/drift-report.json') != '' + with: + name: drift-report + path: contract-snapshots/drift-report.json + retention-days: 90 diff --git a/.gitignore b/.gitignore index 3211b213..9d520fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ docs/.vitepress/cache .windsurf .claude .cortex +contract-snapshots/ diff --git a/tests/contract/checker.ts b/tests/contract/checker.ts new file mode 100644 index 00000000..c59d3ae4 --- /dev/null +++ b/tests/contract/checker.ts @@ -0,0 +1,246 @@ +/** + * Contract checker: runs CLI commands, extracts schemas, + * compares against previous snapshots, and reports drift. + * Run via: npx tsx tests/contract/checker.ts + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + extractSchema, + diffSchemas, + formatReport, + buildReport, + type CommandSchema, + type ContractResult, +} from './schema.js'; + +const exec = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const MAIN = path.join(ROOT, 'dist', 'main.js'); +/** 快照目录:CI 通过环境变量覆盖,本地默认 contract-snapshots/ */ +const SNAPSHOT_DIR = process.env.SNAPSHOT_DIR + ? path.resolve(process.env.SNAPSHOT_DIR) + : path.join(ROOT, 'contract-snapshots'); +const COMMAND_TIMEOUT = 30_000; + +/** 命令定义(固定测试参数) */ +interface CheckTarget { + site: string; + command: string; + args?: string[]; +} + +/** 所有需要检测的命令,新增条目在此追加 */ +const TARGETS: CheckTarget[] = [ + { site: 'hackernews', command: 'top', args: ['--limit', '10'] }, + { site: 'hackernews', command: 'best', args: ['--limit', '10'] }, + { site: 'hackernews', command: 'new', args: ['--limit', '10'] }, + { site: 'hackernews', command: 'show', args: ['--limit', '10'] }, + { site: 'hackernews', command: 'ask', args: ['--limit', '10'] }, + { site: 'hackernews', command: 'jobs', args: ['--limit', '10'] }, + { site: 'v2ex', command: 'hot', args: ['--limit', '10'] }, + { site: 'v2ex', command: 'latest', args: ['--limit', '10'] }, + { site: 'bloomberg', command: 'main', args: ['--limit', '10'] }, + { site: 'bloomberg', command: 'markets', args: ['--limit', '10'] }, + { site: 'bloomberg', command: 'tech', args: ['--limit', '10'] }, + { site: 'apple-podcasts', command: 'top', args: ['--limit', '10'] }, + { site: 'apple-podcasts', command: 'search', args: ['podcast', '--limit', '10'] }, + { site: 'arxiv', command: 'search', args: ['machine learning', '--limit', '10'] }, + { site: 'bbc', command: 'news', args: ['--limit', '10'] }, + { site: 'devto', command: 'top', args: ['--limit', '10'] }, + { site: 'lobsters', command: 'hot', args: ['--limit', '10'] }, + { site: 'stackoverflow', command: 'hot', args: ['--limit', '10'] }, + { site: 'steam', command: 'top-sellers', args: ['--limit', '10'] }, + { site: 'wikipedia', command: 'search', args: ['linux', '--limit', '10'] }, + { site: 'wikipedia', command: 'trending', args: ['--limit', '10'] }, + { site: 'sinafinance', command: 'news', args: ['--limit', '10'] }, + { site: 'weread', command: 'ranking', args: ['--limit', '10'] }, + // "Jokes Aside" podcast by Maotouying Comedy + { site: 'xiaoyuzhou', command: 'podcast', args: ['61791d921989541784257779'] }, + { site: 'yollomi', command: 'models' }, +]; + +/** 快照文件路径 */ +function snapshotPath(site: string, command: string): string { + return path.join(SNAPSHOT_DIR, `${site}_${command}.json`); +} + +/** 加载命令的前一次快照,首次运行返回 null */ +function loadSnapshot(site: string, command: string): CommandSchema | null { + const p = snapshotPath(site, command); + if (!fs.existsSync(p)) return null; + try { + const data = JSON.parse(fs.readFileSync(p, 'utf8')); + // Validate snapshot structure to avoid crashes in diffSchemas + if (!data || typeof data !== 'object' || typeof data.fields !== 'object' || typeof data.rowCount !== 'number') { + console.warn(`Warning: invalid snapshot structure for ${site}/${command}, treating as first run`); + return null; + } + return data as CommandSchema; + } catch (err) { + console.warn(`Warning: corrupt snapshot for ${site}/${command}, treating as first run:`, err); + return null; + } +} + +/** Atomic write: write to .tmp then rename, preventing truncated JSON from CI cancel */ +function atomicWrite(filePath: string, content: string): void { + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, filePath); +} + +/** 保存命令快照 */ +function saveSnapshot(schema: CommandSchema, site: string, command: string): void { + fs.mkdirSync(SNAPSHOT_DIR, { recursive: true }); + atomicWrite(snapshotPath(site, command), JSON.stringify(schema, null, 2) + '\n'); +} + +/** Failure metadata directory (separate from snapshots, persists across drift events) */ +function failureMetaDir(): string { + return path.join(SNAPSHOT_DIR, '_failures'); +} + +/** 读取命令的连续失败次数 */ +function loadFailureCount(site: string, command: string): number { + const metaPath = path.join(failureMetaDir(), `${site}_${command}.json`); + if (!fs.existsSync(metaPath)) return 0; + try { + const data = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + return data.count ?? 0; + } catch { + return 0; + } +} + +/** Save consecutive failure count; deletes the file when count=0 */ +function saveFailureCount(site: string, command: string, count: number): void { + const dir = failureMetaDir(); + fs.mkdirSync(dir, { recursive: true }); + const metaPath = path.join(dir, `${site}_${command}.json`); + if (count === 0) { + if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath); + } else { + atomicWrite(metaPath, JSON.stringify({ count }) + '\n'); + } +} + +/** 运行单条 CLI 命令,返回解析后的 JSON 数组 */ +async function runCommand(target: CheckTarget): Promise<{ data: unknown[] | null; error?: string }> { + const cliArgs = [MAIN, target.site, target.command, ...(target.args ?? []), '-f', 'json']; + try { + const { stdout } = await exec('node', cliArgs, { + cwd: ROOT, + timeout: COMMAND_TIMEOUT, + env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, + }); + const parsed = JSON.parse(stdout.trim()); + if (!Array.isArray(parsed)) return { data: null, error: 'Response is not an array' }; + return { data: parsed }; + } catch (err: any) { + const msg = err.stderr?.trim() || err.message || 'Unknown error'; + const exitCode = err.status ?? err.code ?? 'unknown'; + return { data: null, error: `Exit code ${exitCode}: ${msg.slice(0, 200)}` }; + } +} + +/** Main entry: iterate commands, extract schemas, diff against snapshots, generate report */ +async function main(): Promise { + // Remove stale drift-report from previous artifact download to avoid uploading old report on crash + const staleReport = path.join(SNAPSHOT_DIR, 'drift-report.json'); + if (fs.existsSync(staleReport)) fs.unlinkSync(staleReport); + + const results: ContractResult[] = []; + + for (const target of TARGETS) { + const cmd = `${target.site}/${target.command}`; + const { data, error } = await runCommand(target); + + // 响应失败或为空 + if (!data || data.length === 0) { + const prevFailures = loadFailureCount(target.site, target.command); + const consecutiveFailures = prevFailures + 1; + saveFailureCount(target.site, target.command, consecutiveFailures); + results.push({ + command: cmd, + status: 'failed', + error: error ?? 'empty response', + consecutiveFailures, + }); + continue; + } + + const schema = extractSchema(data, cmd); + + // 全部行非对象,视为失败 + if (schema.rowCount === 0) { + const prevFailures = loadFailureCount(target.site, target.command); + const consecutiveFailures = prevFailures + 1; + saveFailureCount(target.site, target.command, consecutiveFailures); + results.push({ + command: cmd, + status: 'failed', + error: 'no valid object rows in response', + consecutiveFailures, + }); + continue; + } + + // 成功时重置连续失败计数 + saveFailureCount(target.site, target.command, 0); + + const prev = loadSnapshot(target.site, target.command); + + if (!prev) { + // 首次运行:保存基线,不做对比 + saveSnapshot(schema, target.site, target.command); + results.push({ command: cmd, status: 'passed', diffs: [] }); + continue; + } + + const diffs = diffSchemas(prev, schema); + if (diffs.length > 0) { + // 检测到漂移:不更新快照(保留基线) + results.push({ command: cmd, status: 'drifted', diffs }); + } else { + // 无漂移:用最新数据更新快照 + saveSnapshot(schema, target.site, target.command); + results.push({ command: cmd, status: 'passed', diffs: [] }); + } + } + + // 统一时间戳,避免跨日不一致 + const now = new Date(); + + // 输出人类可读摘要 + console.log(formatReport(results, now)); + + // Write JSON report for CI artifact upload + fs.mkdirSync(SNAPSHOT_DIR, { recursive: true }); + const report = buildReport(results, now); + atomicWrite( + path.join(SNAPSHOT_DIR, 'drift-report.json'), + JSON.stringify(report, null, 2) + '\n', + ); + + // Exit with error if drift detected or if zero commands passed (total outage) + const hasDrift = results.some(r => r.status === 'drifted'); + const passedCount = results.filter(r => r.status === 'passed').length; + if (hasDrift) { + process.exit(1); + } + if (passedCount === 0 && results.length > 0) { + console.error('Error: no commands passed — all failed or drifted'); + process.exit(1); + } +} + +main().catch((err) => { + console.error('Contract checker failed:', err); + process.exit(2); +}); diff --git a/tests/contract/schema.test.ts b/tests/contract/schema.test.ts new file mode 100644 index 00000000..2fc532ab --- /dev/null +++ b/tests/contract/schema.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest'; +import { extractSchema, diffSchemas, formatReport, type SchemaDiff, type ContractResult } from './schema.js'; + +describe('extractSchema', () => { + it('extracts field names, types, and presentRate from rows', () => { + const rows = [ + { title: 'a', score: 10, url: 'http://x' }, + { title: 'b', score: 20, url: 'http://y' }, + { title: 'c', score: 30, url: '' }, + ]; + const schema = extractSchema(rows, 'test/cmd'); + expect(schema.command).toBe('test/cmd'); + expect(schema.rowCount).toBe(3); + expect(schema.fields.title).toEqual({ types: ['string'], presentRate: 1.0 }); + expect(schema.fields.score).toEqual({ types: ['number'], presentRate: 1.0 }); + expect(schema.fields.url.presentRate).toBeCloseTo(0.67, 1); + }); + + it('records multiple types when field has mixed values', () => { + const rows = [ + { val: 42 }, + { val: 'text' }, + { val: true }, + ]; + const schema = extractSchema(rows, 'test/mixed'); + expect(schema.fields.val.types.sort()).toEqual(['boolean', 'number', 'string']); + }); + + it('distinguishes arrays from objects', () => { + const rows = [ + { tags: ['a', 'b'], meta: { key: 'val' } }, + { tags: ['c'], meta: { key: 'val2' } }, + ]; + const schema = extractSchema(rows, 'test/array-vs-obj'); + expect(schema.fields.tags.types).toEqual(['array']); + expect(schema.fields.meta.types).toEqual(['object']); + }); + + it('treats null, undefined, and empty string as absent', () => { + const rows = [ + { a: null }, + { a: undefined }, + { a: '' }, + ]; + const schema = extractSchema(rows, 'test/empty'); + expect(schema.fields.a.presentRate).toBe(0); + expect(schema.fields.a.types).toEqual([]); + }); + + it('treats 0 and false as present values', () => { + const rows = [ + { score: 0, active: false }, + { score: 0, active: false }, + ]; + const schema = extractSchema(rows, 'test/falsy'); + expect(schema.fields.score.presentRate).toBe(1.0); + expect(schema.fields.active.presentRate).toBe(1.0); + }); + + it('skips non-object rows', () => { + const rows = ['str', 42, { title: 'a' }, { title: 'b' }, { title: 'c' }] as any[]; + const schema = extractSchema(rows, 'test/mixed-rows'); + expect(schema.rowCount).toBe(3); + }); + + it('handles rows with different field sets (sparse data)', () => { + const rows = [ + { title: 'a', author: 'x' }, + { title: 'b' }, + { title: 'c', author: 'z' }, + ]; + const schema = extractSchema(rows, 'test/sparse'); + expect(schema.fields.title.presentRate).toBe(1.0); + expect(schema.fields.author.presentRate).toBeCloseTo(0.67, 1); + }); + + it('returns rowCount 0 when all rows are non-objects', () => { + const rows = ['a', 'b', 'c'] as any[]; + const schema = extractSchema(rows, 'test/all-primitives'); + expect(schema.rowCount).toBe(0); + expect(Object.keys(schema.fields)).toHaveLength(0); + }); +}); + +describe('diffSchemas', () => { + it('returns empty diffs for identical schemas', () => { + const schema = extractSchema([{ a: 1, b: 'x' }, { a: 2, b: 'y' }], 'test/same'); + const diffs = diffSchemas(schema, schema); + expect(diffs).toEqual([]); + }); + + it('detects field_added', () => { + const prev = extractSchema([{ a: 1 }], 'test/prev'); + const curr = extractSchema([{ a: 1, b: 'new' }], 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs).toContainEqual(expect.objectContaining({ type: 'field_added', field: 'b' })); + }); + + it('detects field_removed', () => { + const prev = extractSchema([{ a: 1, b: 'old' }], 'test/prev'); + const curr = extractSchema([{ a: 1 }], 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs).toContainEqual(expect.objectContaining({ type: 'field_removed', field: 'b' })); + }); + + it('detects type_changed', () => { + const prev = extractSchema([{ a: 1 }, { a: 2 }, { a: 3 }], 'test/prev'); + const curr = extractSchema([{ a: '1' }, { a: '2' }, { a: '3' }], 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs).toContainEqual(expect.objectContaining({ type: 'type_changed', field: 'a', from: 'number', to: 'string' })); + }); + + it('detects array-to-object type change', () => { + const prev = extractSchema([{ tags: ['a'] }, { tags: ['b'] }], 'test/prev'); + const curr = extractSchema([{ tags: { key: 'a' } }, { tags: { key: 'b' } }], 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs).toContainEqual(expect.objectContaining({ type: 'type_changed', field: 'tags', from: 'array', to: 'object' })); + }); + + it('detects presence_dropped when both sides have >= 5 rows', () => { + const prevRows = Array.from({ length: 10 }, () => ({ a: 'val' })); + const currRows = [ + ...Array.from({ length: 3 }, () => ({ a: 'val' })), + ...Array.from({ length: 7 }, () => ({ a: '' })), + ]; + const prev = extractSchema(prevRows, 'test/prev'); + const curr = extractSchema(currRows, 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs).toContainEqual(expect.objectContaining({ type: 'presence_dropped', field: 'a' })); + }); + + it('skips presence_dropped when curr rowCount < 5', () => { + const prev = extractSchema([{ a: 'x' }, { a: 'y' }, { a: 'z' }], 'test/prev'); + const curr = extractSchema([{ a: '' }, { a: '' }, { a: 'z' }], 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs.filter(d => d.type === 'presence_dropped')).toHaveLength(0); + }); + + it('skips presence_dropped when prev rowCount < 5', () => { + const prev = extractSchema(Array.from({ length: 3 }, () => ({ a: 'val' })), 'test/prev'); + const curr = extractSchema([ + ...Array.from({ length: 2 }, () => ({ a: 'val' })), + ...Array.from({ length: 8 }, () => ({ a: '' })), + ], 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs.filter(d => d.type === 'presence_dropped')).toHaveLength(0); + }); + + it('flags type_changed when type set expands', () => { + const prev = extractSchema([{ a: 1 }, { a: 2 }], 'test/prev'); + const curr = extractSchema([{ a: 1 }, { a: 'two' }], 'test/curr'); + const diffs = diffSchemas(prev, curr); + expect(diffs).toContainEqual(expect.objectContaining({ type: 'type_changed', field: 'a' })); + }); + + it('does not flag type_changed when field goes from present to all-empty', () => { + const prevRows = Array.from({ length: 10 }, () => ({ title: 'x' })); + const currRows = Array.from({ length: 10 }, () => ({ title: '' })); + const prev = extractSchema(prevRows, 'test/prev'); + const curr = extractSchema(currRows, 'test/curr'); + const diffs = diffSchemas(prev, curr); + // Should only report presence_dropped, NOT type_changed + expect(diffs.filter(d => d.type === 'type_changed')).toHaveLength(0); + expect(diffs).toContainEqual(expect.objectContaining({ type: 'presence_dropped', field: 'title' })); + }); +}); + +describe('formatReport', () => { + it('prints passed commands with checkmark', () => { + const results: ContractResult[] = [ + { command: 'hackernews/top', status: 'passed', diffs: [] }, + ]; + const output = formatReport(results); + expect(output).toContain('✓'); + expect(output).toContain('hackernews/top'); + expect(output).toContain('no drift'); + }); + + it('prints drifted commands with cross and diff details', () => { + const results: ContractResult[] = [ + { + command: 'v2ex/hot', + status: 'drifted', + diffs: [{ type: 'field_removed', field: 'author', detail: 'missing from response' }], + }, + ]; + const output = formatReport(results); + expect(output).toContain('✗'); + expect(output).toContain('v2ex/hot'); + expect(output).toContain("field 'author'"); + }); + + it('prints failed commands with warning symbol', () => { + const results: ContractResult[] = [ + { command: 'bloomberg/markets', status: 'failed', error: 'timeout' }, + ]; + const output = formatReport(results); + expect(output).toContain('⚠'); + expect(output).toContain('command failed'); + }); + + it('prints summary line with counts', () => { + const results: ContractResult[] = [ + { command: 'a/b', status: 'passed', diffs: [] }, + { command: 'c/d', status: 'drifted', diffs: [{ type: 'field_removed', field: 'x', detail: '' }] }, + { command: 'e/f', status: 'failed', error: 'err' }, + ]; + const output = formatReport(results); + expect(output).toContain('1 passed'); + expect(output).toContain('1 drifted'); + expect(output).toContain('1 failed'); + }); +}); diff --git a/tests/contract/schema.ts b/tests/contract/schema.ts new file mode 100644 index 00000000..dbe1652b --- /dev/null +++ b/tests/contract/schema.ts @@ -0,0 +1,224 @@ +/** Schema for a single field */ +export interface FieldSchema { + types: string[]; + presentRate: number; +} + +/** Schema snapshot for one command */ +export interface CommandSchema { + command: string; + timestamp: string; + rowCount: number; + fields: Record; +} + +/** + * Resolve the type of a value for schema purposes. + * Distinguishes arrays from plain objects (both return "object" from typeof). + */ +function resolveType(val: unknown): string { + if (Array.isArray(val)) return 'array'; + return typeof val; +} + +/** + * Extract schema from command output rows. + * Skips non-object rows. Records all observed types per field + * and the proportion of rows where the field has a non-empty value. + */ +export function extractSchema(rows: unknown[], command: string): CommandSchema { + const objectRows = rows.filter( + (r): r is Record => typeof r === 'object' && r !== null && !Array.isArray(r), + ); + + const fieldMap = new Map; presentCount: number }>(); + + for (const row of objectRows) { + for (const [key, val] of Object.entries(row)) { + if (!fieldMap.has(key)) { + fieldMap.set(key, { typeSet: new Set(), presentCount: 0 }); + } + const entry = fieldMap.get(key)!; + const isPresent = val !== null && val !== undefined && val !== ''; + if (isPresent) { + entry.presentCount++; + entry.typeSet.add(resolveType(val)); + } + } + } + + const rowCount = objectRows.length; + const fields: Record = {}; + for (const [key, entry] of fieldMap) { + fields[key] = { + types: [...entry.typeSet].sort(), + presentRate: rowCount > 0 ? Math.round((entry.presentCount / rowCount) * 100) / 100 : 0, + }; + } + + return { + command, + timestamp: new Date().toISOString(), + rowCount, + fields, + }; +} + +/** A single detected schema change */ +export interface SchemaDiff { + type: 'field_added' | 'field_removed' | 'type_changed' | 'presence_dropped'; + field: string; + detail: string; + from?: string; + to?: string; +} + +/** Minimum row count required on both sides for presence_dropped detection */ +const MIN_ROWS_FOR_PRESENCE = 5; +/** Minimum presentRate drop to trigger presence_dropped */ +const PRESENCE_DROP_THRESHOLD = 0.3; + +/** + * Compare two schemas and return a list of structural differences. + * presence_dropped requires both sides to have >= MIN_ROWS_FOR_PRESENCE rows. + */ +export function diffSchemas(prev: CommandSchema, curr: CommandSchema): SchemaDiff[] { + const diffs: SchemaDiff[] = []; + const prevFields = new Set(Object.keys(prev.fields)); + const currFields = new Set(Object.keys(curr.fields)); + + // field_removed: in prev but not in curr + for (const field of prevFields) { + if (!currFields.has(field)) { + diffs.push({ type: 'field_removed', field, detail: 'missing from response' }); + } + } + + // field_added: in curr but not in prev + for (const field of currFields) { + if (!prevFields.has(field)) { + const typesStr = curr.fields[field].types.join(', ') || 'unknown'; + diffs.push({ type: 'field_added', field, detail: `(${typesStr})` }); + } + } + + // type_changed + presence_dropped: fields present in both + for (const field of prevFields) { + if (!currFields.has(field)) continue; + const pf = prev.fields[field]; + const cf = curr.fields[field]; + + // type_changed: only compare when both sides have actual types + // (empty types set means all values were absent — that's a presence issue, not a type issue) + const prevTypes = pf.types.join(','); + const currTypes = cf.types.join(','); + if (prevTypes !== currTypes && prevTypes.length > 0 && currTypes.length > 0) { + diffs.push({ + type: 'type_changed', + field, + detail: `${prevTypes} -> ${currTypes}`, + from: prevTypes, + to: currTypes, + }); + } + + // presence_dropped: both sides must have enough rows + if (prev.rowCount >= MIN_ROWS_FOR_PRESENCE && curr.rowCount >= MIN_ROWS_FOR_PRESENCE) { + const drop = pf.presentRate - cf.presentRate; + if (drop > PRESENCE_DROP_THRESHOLD) { + const pctPrev = Math.round(pf.presentRate * 100); + const pctCurr = Math.round(cf.presentRate * 100); + diffs.push({ + type: 'presence_dropped', + field, + detail: `${pctPrev}% -> ${pctCurr}% present`, + from: `${pctPrev}%`, + to: `${pctCurr}%`, + }); + } + } + } + + return diffs; +} + +/** 单条命令检测结果 */ +export interface ContractResult { + command: string; + status: 'passed' | 'drifted' | 'failed'; + diffs?: SchemaDiff[]; + error?: string; + consecutiveFailures?: number; +} + +/** 完整的漂移报告(写入 JSON 文件) */ +export interface DriftReport { + timestamp: string; + summary: { total: number; passed: number; drifted: number; failed: number }; + results: ContractResult[]; +} + +/** Commands failing for this many consecutive days are marked as degraded */ +const DEGRADED_THRESHOLD = 7; + +/** + * Format contract check results as human-readable console output. + * No ANSI colors — CI logs render plain text fine. + */ +export function formatReport(results: ContractResult[], now?: Date): string { + const lines: string[] = []; + const date = (now ?? new Date()).toISOString().slice(0, 10); + lines.push(`Schema Contract Check -- ${date}`); + lines.push(''); + + for (const r of results) { + if (r.status === 'passed') { + lines.push(` ✓ ${r.command.padEnd(24)} -- no drift`); + } else if (r.status === 'drifted') { + const diffs = r.diffs ?? []; + lines.push(` ✗ ${r.command.padEnd(24)} -- ${diffs.length} drift(s) detected`); + for (const d of diffs) { + // Prefix symbol per diff type + const prefix = d.type === 'field_added' ? '+' : + d.type === 'field_removed' ? '-' : + d.type === 'type_changed' ? '~' : '↓'; + lines.push(` ${prefix} field '${d.field}' -- ${d.detail}`); + } + } else { + const degraded = (r.consecutiveFailures ?? 0) >= DEGRADED_THRESHOLD; + const suffix = degraded + ? ` (${r.consecutiveFailures} consecutive, degraded)` + : ''; + lines.push(` ⚠ ${r.command.padEnd(24)} -- command failed${suffix}`); + } + } + + const passed = results.filter(r => r.status === 'passed').length; + const drifted = results.filter(r => r.status === 'drifted').length; + const failed = results.filter(r => r.status === 'failed').length; + const degradedCount = results.filter( + r => r.status === 'failed' && (r.consecutiveFailures ?? 0) >= DEGRADED_THRESHOLD, + ).length; + const degradedSuffix = degradedCount > 0 ? ` (${degradedCount} degraded)` : ''; + + lines.push(''); + lines.push(`Summary: ${passed} passed, ${drifted} drifted, ${failed} failed${degradedSuffix}`); + + return lines.join('\n'); +} + +/** + * Build the full JSON drift report from results. + */ +export function buildReport(results: ContractResult[], now?: Date): DriftReport { + return { + timestamp: (now ?? new Date()).toISOString(), + summary: { + total: results.length, + passed: results.filter(r => r.status === 'passed').length, + drifted: results.filter(r => r.status === 'drifted').length, + failed: results.filter(r => r.status === 'failed').length, + }, + results, + }; +}