mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* perf(typecheck): pnpm tc uses the pinned Bun checker when available bun check (Bun 1.4.3, TypeScript 7.0.2 parity, already used by CI) checks the node project in ~29 CPU-s / ~2 GB peak vs tsc's ~98 CPU-s / ~16.6 GB. tsc's incremental cache rarely helps agents: noEmit builds store no declaration signatures, so the first edit to any file after a cold check re-checks every transitive importer (a comment in src/shared/constants.ts re-checks all 18k files). pnpm tc / tc:node / tc:web / tc:cli now pick the Bun from config/.bun-version when it is on PATH and fall back to tsc with a one-line hint otherwise; --compiler=native forces tsc. The pinned-Bun probe is shared with the localization extraction check. * build: pin Bun as a devDependency and make it the only default checker The PATH probe left anyone without the exact pinned Bun (including a 1.4.2 global) on tsc's expensive path. bun@1.4.3 is now a devDependency, its postinstall is allowed, and pnpm tc / typecheck:ci run that binary. A missing binary is an error pointing at pnpm install rather than a silent fallback; --compiler=native still selects tsc. A test keeps the devDependency equal to config/.bun-version. * ci: link the pinned Bun after the script-free install --ignore-scripts leaves node_modules/.bin/bun as bun's postinstall stub, so the typecheck runner found no binary and any pnpm-run script calling bun would hit the stub before setup-bun's copy. pnpm rebuild bun runs only that allowed build.
136 lines
4.6 KiB
JavaScript
136 lines
4.6 KiB
JavaScript
import fs from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
|
|
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
|
|
|
function flattenCatalog(value, prefix = '', entries = new Map()) {
|
|
if (typeof value === 'string') {
|
|
entries.set(prefix, value)
|
|
return entries
|
|
}
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return entries
|
|
}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function placeholders(value) {
|
|
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
|
|
}
|
|
|
|
export function compareExtraction(extractedCatalog, englishCatalog) {
|
|
const extracted = flattenCatalog(extractedCatalog)
|
|
const english = flattenCatalog(englishCatalog)
|
|
const dynamicDefaults = [...extracted.entries()]
|
|
.filter(([, value]) => value.length === 0)
|
|
.map(([key]) => key)
|
|
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
|
|
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
|
|
const fallbackDrift = []
|
|
const placeholderMismatches = []
|
|
|
|
for (const [key, extractedValue] of extracted) {
|
|
const englishValue = english.get(key)
|
|
if (
|
|
extractedValue.length === 0 ||
|
|
englishValue === undefined ||
|
|
englishValue === extractedValue
|
|
) {
|
|
continue
|
|
}
|
|
fallbackDrift.push(key)
|
|
if (placeholders(extractedValue) !== placeholders(englishValue)) {
|
|
placeholderMismatches.push(key)
|
|
}
|
|
}
|
|
|
|
return {
|
|
extracted,
|
|
dynamicDefaults,
|
|
missingFromEnglish,
|
|
orphans,
|
|
fallbackDrift,
|
|
placeholderMismatches
|
|
}
|
|
}
|
|
|
|
function printKeys(label, keys) {
|
|
if (keys.length === 0) {
|
|
return
|
|
}
|
|
console.error(`${label}:`)
|
|
for (const key of keys.slice(0, 20)) {
|
|
console.error(` ${key}`)
|
|
}
|
|
if (keys.length > 20) {
|
|
console.error(` ...and ${keys.length - 20} more`)
|
|
}
|
|
}
|
|
|
|
async function extractToTemporaryCatalog(root, tempDir) {
|
|
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
|
|
const outputPattern = path.join(tempDir, '{{language}}.json')
|
|
// Why: extraction output is evidence for this check, not another committed
|
|
// catalog that feature authors must keep synchronized.
|
|
const [{ runProcessSync }, { describeProcessFailure }, { resolvePinnedBunExecutable }] =
|
|
await Promise.all([
|
|
import('@orca/process-host'),
|
|
import('./process-failure-message.mjs'),
|
|
import('./pinned-bun-executable.mjs')
|
|
])
|
|
// Missing or unusable optional Bun keeps the existing parent runtime.
|
|
const program = resolvePinnedBunExecutable(root, runProcessSync) ?? process.execPath
|
|
const result = runProcessSync({
|
|
program,
|
|
args: [cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'],
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
|
},
|
|
timeoutMs: null,
|
|
maxOutputBytes: 1024 * 1024
|
|
})
|
|
if (result.code !== 0 || result.signal || result.timedOut || result.outputTruncated) {
|
|
throw new Error(`Localization extraction failed: ${describeProcessFailure(result)}`)
|
|
}
|
|
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
|
|
}
|
|
|
|
export async function main(root = process.cwd()) {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
|
|
|
|
try {
|
|
const [extractedCatalog, englishCatalog] = await Promise.all([
|
|
extractToTemporaryCatalog(root, tempDir),
|
|
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
|
|
])
|
|
const result = compareExtraction(extractedCatalog, englishCatalog)
|
|
|
|
console.log(
|
|
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ.`
|
|
)
|
|
|
|
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
|
|
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
|
|
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
|
|
return 1
|
|
}
|
|
|
|
return 0
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main())
|
|
}
|