返回 CodeWhale
check-facts.mjs
根目录 / web / scripts / check-facts.mjs
1 #!/usr/bin/env node
2 /**
3 * check-facts.mjs — CI drift gate for website facts.
4 *
5 * Re-derives mechanical facts from the current workspace (using the same
6 * logic as derive-facts.mjs / facts-lib.mjs) and compares them against the
7 * committed web/lib/facts.generated.ts. Exits non-zero when the committed
8 * file is stale so the mismatch is caught before deploy.
9 *
10 * Usage:
11 * cd web && npm run check:facts
12 *
13 * Checked fields:
14 * version, providers, crates, sandboxBackends, defaultModel, nodeEngines,
15 * toolCount, license, latestPublishedRelease.
16 *
17 * Fields NOT checked (by design):
18 * generatedAt — always different
19 * sourceRevision/sourceCommittedAt — injected from the exact build checkout
20 */
21 import { readFileSync, existsSync } from "node:fs";
22 import { resolve, dirname } from "node:path";
23 import { fileURLToPath } from "node:url";
24 import { buildFacts, unmappedProviderVariants } from "./facts-lib.mjs";
25
26 const __dirname = dirname(fileURLToPath(import.meta.url));
27 const GENERATED_PATH = resolve(__dirname, "..", "lib", "facts.generated.ts");
28
29 // --- Helpers ---------------------------------------------------------
30
31 /**
32 * Parse the committed `facts.generated.ts` into a plain object.
33 * We don't `import()` the TS file (which would need ts-node); instead we
34 * extract the JSON object literal from the export declaration.
35 */
36 function parseCommittedFacts() {
37 if (!existsSync(GENERATED_PATH)) {
38 return { error: `not found: ${GENERATED_PATH}` };
39 }
40 const src = readFileSync(GENERATED_PATH, "utf-8");
41
42 // Extract the object literal between "export const FACTS: RepoFacts = " and
43 // the closing ";" (possibly preceded by "as const").
44 const m = src.match(/export const FACTS\s*:\s*\w+\s*=\s*([\s\S]*?);?\s*$/);
45 if (!m) {
46 return { error: `could not parse FACTS export from ${GENERATED_PATH}` };
47 }
48 try {
49 const obj = JSON.parse(m[1]);
50 return { facts: obj };
51 } catch (e) {
52 return { error: `invalid JSON in ${GENERATED_PATH}: ${e.message}` };
53 }
54 }
55
56 /**
57 * Compare two facts objects and return a list of field-level diffs.
58 */
59 function diffFacts(committed, fresh) {
60 // Fields checked for drift. Skip generatedAt and exact-build provenance.
61 const checkFields = [
62 "version",
63 "crates",
64 "sandboxBackends",
65 "providers",
66 "defaultModel",
67 "nodeEngines",
68 "toolCount",
69 "license",
70 "latestPublishedRelease",
71 ];
72
73 const diffs = [];
74 for (const field of checkFields) {
75 const a = JSON.stringify(committed[field] ?? null);
76 const b = JSON.stringify(fresh[field] ?? null);
77 if (a !== b) {
78 diffs.push({ field, committed: committed[field], fresh: fresh[field] });
79 }
80 }
81 return diffs;
82 }
83
84 // --- Main -------------------------------------------------------------
85
86 const committed = parseCommittedFacts();
87 if (committed.error) {
88 console.error(`[check-facts] ERROR: ${committed.error}`);
89 process.exit(1);
90 }
91
92 // Provider-inventory drift is a hard failure: a new Rust ApiProvider variant
93 // that is neither mapped to a website label nor intentionally excluded would
94 // otherwise be silently dropped from the public provider list while committed
95 // facts still "match" the (also-incomplete) fresh derivation (#3772).
96 const unmappedProviders = unmappedProviderVariants();
97 if (unmappedProviders.length > 0) {
98 console.error(
99 `[check-facts] FAIL — unmapped ApiProvider variant(s): ${unmappedProviders.join(", ")}.`,
100 );
101 console.error(
102 "Add each to PROVIDER_LABEL_MAP in web/scripts/facts-lib.mjs AND labelMap in " +
103 "web/lib/facts-drift.ts, or to EXCLUDED_PROVIDERS / EXCLUDED if intentionally hidden.",
104 );
105 process.exit(1);
106 }
107
108 const fresh = buildFacts();
109
110 // Quick sanity: critical source facts must never degrade to matching nulls.
111 const criticalGaps = [];
112 if (!fresh.version) criticalGaps.push("version");
113 if (fresh.providers.length === 0) criticalGaps.push("providers");
114 if (!fresh.latestPublishedRelease) criticalGaps.push("latestPublishedRelease");
115 if (criticalGaps.length > 0) {
116 console.error(
117 `[check-facts] FAIL — fresh derivation returned empty/missing: ${criticalGaps.join(", ")}`,
118 );
119 process.exit(1);
120 }
121
122 const diffs = diffFacts(committed.facts, fresh);
123
124 if (diffs.length === 0) {
125 console.log("[check-facts] OK — committed facts.generated.ts matches workspace");
126 process.exit(0);
127 }
128
129 console.error("[check-facts] FAIL — committed facts.generated.ts is stale");
130 for (const d of diffs) {
131 console.error(` ${d.field}:`);
132 console.error(` committed: ${JSON.stringify(d.committed)}`);
133 console.error(` fresh: ${JSON.stringify(d.fresh)}`);
134 }
135
136 console.error(
137 "\nRun `cd web && npm run prebuild` to regenerate facts.generated.ts, then commit the result.",
138 );
139 process.exit(1);
140
140 lines Plain Text