Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,8 @@ jobs:

- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
native-runtime: none
node-version: ${{ matrix.node }}
cache-electron-package: 'true'
cache-dependency-path: |
pnpm-lock.yaml
cloud/pnpm-lock.yaml
Expand Down
17 changes: 12 additions & 5 deletions config/scripts/audit-localization-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isTestOnlySourcePath } from './test-only-source-path.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
const CLASS_PROPERTY_NAMES = new Set(['className', 'classNames'])
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
'ariaLabel',
'aria-label',
Expand Down Expand Up @@ -314,7 +315,7 @@ function isUserVisibleCallArgument(node) {
}

function classifyStringNode(node) {
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
if (hasAncestorObjectPropertyName(node, CLASS_PROPERTY_NAMES)) {
return undefined
}

Expand Down Expand Up @@ -424,10 +425,16 @@ export function collectLocalizationCandidates(filePath, sourceText, root = proce
return
}

const kind = classifyStringNode(node)
if (kind) {
for (const part of stringParts(node)) {
pushReport(node, kind, part.text, part.dynamic)
if (
ts.isStringLiteralLike(node) ||
ts.isNoSubstitutionTemplateLiteral(node) ||
ts.isTemplateExpression(node)
) {
const kind = classifyStringNode(node)
if (kind) {
for (const part of stringParts(node)) {
pushReport(node, kind, part.text, part.dynamic)
}
}
}

Expand Down
46 changes: 46 additions & 0 deletions config/scripts/audit-localization-coverage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,52 @@ describe('localization coverage candidates', () => {

expect(reports).toEqual([])
})

it('sees static templates and every dynamic template part in visible calls', () => {
const reports = candidates(
'Sample.ts',
'confirm(`Retry the sync`); alert(`Reconnect ${host} after ${attempts} attempts`);'
)

expect(reports.map(({ text, dynamic }) => ({ text, dynamic }))).toEqual([
{ text: 'Retry the sync', dynamic: false },
{ text: 'Reconnect', dynamic: true },
{ text: 'after', dynamic: true },
{ text: 'attempts', dynamic: true }
])
})

it('ignores nested class properties and translations while seeing sibling copy', () => {
const reports = candidates(
'Sample.tsx',
[
'const config = {',
' className: { label: "Hidden class copy" },',
' classNames: ["Hidden token", { title: "Hidden nested token" }],',
' title: `Connect ${host}`,',
' message: "Reconnect the host",',
' description: t("connection.ready", "Ready now")',
'};',
'export const view = <span>{t("connection.banner", { label: "Connection banner" })}</span>;'
].join('\n')
)

expect(reports.map(({ text, dynamic }) => ({ text, dynamic }))).toEqual([
{ text: 'Connect', dynamic: true },
{ text: 'Reconnect the host', dynamic: false }
])
})

it('keeps traversing calls and callbacks to find nested JSX attributes and text', () => {
const reports = candidates(
'Sample.tsx',
`export function Sample() {
return <div>{select(true, () => <button title={'Retry after reconnect'}>Reconnect now</button>)}</div>
}`
)

expect(reports.map((report) => report.text)).toEqual(['Retry after reconnect', 'Reconnect now'])
})
})

describe('localization coverage file skipping', () => {
Expand Down
17 changes: 15 additions & 2 deletions config/scripts/localization-extraction-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,25 @@ const TOOLCHAIN_FILES = new Set([
'.github/workflows/pr.yml'
])

function isIgnoredTestSource(path) {
if (!/\.(?:js|jsx|ts|tsx|mts|cts)$/.test(path)) {
return false
}
const segments = path.split('/')
const filename = segments.at(-1) ?? ''
return (
filename.includes('.test.') ||
filename.includes('.spec.') ||
segments.slice(0, -1).some((segment) => segment === '__tests__' || segment === '__snapshots__')
)
}

export function affectsLocalizationExtraction(paths) {
return paths.some(
(path) =>
TOOLCHAIN_FILES.has(path) ||
// Include all source paths so catalog moves and future extractor inputs stay covered.
path.startsWith('src/') ||
// Keep catalogs and future source inputs; skip only configured test exclusions.
(path.startsWith('src/') && !isIgnoredTestSource(path)) ||
path.startsWith('config/i18next.') ||
path.startsWith('config/tsconfig') ||
path.startsWith('config/patches/') ||
Expand Down
58 changes: 58 additions & 0 deletions config/scripts/localization-extraction-change-scope.test.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
import localizationConfig from '../i18next.config'
import { affectsLocalizationExtraction } from './localization-extraction-change-scope.mjs'

it('pins the extractor inputs and exclusions used by the routing decision', () => {
expect(localizationConfig.extract.input).toEqual(['src/**/*.{js,jsx,ts,tsx,mts,cts}'])
expect(localizationConfig.extract.ignore).toEqual([
'**/*.test.*',
'**/*.spec.*',
'**/__tests__/**',
'**/__snapshots__/**',
'**/assets/**'
])
})

it.each([
'src/renderer/src/components/Example.tsx',
'src/main/notifications.ts',
Expand All @@ -24,6 +36,52 @@ it.each([
expect(affectsLocalizationExtraction([path])).toBe(true)
})

it.each([
'src/main/notifications.test.ts',
'src/main/notifications.spec.ts',
'src/renderer/src/components/Example.test.tsx',
'src/main/example.test.js',
'src/main/example.spec.jsx',
'src/main/example.test.mts',
'src/main/example.spec.cts',
'src/.test.ts',
'src/.spec.ts',
'src/__tests__/notifications.ts',
'src/main/__tests__/nested/notifications.ts',
'src/main/__snapshots__/notifications.ts'
])('avoids extraction for an explicitly ignored test source: %s', (path) => {
expect(affectsLocalizationExtraction([path])).toBe(false)
})

it.each([
'src/main/notifications-test.ts',
'src/main/notifications.test-support.ts',
'src/main/notifications.specification.ts',
'src/main/notifications.TEST.ts',
'src/main/.test./notifications.ts',
'src/main/notifications.test/notifications.ts',
'src/main/__tests__-support/notifications.ts',
'src/main/__snapshots__-support/notifications.ts',
'src/main/__tests__.ts',
'src/main/assets/notifications.ts',
'src/main/__tests__/en.json',
'src/renderer/src/i18n/locales/en.test.json',
'src/main/notifications.test.unknown'
])('retains extraction for catalogs, assets and near-match source paths: %s', (path) => {
expect(affectsLocalizationExtraction([path])).toBe(true)
})

it('retains mixed changes and both sides of source-to-test renames', () => {
const ignored = 'src/main/notifications.test.ts'
expect(affectsLocalizationExtraction([ignored, 'src/main/notifications.ts'])).toBe(true)
expect(affectsLocalizationExtraction(['src/main/notifications.ts', ignored])).toBe(true)
expect(affectsLocalizationExtraction([ignored, 'config/i18next.config.ts'])).toBe(true)
expect(affectsLocalizationExtraction([ignored, 'src/renderer/src/i18n/locales/en.json'])).toBe(
true
)
expect(affectsLocalizationExtraction([ignored, 'src/main/notifications.spec.ts'])).toBe(false)
})

it('avoids extraction for unrelated CI, documentation, and native changes', () => {
expect(
affectsLocalizationExtraction([
Expand Down
44 changes: 44 additions & 0 deletions docs/reference/ci-runner-efficiency.md
Original file line number Diff line number Diff line change
Expand Up @@ -2583,3 +2583,47 @@ The single hosted trial occupied 134 runner-seconds including all six samples,
shared setup and upload. That is trial cost, not a production saving. These
focused process measurements do not establish full-shard savings, PR runtime
percentiles, queue relief or a change in the organization's runner allowance.

## October 5 localization gate work

The localization coverage audit now classifies only AST nodes that can emit string
parts, and reuses the class-property exclusion set. It still visits every child and
handles JSX text separately. The extraction scope skips only source extensions and
test paths explicitly excluded by the real extractor configuration. Catalogs,
assets, unknown extensions, configuration changes and mixed production changes
still select extraction; both sides of a rename remain covered.

The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37360932616)
used fixed source `761d63a4e52ff7915d4432c004df13182db63010`, Linux ARM,
Node 24.21.0 and pnpm 12.8.1. Dependency setup was shared outside the samples.
Each timed check was a fresh process with Node compile caching disabled.

| Complete coverage audit | Original | Candidate | Paired saving |
| ----------------------- | -------- | --------- | ------------- |
| Pair 1 | 9.182s | 4.924s | 4.258s |
| Pair 2, reversed order | 9.133s | 4.924s | 4.209s |
| Pair 3 | 8.930s | 4.924s | 4.007s |

The median paired saving is 4.209 seconds, or
46.1% of this audit. All three pairs improved; the original range was
0.252 seconds. Complete JSON finding inventories match byte-for-byte,
including all 13 findings and their source locations. All 53 candidate tests pass.
Four real audit mutations fail their intended assertions. An ignored test fixture
passes the real extraction gate; renaming it into production source selects the
gate and fails on the deliberately missing translation key.

For an existing ignored test path, three conditional comparisons avoid a median
27.628 seconds of extraction work. Original arms really run the full gate
and pass; candidate extraction arms are explicitly `not_selected`. The routing
tests pin the actual extractor inputs and exclusions, so configuration drift fails
the tests. This saving applies only when every changed source path is ignored.

Relay integration also omits desktop native preparation and the Electron archive
cache. Its existing two files use Node's SQLite, HTTP and WebSocket paths; general
unit shards retain their desktop setup. The existing 16 relay cases pass locally
on Node 24.20.0 and 26.7.0. Removed setup work is not a measured timing saving.

These comparisons exclude queues and other checks. Extraction already overlaps
other preflight work, so conditional avoidance does not translate directly into
preflight wall time. No change to full-PR percentiles or the concurrency allowance
is established.
Loading