| 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 |