返回 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/config/assets/provider_descriptors.json → provider labels and default 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 { parseModelCatalog } from "../lib/model-catalog.mjs";
16 import { parseProviderDescriptors } from "../lib/provider-descriptors.mjs";
17 import { readFileSync, readdirSync, existsSync } from "node:fs";
18 import { spawnSync } from "node:child_process";
19 import { join, dirname, resolve } from "node:path";
20 import { fileURLToPath } from "node:url";
21
22 const __dirname = dirname(fileURLToPath(import.meta.url));
23 // __dirname is web/scripts; REPO_ROOT is the workspace root (two levels up).
24 export const REPO_ROOT = resolve(__dirname, "..", "..");
25
26 function read(rel) {
27 const p = join(REPO_ROOT, rel);
28 if (!existsSync(p)) return null;
29 return readFileSync(p, "utf-8");
30 }
31
32 export function deriveVersion() {
33 const cargo = read("Cargo.toml");
34 if (!cargo) return null;
35 const m = cargo.match(/^version\s*=\s*"([^"]+)"/m);
36 return m ? m[1] : null;
37 }
38
39 export function deriveCrates() {
40 const cargo = read("Cargo.toml");
41 if (!cargo) return [];
42 const block = cargo.match(/members\s*=\s*\[([\s\S]*?)\]/);
43 if (!block) return [];
44 return [...block[1].matchAll(/"crates\/([^"]+)"/g)].map((m) => m[1]).sort();
45 }
46
47 export function deriveSandboxBackends() {
48 const source = read("crates/tui/src/sandbox/mod.rs");
49 return source ? deriveSandboxBackendsFromSource(source) : [];
50 }
51
52 export function deriveSandboxBackendsFromSource(source) {
53 const marker = source.match(
54 /pub const PUBLIC_SANDBOX_BACKENDS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/,
55 );
56 if (!marker) return [];
57 return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
58 }
59
60 // Descriptor data owns public identities, order, labels and defaults.
61 const descriptorData = parseProviderDescriptors(read("crates/config/assets/provider_descriptors.json"));
62 export function deriveProviders() {
63 if (!descriptorData) throw new Error("missing or malformed provider descriptors");
64 return descriptorData.providers;
65 }
66
67 export function deriveDefaultModel() {
68 return descriptorData?.defaultModel ?? null;
69 }
70
71 export function deriveNodeEngines() {
72 const pkg = read("npm/codewhale/package.json");
73 if (!pkg) return null;
74 try {
75 return JSON.parse(pkg).engines?.node ?? null;
76 } catch {
77 return null;
78 }
79 }
80
81 // Public model identities, labels and source-support dates share the reviewed
82 // Engine catalog owner. A malformed source cannot become a partial/empty list.
83 export function deriveModels() {
84 const models = parseModelCatalog(read("crates/config/assets/models_dev.bundled.json"));
85 if (!models) throw new Error("missing or malformed reviewed model catalog");
86 return models;
87 }
88
89 export function deriveToolCount() {
90 const dir = join(REPO_ROOT, "crates/tui/src/tools");
91 if (!existsSync(dir)) return null;
92 let count = 0;
93 for (const f of readdirSync(dir)) {
94 if (!f.endsWith(".rs")) continue;
95 const body = readFileSync(join(dir, f), "utf-8");
96 count += (body.match(/^impl ToolSpec for /gm) ?? []).length;
97 }
98 return count > 0 ? count : null;
99 }
100
101 export function deriveLicense() {
102 const lic = read("LICENSE");
103 if (!lic) return null;
104 const first = lic.split(/\r?\n/).find((l) => l.trim().length > 0);
105 if (!first) return null;
106 if (/^MIT License/i.test(first)) return "MIT";
107 if (/Apache.*2\.0/i.test(first)) return "Apache-2.0";
108 return first.trim();
109 }
110
111 export function deriveLatestPublishedRelease() {
112 const raw = read("web/data/latest-published-release.json");
113 if (!raw) return null;
114 try {
115 const release = JSON.parse(raw);
116 if (
117 typeof release.tag !== "string" ||
118 typeof release.version !== "string" ||
119 release.tag !== `v${release.version}` ||
120 typeof release.publishedAt !== "string" ||
121 !Number.isFinite(Date.parse(release.publishedAt)) ||
122 typeof release.url !== "string" ||
123 release.url !== `https://github.com/codewhale-hq/CodeWhale/releases/tag/${release.tag}`
124 ) {
125 return null;
126 }
127 return release;
128 } catch {
129 return null;
130 }
131 }
132
133 /**
134 * Re-derive all mechanical facts from the current workspace. The returned
135 * object is the same shape as web/lib/facts.generated.ts → RepoFacts.
136 */
137 export function buildFacts() {
138 const providers = deriveProviders();
139
140 const facts = {
141 generatedAt: new Date().toISOString(),
142 // next.config.ts injects these from the exact checkout into the built
143 // artifact. They stay null in the tracked snapshot to avoid a
144 // self-referential generated-file diff after every commit.
145 sourceRevision: null,
146 sourceCommittedAt: null,
147 version: deriveVersion(),
148 crates: deriveCrates(),
149 sandboxBackends: deriveSandboxBackends(),
150 providers,
151 models: deriveModels(),
152 defaultModel: deriveDefaultModel(),
153 nodeEngines: deriveNodeEngines(),
154 toolCount: deriveToolCount(),
155 license: deriveLicense(),
156 latestPublishedRelease: deriveLatestPublishedRelease(),
157 };
158
159 return facts;
160 }
161
161 lines Plain Text