diff --git a/src/drift.test.ts b/src/drift.test.ts new file mode 100644 index 0000000..9dae187 --- /dev/null +++ b/src/drift.test.ts @@ -0,0 +1,183 @@ +/** + * Tests for drift.ts: schema drift detection. + */ + +import { describe, it, expect } from 'vitest'; +import { detectDrift, formatDriftWarning } from './drift.js'; + +describe('detectDrift', () => { + it('returns no drift when all fields are populated', () => { + const rows = [ + { title: 'a', author: 'x' }, + { title: 'b', author: 'y' }, + { title: 'c', author: 'z' }, + ]; + const report = detectDrift(rows, ['title', 'author']); + expect(report.hasDrift).toBe(false); + expect(report.fields).toHaveLength(0); + }); + + it('detects single field all-empty', () => { + const rows = [ + { title: 'a', author: '' }, + { title: 'b', author: '' }, + { title: 'c', author: '' }, + ]; + const report = detectDrift(rows, ['title', 'author']); + expect(report.hasDrift).toBe(true); + expect(report.fields).toHaveLength(1); + expect(report.fields[0].field).toBe('author'); + expect(report.fields[0].emptyRate).toBe(1.0); + expect(report.fields[0].emptyCount).toBe(3); + expect(report.fields[0].totalRows).toBe(3); + }); + + it('detects drift at threshold boundary (>= 0.8)', () => { + // 4 out of 5 = 80% + const rows = [ + { title: '' }, + { title: '' }, + { title: '' }, + { title: '' }, + { title: 'x' }, + ]; + const report = detectDrift(rows, ['title'], 0.8); + expect(report.hasDrift).toBe(true); + expect(report.fields[0].emptyRate).toBe(0.8); + }); + + it('does not detect drift below threshold (60%)', () => { + // 3 out of 5 = 60%, well below default 0.8 + const rows = [ + { title: '' }, + { title: '' }, + { title: '' }, + { title: 'x' }, + { title: 'y' }, + ]; + const report = detectDrift(rows, ['title']); + expect(report.hasDrift).toBe(false); + }); + + it('does not detect drift just below threshold (75%)', () => { + // 3 out of 4 = 75%, just under 0.8 + const rows = [ + { title: '' }, + { title: '' }, + { title: '' }, + { title: 'x' }, + ]; + const report = detectDrift(rows, ['title'], 0.8); + expect(report.hasDrift).toBe(false); + }); + + it('detects multiple fields drifting', () => { + const rows = [ + { title: '', author: '', score: 10 }, + { title: '', author: '', score: 20 }, + { title: '', author: '', score: 30 }, + ]; + const report = detectDrift(rows, ['title', 'author', 'score']); + expect(report.hasDrift).toBe(true); + expect(report.fields).toHaveLength(2); + const fieldNames = report.fields.map(f => f.field); + expect(fieldNames).toContain('title'); + expect(fieldNames).toContain('author'); + }); + + it('returns no drift for empty columns array', () => { + const rows = [{ title: '' }, { title: '' }, { title: '' }]; + const report = detectDrift(rows, []); + expect(report.hasDrift).toBe(false); + expect(report.fields).toHaveLength(0); + }); + + it('detects columns not present in rows as 100% empty', () => { + const rows = [ + { title: 'a' }, + { title: 'b' }, + { title: 'c' }, + ]; + const report = detectDrift(rows, ['title', 'missing_field']); + expect(report.hasDrift).toBe(true); + expect(report.fields[0].field).toBe('missing_field'); + expect(report.fields[0].emptyRate).toBe(1.0); + }); + + it('treats null, undefined, and empty string as empty', () => { + const rows = [ + { a: null }, + { a: undefined }, + { a: '' }, + ]; + const report = detectDrift(rows, ['a']); + expect(report.hasDrift).toBe(true); + expect(report.fields[0].emptyCount).toBe(3); + }); + + it('treats 0 and false as non-empty values', () => { + const rows = [ + { score: 0, active: false }, + { score: 0, active: false }, + { score: 0, active: false }, + ]; + const report = detectDrift(rows, ['score', 'active']); + expect(report.hasDrift).toBe(false); + }); + + it('skips non-object rows (primitives)', () => { + const rows = [ + 'string-row', + 42, + { title: 'a', author: '' }, + { title: 'b', author: '' }, + { title: 'c', author: '' }, + ] as any[]; + const report = detectDrift(rows, ['title', 'author']); + expect(report.hasDrift).toBe(true); + // totalRows should be 3 (only object rows counted) + expect(report.fields[0].totalRows).toBe(3); + }); + + it('returns no drift when valid object rows < 3', () => { + const rows = [ + 'primitive', + 'primitive', + 'primitive', + { title: '', author: '' }, + { title: '', author: '' }, + ] as any[]; + const report = detectDrift(rows, ['title', 'author']); + // Only 2 valid object rows, below minimum of 3 + expect(report.hasDrift).toBe(false); + }); +}); + +describe('formatDriftWarning', () => { + it('formats warning with field names, counts, and percentages', () => { + const report = { + hasDrift: true, + fields: [ + { field: 'author', emptyCount: 10, totalRows: 10, emptyRate: 1.0 }, + { field: 'title', emptyCount: 8, totalRows: 10, emptyRate: 0.8 }, + ], + }; + const output = formatDriftWarning(report, 'bilibili/hot'); + // Strip ANSI codes for assertion + const plain = output.replace(/\x1b\[[0-9;]*m/g, ''); + expect(plain).toContain('Schema drift detected (bilibili/hot)'); + expect(plain).toContain("field 'author'"); + expect(plain).toContain('10/10 rows (100%)'); + expect(plain).toContain("field 'title'"); + expect(plain).toContain('8/10 rows (80%)'); + }); + + it('does not include trailing newline', () => { + const report = { + hasDrift: true, + fields: [{ field: 'x', emptyCount: 3, totalRows: 3, emptyRate: 1.0 }], + }; + const output = formatDriftWarning(report, 'test/cmd'); + expect(output.endsWith('\n')).toBe(false); + }); +}); diff --git a/src/drift.ts b/src/drift.ts new file mode 100644 index 0000000..f3ded52 --- /dev/null +++ b/src/drift.ts @@ -0,0 +1,71 @@ +/** + * Schema drift detection: checks if command output fields are suspiciously empty, + * indicating upstream API structure changes. + */ + +import chalk from 'chalk'; + +/** A single field's drift measurement */ +export interface DriftField { + field: string; + emptyCount: number; + totalRows: number; + emptyRate: number; +} + +/** Overall drift detection result */ +export interface DriftReport { + hasDrift: boolean; + fields: DriftField[]; +} + +/** + * Detect schema drift by checking empty-value ratio per column. + * "Empty" = null, undefined, or ''. Numeric 0 and false are valid values. + * Non-object rows are skipped. Returns hasDrift: false if valid rows < 3. + * Trigger: emptyRate >= threshold (inclusive). + */ +export function detectDrift( + rows: unknown[], + columns: string[], + threshold: number = 0.8 +): DriftReport { + if (!columns.length) return { hasDrift: false, fields: [] }; + + // Filter to valid object rows + const objectRows = rows.filter( + (r): r is Record => typeof r === 'object' && r !== null && !Array.isArray(r) + ); + if (objectRows.length < 3) return { hasDrift: false, fields: [] }; + + const totalRows = objectRows.length; + const driftFields: DriftField[] = []; + + for (const col of columns) { + let emptyCount = 0; + for (const row of objectRows) { + const v = row[col]; + if (v === null || v === undefined || v === '') emptyCount++; + } + const emptyRate = emptyCount / totalRows; + if (emptyRate >= threshold) { + driftFields.push({ field: col, emptyCount, totalRows, emptyRate }); + } + } + + return { hasDrift: driftFields.length > 0, fields: driftFields }; +} + +/** + * Format drift report as human-readable stderr warning. + * Wrapped in chalk.yellow. No trailing newline (console.error adds it). + */ +export function formatDriftWarning(report: DriftReport, commandName: string): string { + if (!report.fields.length) return ''; + const lines = [chalk.yellow(`\u26a0 Schema drift detected (${commandName}):`)]; + for (const f of report.fields) { + const pct = Math.round(f.emptyRate * 100); + lines.push(chalk.yellow(` \u2022 field '${f.field}' \u2014 empty in ${f.emptyCount}/${f.totalRows} rows (${pct}%)`)); + } + return lines.join('\n'); +} diff --git a/src/main.ts b/src/main.ts index e3d89cf..7682cea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import chalk from 'chalk'; import { discoverClis, executeCommand } from './engine.js'; import { Strategy, type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; import { render as renderOutput } from './output.js'; +import { detectDrift, formatDriftWarning } from './drift.js'; import { PlaywrightMCP } from './browser/index.js'; import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js'; import { PKG_VERSION } from './version.js'; @@ -205,6 +206,13 @@ for (const [, cmd] of registry) { if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) { console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`)); } + // Schema drift: warn if columns are suspiciously empty (non-breaking, stderr only) + if (Array.isArray(result) && result.length > 0 && cmd.columns?.length) { + const report = detectDrift(result, cmd.columns); + if (report.hasDrift) { + console.error(formatDriftWarning(report, fullName(cmd))); + } + } renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) }); } catch (err: any) { if (err instanceof CliError) {