返回 CodeWhale
facts-lib.mjs
根目录 / web / scripts / facts-lib.mjs
1 /**
2 * facts-lib.mjs — shared derivation logic for website fact generation and
3 * drift checking. Imported by both derive-facts.mjs (prebuild) and
4 * check-facts.mjs (CI gate).
5 *
6 * Sources of truth:
7 * - <repo>/Cargo.toml → version, workspace crates
8 * - <repo>/crates/tui/src/sandbox/mod.rs → enforced sandbox markers
9 * - <repo>/crates/tui/src/config.rs → provider list (ApiProvider enum), DEFAULT_TEXT_MODEL
10 * - <repo>/npm/codewhale/package.json → node engines
11 * - <repo>/crates/tui/src/tools/*.rs → tool count (ToolSpec impls)
12 * - <repo>/LICENSE → license
13 * - <repo>/web/data/latest-published-release.json → latest published release
14 */
15 import { readFileSync, readdirSync, existsSync } from "node:fs";
16 import { join, dirname, resolve } from "node:path";
17 import { fileURLToPath } from "node:url";
18
19 const __dirname = dirname(fileURLToPath(import.meta.url));
20 // __dirname is web/scripts; REPO_ROOT is the workspace root (two levels up).
21 export const REPO_ROOT = resolve(__dirname, "..", "..");
22
23 function read(rel) {
24 const p = join(REPO_ROOT, rel);
25 if (!existsSync(p)) return null;
26 return readFileSync(p, "utf-8");
27 }
28
29 export function deriveVersion() {
30 const cargo = read("Cargo.toml");
31 if (!cargo) return null;
32 const m = cargo.match(/^version\s*=\s*"([^"]+)"/m);
33 return m ? m[1] : null;
34 }
35
36 export function deriveCrates() {
37 const cargo = read("Cargo.toml");
38 if (!cargo) return [];
39 const block = cargo.match(/members\s*=\s*\[([\s\S]*?)\]/);
40 if (!block) return [];
41 return [...block[1].matchAll(/"crates\/([^"]+)"/g)].map((m) => m[1]).sort();
42 }
43
44 export function deriveSandboxBackends() {
45 const source = read("crates/tui/src/sandbox/mod.rs");
46 return source ? deriveSandboxBackendsFromSource(source) : [];
47 }
48
49 export function deriveSandboxBackendsFromSource(source) {
50 const marker = source.match(
51 /pub const PUBLIC_SANDBOX_BACKENDS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/,
52 );
53 if (!marker) return [];
54 return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
55 }
56
57 /**
58 * Provider label map — the single source of truth for provider → website
59 * display mapping. MUST be kept in sync with the copy in
60 * web/lib/facts-drift.ts (for the runtime Cloudflare cron path).
61 *
62 * Excluded variants: DeepseekCN (not wired through shared ProviderKind, #1104).
63 */
64 const PROVIDER_LABEL_MAP = {
65 Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" },
66 DeepseekAnthropic: { id: "deepseek-anthropic", label: "DeepSeek Anthropic", env: "DEEPSEEK_API_KEY / ANTHROPIC_API_KEY" },
67 NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" },
68 Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" },
69 Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" },
70 WanjieArk: { id: "wanjie-ark", label: "Wanjie Ark", env: "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" },
71 Volcengine: { id: "volcengine", label: "Volcengine Ark", env: "VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY" },
72 Openrouter: { id: "openrouter", label: "OpenRouter", env: "OPENROUTER_API_KEY" },
73 XiaomiMimo: { id: "xiaomi-mimo", label: "Xiaomi MiMo", env: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY / MIMO_TOKEN_PLAN_API_KEY / XIAOMI_MIMO_API_KEY / XIAOMI_API_KEY / MIMO_API_KEY" },
74 Novita: { id: "novita", label: "Novita AI", env: "NOVITA_API_KEY" },
75 Fireworks: { id: "fireworks", label: "Fireworks AI", env: "FIREWORKS_API_KEY" },
76 Siliconflow: { id: "siliconflow", label: "SiliconFlow", env: "SILICONFLOW_API_KEY" },
77 SiliconflowCn: { id: "siliconflow-CN", label: "SiliconFlow CN", env: "SILICONFLOW_API_KEY" },
78 Arcee: { id: "arcee", label: "Arcee AI", env: "ARCEE_API_KEY" },
79 Moonshot: { id: "moonshot", label: "Moonshot/Kimi", env: "MOONSHOT_API_KEY / KIMI_API_KEY" },
80 Sglang: { id: "sglang", label: "SGLang", env: "SGLANG_API_KEY" },
81 Vllm: { id: "vllm", label: "vLLM", env: "VLLM_API_KEY" },
82 Ollama: { id: "ollama", label: "Ollama", env: "OLLAMA_API_KEY" },
83 Huggingface: { id: "huggingface", label: "Hugging Face", env: "HUGGINGFACE_API_KEY / HF_TOKEN" },
84 Deepinfra: { id: "deepinfra", label: "DeepInfra", env: "DEEPINFRA_API_KEY / DEEPINFRA_TOKEN" },
85 Together: { id: "together", label: "Together AI", env: "TOGETHER_API_KEY" },
86 Qianfan: { id: "qianfan", label: "Baidu Qianfan", env: "QIANFAN_API_KEY / BAIDU_QIANFAN_API_KEY" },
87 OpenaiCodex: { id: "openai-codex", label: "OpenAI Codex", env: "ChatGPT/Codex OAuth via `codex login` (OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN override)" },
88 OpencodeGo: { id: "opencode-go", label: "OpenCode Go", env: "OPENCODE_GO_API_KEY" },
89 OpencodeZen: { id: "opencode-zen", label: "OpenCode Zen", env: "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" },
90 Anthropic: { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" },
91 Zai: { id: "zai", label: "Z.ai", env: "ZAI_API_KEY / Z_AI_API_KEY" },
92 Stepfun: { id: "stepfun", label: "StepFun", env: "STEPFUN_API_KEY / STEP_API_KEY" },
93 Minimax: { id: "minimax", label: "MiniMax", env: "MINIMAX_API_KEY" },
94 MinimaxAnthropic: { id: "minimax-anthropic", label: "MiniMax (Anthropic-compatible)", env: "MINIMAX_API_KEY" },
95 Openmodel: { id: "openmodel", label: "OpenModel", env: "OPENMODEL_API_KEY" },
96 Sakana: { id: "sakana", label: "Sakana AI", env: "FUGU_API_KEY / SAKANA_API_KEY" },
97 LongCat: { id: "longcat", label: "Meituan LongCat", env: "LONGCAT_API_KEY" },
98 Meta: { id: "meta", label: "Meta Model API", env: "META_MODEL_API_KEY / MODEL_API_KEY" },
99 Telecomjs: { id: "telecomjs", label: "TelecomJS TokenHub", env: "TELECOMJS_API_KEY" },
100 Xai: { id: "xai", label: "xAI", env: "XAI_API_KEY" },
101 ModelstudioTokenPlan: { id: "modelstudio-token-plan", label: "Model Studio Token Plan", env: "MODELSTUDIO_API_KEY" },
102 ModelstudioTokenPlanAnthropic: { id: "modelstudio-token-plan-anthropic", label: "Model Studio Token Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" },
103 ModelstudioCodingPlan: { id: "modelstudio-coding-plan", label: "Model Studio Coding Plan", env: "MODELSTUDIO_API_KEY" },
104 ModelstudioCodingPlanAnthropic: { id: "modelstudio-coding-plan-anthropic", label: "Model Studio Coding Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" },
105 };
106
107 // DeepseekCN: not wired through shared ProviderKind (#1104).
108 // Custom: the dynamic OpenAI-compatible meta-provider (#1519) — a runtime
109 // catch-all for user-defined endpoints, not a website-listable provider.
110 const EXCLUDED_PROVIDERS = new Set(["DeepseekCN", "Custom"]);
111
112 function providerEnumVariants() {
113 const cfg = read("crates/tui/src/config.rs");
114 if (!cfg) return [];
115 const enumBlock = cfg.match(/pub enum ApiProvider \{([\s\S]*?)\}/);
116 if (!enumBlock) return [];
117 return [...enumBlock[1].matchAll(/^\s*(\w+)\s*,\s*$/gm)].map((m) => m[1]);
118 }
119
120 /**
121 * ApiProvider variants that are neither mapped to a website label nor
122 * intentionally excluded. Exposed so the CI gate (`check-facts.mjs`) can
123 * hard-fail on provider-inventory drift (#3772); the generator stays lenient
124 * and merely warns so local `prebuild` is not blocked mid-development.
125 */
126 export function unmappedProviderVariants() {
127 return providerEnumVariants().filter(
128 (v) => !EXCLUDED_PROVIDERS.has(v) && !PROVIDER_LABEL_MAP[v],
129 );
130 }
131
132 export function deriveProviders() {
133 const variants = providerEnumVariants();
134
135 const unmapped = unmappedProviderVariants();
136 if (unmapped.length > 0) {
137 console.error(
138 `[facts-lib] ApiProvider variants missing from PROVIDER_LABEL_MAP: ${unmapped.join(", ")}. ` +
139 "Add them to PROVIDER_LABEL_MAP here AND in web/lib/facts-drift.ts (or to EXCLUDED_PROVIDERS if intentionally hidden).",
140 );
141 // The generator stays lenient and returns what it can map; the hard gate
142 // lives in check-facts.mjs via unmappedProviderVariants() (#3772).
143 }
144 return variants.map((v) => PROVIDER_LABEL_MAP[v]).filter(Boolean);
145 }
146
147 export function deriveDefaultModel() {
148 // DEFAULT_TEXT_MODEL's definition moved to config/models.rs in the #3311 split;
149 // read both and match the const *definition* specifically (`= "..."`) so we
150 // don't mis-bind to a later string at a mere use site.
151 const cfg =
152 (read("crates/tui/src/config/models.rs") ?? "") +
153 "\n" +
154 (read("crates/tui/src/config.rs") ?? "");
155 if (!cfg.trim()) return null;
156 const m = cfg.match(/DEFAULT_TEXT_MODEL\s*(?::\s*&str\s*)?=\s*"([^"]+)"/);
157 return m ? m[1] : null;
158 }
159
160 export function deriveNodeEngines() {
161 const pkg = read("npm/codewhale/package.json");
162 if (!pkg) return null;
163 try {
164 return JSON.parse(pkg).engines?.node ?? null;
165 } catch {
166 return null;
167 }
168 }
169
170 export function deriveToolCount() {
171 const dir = join(REPO_ROOT, "crates/tui/src/tools");
172 if (!existsSync(dir)) return null;
173 let count = 0;
174 for (const f of readdirSync(dir)) {
175 if (!f.endsWith(".rs")) continue;
176 const body = readFileSync(join(dir, f), "utf-8");
177 count += (body.match(/^impl ToolSpec for /gm) ?? []).length;
178 }
179 return count > 0 ? count : null;
180 }
181
182 export function deriveLicense() {
183 const lic = read("LICENSE");
184 if (!lic) return null;
185 const first = lic.split(/\r?\n/).find((l) => l.trim().length > 0);
186 if (!first) return null;
187 if (/^MIT License/i.test(first)) return "MIT";
188 if (/Apache.*2\.0/i.test(first)) return "Apache-2.0";
189 return first.trim();
190 }
191
192 export function deriveLatestPublishedRelease() {
193 const raw = read("web/data/latest-published-release.json");
194 if (!raw) return null;
195 try {
196 const release = JSON.parse(raw);
197 if (
198 typeof release.tag !== "string" ||
199 typeof release.version !== "string" ||
200 release.tag !== `v${release.version}` ||
201 typeof release.publishedAt !== "string" ||
202 !Number.isFinite(Date.parse(release.publishedAt)) ||
203 typeof release.url !== "string" ||
204 release.url !== `https://github.com/Hmbown/CodeWhale/releases/tag/${release.tag}`
205 ) {
206 return null;
207 }
208 return release;
209 } catch {
210 return null;
211 }
212 }
213
214 /**
215 * Re-derive all mechanical facts from the current workspace. The returned
216 * object is the same shape as web/lib/facts.generated.ts → RepoFacts.
217 */
218 export function buildFacts() {
219 const providers = deriveProviders();
220 // In check mode, missing provider mappings are a warning, not a crash.
221 // But if we truly have zero mapped providers, that signals something
222 // went wrong (e.g. config.rs renamed) — still return an empty array
223 // rather than null so the checker can report it.
224
225 const facts = {
226 generatedAt: new Date().toISOString(),
227 // next.config.ts injects these from the exact checkout into the built
228 // artifact. They stay null in the tracked snapshot to avoid a
229 // self-referential generated-file diff after every commit.
230 sourceRevision: null,
231 sourceCommittedAt: null,
232 version: deriveVersion(),
233 crates: deriveCrates(),
234 sandboxBackends: deriveSandboxBackends(),
235 providers,
236 defaultModel: deriveDefaultModel(),
237 nodeEngines: deriveNodeEngines(),
238 toolCount: deriveToolCount(),
239 license: deriveLicense(),
240 latestPublishedRelease: deriveLatestPublishedRelease(),
241 };
242
243 return facts;
244 }
245
245 lines Plain Text