返回 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 "models",
67 "defaultModel",
68 "nodeEngines",
69 "toolCount",
70 "license",
71 "latestPublishedRelease",
72 ];
73
74 const diffs = [];
75 for (const field of checkFields) {
76 const a = JSON.stringify(committed[field] ?? null);
77 const b = JSON.stringify(fresh[field] ?? null);
78 if (a !== b) {
79 diffs.push({ field, committed: committed[field], fresh: fresh[field] });
80 }
81 }
82 return diffs;
83 }
84
85 // --- Main -------------------------------------------------------------
86
87 const committed = parseCommittedFacts();
88 if (committed.error) {
89 console.error(`[check-facts] ERROR: ${committed.error}`);
90 process.exit(1);
91 }
92
93 // Provider-inventory drift is a hard failure: a new Rust ApiProvider variant
94 // that is neither mapped to a website label nor intentionally excluded would
95 // otherwise be silently dropped from the public provider list while committed
96 // facts still "match" the (also-incomplete) fresh derivation (#3772).
97 const unmappedProviders = unmappedProviderVariants();
98 if (unmappedProviders.length > 0) {
99 console.error(
100 `[check-facts] FAIL — unmapped ApiProvider variant(s): ${unmappedProviders.join(", ")}.`,
101 );
102 console.error(
103 "Add each to PROVIDER_LABEL_MAP in web/scripts/facts-lib.mjs AND labelMap in " +
104 "web/lib/facts-drift.ts, or to EXCLUDED_PROVIDERS / EXCLUDED if intentionally hidden.",
105 );
106 process.exit(1);
107 }
108
109 const fresh = buildFacts();
110
111 // Quick sanity: critical source facts must never degrade to matching nulls.
112 const criticalGaps = [];
113 if (!fresh.version) criticalGaps.push("version");
114 if (fresh.providers.length === 0) criticalGaps.push("providers");
115 if (!fresh.latestPublishedRelease) criticalGaps.push("latestPublishedRelease");
116 if (criticalGaps.length > 0) {
117 console.error(
118 `[check-facts] FAIL — fresh derivation returned empty/missing: ${criticalGaps.join(", ")}`,
119 );
120 process.exit(1);
121 }
122
123 const diffs = diffFacts(committed.facts, fresh);
124
125 if (diffs.length === 0) {
126 console.log("[check-facts] OK — committed facts.generated.ts matches workspace");
127 process.exit(0);
128 }
129
130 console.error("[check-facts] FAIL — committed facts.generated.ts is stale");
131 for (const d of diffs) {
132 console.error(` ${d.field}:`);
133 console.error(` committed: ${JSON.stringify(d.committed)}`);
134 console.error(` fresh: ${JSON.stringify(d.fresh)}`);
135 }
136
137 console.error(
138 "\nRun `cd web && npm run prebuild` to regenerate facts.generated.ts, then commit the result.",
139 );
140 process.exit(1);
141
141 lines Plain Text