Files
orca/config/scripts/run-lint.mjs
T
OrcaWinandOrcaWin 12a7d4c13a feat(mobile): localization infrastructure and language setting (#35399)
* feat(mobile): add UI localization infrastructure

Mobile follows the phone's language (or an in-app choice under Settings > Appearance)
across the same 11 languages as desktop, switching live like the theme does.

- i18next with desktop's translate(key, fallback, options) call shape; catalogs in
  mobile/src/i18n/locales load lazily, English fallbacks stay inline
- Hermes lacks Intl.PluralRules: load the FormatJS polyfill with the 11 languages' rules
- OTA pages get the shell's language through an optional bridge init field
- iOS permission prompts localized; iOS and Android 13+ per-app language pickers
- verify:mobile-localization and a coverage ratchet run in lint, check:changed and CI

* fix(mobile): mount pages after the newest language request; tighten localization gates

- Wait for the latest page language activation, not a superseded one
- Keep literal fallbacks when a key also has a dynamic one
- Count headerTitle as visible copy; select the mobile check when the runtime catalog generator changes

* feat(mobile): repaint themed components on language changes

Components that read the theme already subscribe for repaint; a language switch
repaints them too, so labels built by helpers during render follow it. RPC
recordings load the real i18next and JSON catalogs.

* fix(mobile): scope the translate() gate to render bodies and memos; prune stale keys on sync

Handlers and effects may call translate() when they run. sync:mobile-localization now
removes keys no code references from every catalog.

* fix(mobile): use node: imports in the locale config plugin; pass uiLocale in bridge hook test

* chore(mobile): refresh the localization coverage baseline

* fix(mobile): follow render callbacks in the translate() gate; keep used plural siblings on prune

Also let anonymous default-exported screens count as components, flag any plural
family a locale started, and mark JSON modules as ES modules in the recording loader.

* fix(mobile): treat async and named local callbacks as handlers in the translate() gate

* chore(mobile): refresh the localization coverage baseline

* chore(mobile): refresh the localization coverage baseline

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 17:14:05 -07:00

137 lines
4.7 KiB
JavaScript

// `pnpm lint`: runs the independent lint steps in a bounded pool, printing output only for failures.
import { runProcess } from '@orca/process-host'
import { readFileSync } from 'node:fs'
import { availableParallelism } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
// package.json script names, slowest first so short steps fill in around them.
export const LINT_STEPS = [
'check:import-boundaries',
'verify:localization-extraction',
'audit:anti-slop',
'audit:code-quality:type-aware',
'check:test-file-runners',
'verify:localization-catalogs',
'verify:localization-coverage',
'verify:mobile-localization',
'verify:mobile-localization-coverage',
'lint:ast-grep-gates',
'check:main-sync-spawn-ratchet',
'lint:js-plugins',
'oxlint',
'check:dead-classes',
'check:layout-writer-ratchets',
'check:e2e-typecheck-ratchet',
'check:focus-read-ratchet',
'audit:code-quality:native',
'check:legacy-relay-fence-ratchet',
'check:runtime-electron-ratchet',
'verify:rpc-params-catalog',
'check:repo-host-read-ratchet',
'test:ast-grep',
'check:max-lines-ratchet',
'check:mobile-shared-dependency-versions',
'check:ts-nocheck-ratchet',
'check:workspace-record-read-ratchet',
'check:unproven-pty-retirement-ratchet',
'check:daemon-blocking-call-ratchet',
'check:node-runtime-pin',
'check:path-literal-ratchet',
'check:host-list-render-ratchet',
'verify:skill-bundle-manifest',
'check:reliability-gates',
'verify:bundled-skill-guides',
'check:readme-local-links',
'check:id-only-store-write-ratchet',
'verify:acp-protocol'
]
// Steps with no package script of their own run as the bare command.
function resolveLintCommands(scripts, root) {
const pnpm = resolvePnpmCliInvocation()
return LINT_STEPS.map((name) => {
if (name === 'oxlint') {
const { command, prefixArgs } = resolveOxlintInvocation(root)
return { name, program: command, args: prefixArgs }
}
if (!scripts[name]) {
throw new Error(`Lint step ${name} has no package.json script.`)
}
return { name, program: pnpm.command, args: [...pnpm.prefixArgs, 'run', name] }
})
}
async function runStep({ name, program, args }, root) {
const startedAt = performance.now()
try {
const result = await runProcess({ program, args, cwd: root, timeoutMs: null }, 'tail')
return {
name,
ok: result.code === 0 && !result.signal && !result.timedOut,
output: result.stdout + result.stderr,
elapsed: `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
}
} catch (error) {
return { name, ok: false, output: error.message ?? String(error), elapsed: 'failed to start' }
}
}
async function main(root, argv) {
const startedAt = performance.now()
const { scripts } = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
// Individual linters also use multiple cores.
let concurrency = Math.max(2, Math.min(4, Math.floor(availableParallelism() / 4)))
let timings = false
for (const argument of argv) {
if (argument === '--') {
continue
}
if (argument === '--timings') {
timings = true
} else if (/^--max-?workers=[1-9]\d*$/i.test(argument)) {
concurrency = Number(argument.split('=')[1])
} else {
throw new Error(`Unknown option ${argument}. Use --maxWorkers=<n> or --timings.`)
}
}
const steps = resolveLintCommands(scripts, root)
const results = []
let next = 0
await Promise.all(
Array.from({ length: Math.min(concurrency, steps.length) }, async () => {
while (next < steps.length) {
const result = await runStep(steps[next++], root)
if (!result.ok) {
console.error(`\n✗ ${result.name} (${result.elapsed})\n${result.output.trimEnd()}`)
} else if (timings) {
console.log(`✓ ${result.name} (${result.elapsed})`)
}
results.push(result)
}
})
)
const failed = results.filter((result) => !result.ok)
const elapsed = `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
if (failed.length > 0) {
console.error(
`\n✗ ${failed.length} of ${results.length} lint step(s) failed in ${elapsed}: ${failed.map((result) => result.name).join(', ')}`
)
return 1
}
console.log(`✓ ${results.length} lint step(s) passed in ${elapsed}.`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
process.exitCode = await main(path.resolve(import.meta.dirname, '../..'), process.argv.slice(2))
} catch (error) {
console.error(`lint: ${error.message ?? error}`)
process.exitCode = 1
}
}