Skip to content

Commit a10741d

Browse files
committed
fix(contract): address crosscheck findings — atomic writes, exit code handling, snapshot validation
1 parent 5f852e0 commit a10741d

3 files changed

Lines changed: 34 additions & 15 deletions

File tree

.github/workflows/contract-check.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ jobs:
5252
retention-days: 90
5353
overwrite: true
5454

55-
# Upload drift report when drift detected
55+
# Upload drift report only when checker detected drift (not on other failures)
5656
- uses: actions/upload-artifact@v7
57-
if: failure()
57+
if: failure() && hashFiles('contract-snapshots/drift-report.json') != ''
5858
with:
5959
name: drift-report
6060
path: contract-snapshots/drift-report.json

tests/contract/checker.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const TARGETS: CheckTarget[] = [
6060
{ site: 'wikipedia', command: 'trending', args: ['--limit', '10'] },
6161
{ site: 'sinafinance', command: 'news', args: ['--limit', '10'] },
6262
{ site: 'weread', command: 'ranking', args: ['--limit', '10'] },
63+
// "不开玩笑 Jokes Aside" podcast by 猫头鹰喜剧
6364
{ site: 'xiaoyuzhou', command: 'podcast', args: ['61791d921989541784257779'] },
6465
{ site: 'yollomi', command: 'models' },
6566
];
@@ -74,17 +75,30 @@ function loadSnapshot(site: string, command: string): CommandSchema | null {
7475
const p = snapshotPath(site, command);
7576
if (!fs.existsSync(p)) return null;
7677
try {
77-
return JSON.parse(fs.readFileSync(p, 'utf8'));
78+
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
79+
// Validate snapshot structure to avoid crashes in diffSchemas
80+
if (!data || typeof data !== 'object' || typeof data.fields !== 'object' || typeof data.rowCount !== 'number') {
81+
console.warn(`Warning: invalid snapshot structure for ${site}/${command}, treating as first run`);
82+
return null;
83+
}
84+
return data as CommandSchema;
7885
} catch (err) {
7986
console.warn(`Warning: corrupt snapshot for ${site}/${command}, treating as first run:`, err);
8087
return null;
8188
}
8289
}
8390

91+
/** 原子写入:先写临时文件再 rename,防止 CI cancel 导致截断 JSON */
92+
function atomicWrite(filePath: string, content: string): void {
93+
const tmp = filePath + '.tmp';
94+
fs.writeFileSync(tmp, content);
95+
fs.renameSync(tmp, filePath);
96+
}
97+
8498
/** 保存命令快照 */
8599
function saveSnapshot(schema: CommandSchema, site: string, command: string): void {
86100
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
87-
fs.writeFileSync(snapshotPath(site, command), JSON.stringify(schema, null, 2) + '\n');
101+
atomicWrite(snapshotPath(site, command), JSON.stringify(schema, null, 2) + '\n');
88102
}
89103

90104
/** 失败元数据目录(与快照分离,跨 drift 事件保留) */
@@ -112,7 +126,7 @@ function saveFailureCount(site: string, command: string, count: number): void {
112126
if (count === 0) {
113127
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
114128
} else {
115-
fs.writeFileSync(metaPath, JSON.stringify({ count }) + '\n');
129+
atomicWrite(metaPath, JSON.stringify({ count }) + '\n');
116130
}
117131
}
118132

@@ -130,7 +144,8 @@ async function runCommand(target: CheckTarget): Promise<{ data: unknown[] | null
130144
return { data: parsed };
131145
} catch (err: any) {
132146
const msg = err.stderr?.trim() || err.message || 'Unknown error';
133-
return { data: null, error: `Exit code ${err.code ?? 1}: ${msg.slice(0, 200)}` };
147+
const exitCode = err.status ?? err.code ?? 'unknown';
148+
return { data: null, error: `Exit code ${exitCode}: ${msg.slice(0, 200)}` };
134149
}
135150
}
136151

@@ -195,13 +210,16 @@ async function main(): Promise<void> {
195210
}
196211
}
197212

213+
// 统一时间戳,避免跨日不一致
214+
const now = new Date();
215+
198216
// 输出人类可读摘要
199-
console.log(formatReport(results));
217+
console.log(formatReport(results, now));
200218

201219
// 写入 JSON 报告(供 CI artifact 上传)
202220
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
203-
const report = buildReport(results);
204-
fs.writeFileSync(
221+
const report = buildReport(results, now);
222+
atomicWrite(
205223
path.join(SNAPSHOT_DIR, 'drift-report.json'),
206224
JSON.stringify(report, null, 2) + '\n',
207225
);

tests/contract/schema.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,18 +164,19 @@ const DEGRADED_THRESHOLD = 7;
164164
* Format contract check results as human-readable console output.
165165
* No ANSI colors — CI logs render plain text fine.
166166
*/
167-
export function formatReport(results: ContractResult[]): string {
167+
export function formatReport(results: ContractResult[], now?: Date): string {
168168
const lines: string[] = [];
169-
const date = new Date().toISOString().slice(0, 10);
169+
const date = (now ?? new Date()).toISOString().slice(0, 10);
170170
lines.push(`Schema Contract Check -- ${date}`);
171171
lines.push('');
172172

173173
for (const r of results) {
174174
if (r.status === 'passed') {
175175
lines.push(` ✓ ${r.command.padEnd(24)} -- no drift`);
176176
} else if (r.status === 'drifted') {
177-
lines.push(` ✗ ${r.command.padEnd(24)} -- ${r.diffs!.length} drift(s) detected`);
178-
for (const d of r.diffs!) {
177+
const diffs = r.diffs ?? [];
178+
lines.push(` ✗ ${r.command.padEnd(24)} -- ${diffs.length} drift(s) detected`);
179+
for (const d of diffs) {
179180
// 各 diff 类型对应前缀符号
180181
const prefix = d.type === 'field_added' ? '+' :
181182
d.type === 'field_removed' ? '-' :
@@ -208,9 +209,9 @@ export function formatReport(results: ContractResult[]): string {
208209
/**
209210
* Build the full JSON drift report from results.
210211
*/
211-
export function buildReport(results: ContractResult[]): DriftReport {
212+
export function buildReport(results: ContractResult[], now?: Date): DriftReport {
212213
return {
213-
timestamp: new Date().toISOString(),
214+
timestamp: (now ?? new Date()).toISOString(),
214215
summary: {
215216
total: results.length,
216217
passed: results.filter(r => r.status === 'passed').length,

0 commit comments

Comments
 (0)