#!/usr/bin/env tsx /** * 阶段0 录证产物机器验收 * 用法: npm run verify:phase0 */ import fs from "node:fs"; import { countAxelQuoteTiers } from "./lib/axel-quote-contract"; import { listHarEntries, pickBestQuoteEntry, readHarFile, scoreQuoteBody, } from "./lib/har-parse"; import { BASELINE_HAR, QUOTE_CONTRACT_SCHEMA, SESSION_STORAGE_SNAPSHOT, } from "./lib/paths"; const QUOTE_CONTRACT_SUMMARY = `${process.cwd()}/.rpa/phase0/quote-contract-summary.json`; type AssertionResult = { id: string; description: string; pass: boolean; detail: string; }; function collectPlaceIds(node: unknown, out: string[]): void { if (node == null) { return; } if (Array.isArray(node)) { for (const item of node) { collectPlaceIds(item, out); } return; } if (typeof node !== "object") { return; } for (const [key, value] of Object.entries(node as Record)) { if (/place[_-]?id/i.test(key) && typeof value === "string" && value.trim()) { out.push(value.trim()); } collectPlaceIds(value, out); } } function countRateLeaves(node: unknown): { pricedLeafCount: number; fourTierOk: boolean; availableRateCount: number; } { return countAxelQuoteTiers(node); } function runAssertions(): AssertionResult[] { const results: AssertionResult[] = []; // A1: baseline.har 存在且含网络条目 const harExists = fs.existsSync(BASELINE_HAR); let harEntryCount = 0; if (harExists) { harEntryCount = listHarEntries(readHarFile(BASELINE_HAR)).length; } results.push({ id: "A1", description: "baseline.har 存在且 entries.length ≥ 10", pass: harExists && harEntryCount >= 10, detail: harExists ? `entries=${harEntryCount}` : `文件不存在: ${BASELINE_HAR}`, }); // A2: HAR 中含可识别的 quote JSON 响应 let quotePicked: ReturnType = null; if (harExists) { quotePicked = pickBestQuoteEntry(listHarEntries(readHarFile(BASELINE_HAR))); } results.push({ id: "A2", description: "HAR 中存在 score>0 的 quote JSON 响应", pass: quotePicked != null, detail: quotePicked ? `${quotePicked.entry.request.method} ${quotePicked.entry.request.url}` : "未匹配 axel/quote 或 rates 结构", }); // A3: axel/quote 四档齐全(data.rates)或 availableRates ≥ 4 const tierStats = quotePicked ? countRateLeaves(quotePicked.body) : { pricedLeafCount: 0, fourTierOk: false, availableRateCount: 0 }; const a3Pass = tierStats.fourTierOk || tierStats.pricedLeafCount >= 4; results.push({ id: "A3", description: "data.rates 四档齐全(std/guar × lowest/fastest)或 availableRates ≥ 4", pass: a3Pass, detail: `fourTier=${tierStats.fourTierOk} data.rates leaves=${tierStats.pricedLeafCount} availableRates=${tierStats.availableRateCount}`, }); if (quotePicked) { fs.writeFileSync( QUOTE_CONTRACT_SUMMARY, `${JSON.stringify( { verifiedAt: new Date().toISOString(), sourceUrl: quotePicked.entry.request.url, ...tierStats, endpoint: "POST https://services.mothership.com/axel/quote", mappingNotes: { standardLowest: "data.rates.standard.lowest", standardFastest: "data.rates.standard.fastest", guaranteedLowest: "data.rates.guaranteed.lowest", guaranteedFastest: "data.rates.guaranteed.fastest", allRates: "data.availableRates", }, }, null, 2, )}\n`, "utf8", ); } // A4: place_id 非空且匹配正则 const PLACE_ID_RE = /^[A-Za-z0-9_-]{8,128}$/; const placeIds: string[] = []; if (quotePicked) { collectPlaceIds(quotePicked.body, placeIds); } const snapshotRaw = fs.existsSync(SESSION_STORAGE_SNAPSHOT) ? fs.readFileSync(SESSION_STORAGE_SNAPSHOT, "utf8") : ""; if (snapshotRaw) { collectPlaceIds(JSON.parse(snapshotRaw) as unknown, placeIds); } const validPlaceIds = [...new Set(placeIds)].filter((id) => PLACE_ID_RE.test(id), ); results.push({ id: "A4", description: "place_id 字段非空且匹配 ^[A-Za-z0-9_-]{8,128}$", pass: validPlaceIds.length >= 1, detail: validPlaceIds.length > 0 ? `matched=${validPlaceIds.slice(0, 3).join(", ")}` : `found=${placeIds.length} raw, none matched regex`, }); // A5: session-storage-snapshot 含 cookies + origins let snapshotOk = false; let snapshotDetail = "文件不存在"; if (fs.existsSync(SESSION_STORAGE_SNAPSHOT)) { const snap = JSON.parse( fs.readFileSync(SESSION_STORAGE_SNAPSHOT, "utf8"), ) as { storageState?: { cookies?: unknown[]; origins?: unknown[] }; checkpoint?: string; }; const cookies = snap.storageState?.cookies?.length ?? 0; const origins = snap.storageState?.origins?.length ?? 0; const pageUrl = typeof (snap as { pageUrl?: string }).pageUrl === "string" ? (snap as { pageUrl: string }).pageUrl : ""; snapshotOk = snap.checkpoint === "address_locked" && snap.storageState != null && pageUrl.includes("mothership"); snapshotDetail = `checkpoint=${snap.checkpoint ?? "?"} cookies=${cookies} origins=${origins} url=${pageUrl || "?"}`; } results.push({ id: "A5", description: "session-storage-snapshot.json 含 storageState(cookies + origins)且 checkpoint=address_locked", pass: snapshotOk, detail: snapshotDetail, }); // A6: quote-contract.schema.json 为有效 JSON Schema 且非 incomplete 占位 let schemaOk = false; let schemaDetail = "文件不存在"; if (fs.existsSync(QUOTE_CONTRACT_SCHEMA)) { const schema = JSON.parse( fs.readFileSync(QUOTE_CONTRACT_SCHEMA, "utf8"), ) as { $schema?: string; body?: unknown; incomplete?: boolean; sourceHar?: { url?: string }; }; schemaOk = Boolean(schema.$schema) && schema.body != null && schema.incomplete !== true && Boolean(schema.sourceHar?.url); schemaDetail = schemaOk ? `source=${schema.sourceHar?.url}` : schema.incomplete ? "incomplete 占位,需重新录证" : "缺少 $schema/body/sourceHar"; } results.push({ id: "A6", description: "quote-contract.schema.json 有效且 sourceHar 指向真实 quote endpoint", pass: schemaOk, detail: schemaDetail, }); // A7: quote body score ≥ 5(含 rates+standard+guaranteed) const quoteScore = quotePicked ? scoreQuoteBody(quotePicked.body) : 0; results.push({ id: "A7", description: "quote 响应结构 score ≥ 5(含 rates/standard/guaranteed 信号)", pass: quoteScore >= 5, detail: `score=${quoteScore}`, }); return results; } function main(): void { const results = runAssertions(); const failed = results.filter((r) => !r.pass); console.log("\n=== 阶段0 录证验收 ===\n"); for (const r of results) { const mark = r.pass ? "PASS" : "FAIL"; console.log(`[${mark}] ${r.id}: ${r.description}`); console.log(` ${r.detail}\n`); } if (failed.length > 0) { console.error(`验收未通过: ${failed.length}/${results.length} 项失败`); console.error("请重新执行 npm run record:phase0 并完成完整人工流程"); process.exit(1); } console.log(`验收通过: ${results.length}/${results.length}`); } main();