Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/drift.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
71 changes: 71 additions & 0 deletions src/drift.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => 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');
}
8 changes: 8 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Loading