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
2 changes: 2 additions & 0 deletions .github/workflows/code-qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ jobs:
install-args: "--frozen-lockfile"
- name: Verify all translations are complete
run: node scripts/find-missing-translations.js
- name: Verify t() calls pass every interpolation variable
run: node scripts/check-i18n-interpolation.js

knip:
runs-on: ubuntu-latest
Expand Down
36 changes: 36 additions & 0 deletions packages/config-eslint/i18n-keys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// i18next splits a namespace from a key on ":" only. t("chat.foo") has no namespace, so it looks in the
// default namespace, misses, and shows the raw key to the user. Built-in no-restricted-syntax only.
import { readdirSync } from "node:fs"
import { basename } from "node:path"

/**
* @param {URL | string} enLocalesDir The `en` locale directory. Each JSON file name is a namespace.
*/
export function createI18nDotKeyConfig(enLocalesDir) {
const namespaces = readdirSync(enLocalesDir)
.filter((file) => file.endsWith(".json"))
.map((file) => basename(file, ".json"))
const dotKey = `/^(${namespaces.join("|")})\\./`
const message = "Write the key as 'namespace:path'. A dot after the namespace is not a namespace separator."

return {
files: ["**/*.ts", "**/*.tsx"],
ignores: ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"],
rules: {
"no-restricted-syntax": [
"error",
...i18nDotKeySelectors(dotKey).map((selector) => ({ selector, message })),
],
},
}
}

/** The selectors alone, for a file set that already sets no-restricted-syntax. */
export function getI18nDotKeySelectors(config) {
return config.rules["no-restricted-syntax"].slice(1)
}

function i18nDotKeySelectors(dotKey) {
const call = "CallExpression:matches([callee.name='t'], [callee.property.name='t'])"
return [`${call}[arguments.0.value=${dotKey}]`, `${call}[arguments.0.quasis.0.value.cooked=${dotKey}]`]
}
44 changes: 44 additions & 0 deletions packages/config-eslint/i18n-keys.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { URL } from "node:url"

import { Linter } from "eslint"
import typescriptEslint from "typescript-eslint"

import { createI18nDotKeyConfig } from "./i18n-keys.js"

const linter = new Linter({ configType: "flat" })
const config = [
{ files: ["**/*.ts", "**/*.tsx"], languageOptions: { parser: typescriptEslint.parser } },
createI18nDotKeyConfig(new URL("../../webview-ui/src/i18n/locales/en", import.meta.url)),
]

const lint = (code, filename = "components/example.tsx") =>
linter.verify(code, config, { filename }).map((message) => message.message)

describe("i18n dot-form keys", () => {
for (const code of [
't("chat.commandExecution.exitStatus", { exitCode })',
'i18next.t("settings.autoApprove.title")',
"t(`chat.errors.${name}`)",
"t(`chat\\u002ecommandExecution.exitStatus`)",
]) {
it(`flags: ${code}`, () => {
assert.equal(lint(code).length, 1)
})
}

for (const code of [
't("chat:commandExecution.exitStatus", { exitCode })',
't("unknownNamespace.key")',
"t(`chat:errors.${name}`)",
]) {
it(`allows: ${code}`, () => {
assert.deepEqual(lint(code), [])
})
}

it("does not apply to spec files", () => {
assert.deepEqual(lint('t("chat.key")', "components/__tests__/example.spec.tsx"), [])
})
})
5 changes: 3 additions & 2 deletions packages/config-eslint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
"./react": "./react.js",
"./next-js": "./next.js",
"./provider-identifiers": "./provider-identifiers.js",
"./api-provider-hygiene": "./api-provider-hygiene.js"
"./api-provider-hygiene": "./api-provider-hygiene.js",
"./i18n-keys": "./i18n-keys.js"
},
"scripts": {
"lint": "eslint . --max-warnings=0",
"test": "node --test provider-identifiers.test.js api-provider-hygiene.test.js"
"test": "node --test provider-identifiers.test.js api-provider-hygiene.test.js i18n-keys.test.js"
},
"devDependencies": {
"@eslint/js": "9.39.5",
Expand Down
118 changes: 118 additions & 0 deletions scripts/check-i18n-interpolation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Finds t("ns:key", { ... }) calls that do not pass a variable the English string needs.
* Example: the string has {{exitCode}} but the call passes { exitStatus }.
*
* Usage: node scripts/check-i18n-interpolation.js
*
* Only calls with a string-literal key and an object-literal second argument are checked.
* A spread in the object skips the call.
*/

const path = require("path")
const fs = require("fs")
const { execFileSync } = require("child_process")
const ts = require("typescript")

const ROOT = path.join(__dirname, "..")
const PACKAGES = ["src", "webview-ui/src"]

const files = execFileSync(
"git",
["ls-files", "-z", "--", ...PACKAGES.map((p) => `${p}/**/*.ts`), ...PACKAGES.map((p) => `${p}/**/*.tsx`)],
{
cwd: ROOT,
encoding: "utf8",
},
)
.split("\0")
.filter((f) => f && !/(\/__tests__\/|\.(spec|test)\.tsx?$)/.test(f))

const resources = {}
function getResource(pkg, ns) {
// A namespace is a locale file name. Reject anything that could leave the locale directory.
if (!/^[\w.-]+$/.test(ns)) return undefined
const id = `${pkg}:${ns}`
if (!(id in resources)) {
const file = path.join(ROOT, pkg === "src" ? "src" : "webview-ui/src", "i18n/locales/en", `${ns}.json`)
resources[id] = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : undefined
}
return resources[id]
}

function lookup(resource, keyPath) {
let node = resource
for (const part of keyPath.split(".")) {
if (node === null || typeof node !== "object" || !(part in node)) return undefined
node = node[part]
}
return node
}

// "Exited with {{exitCode}}" and "{{- name}}" and "{{n, number}}" -> exitCode, name, n
function placeholders(value) {
return [...value.matchAll(/\{\{-?\s*([\w$]+)[^}]*\}\}/g)].map((m) => m[1])
}

const problems = []

for (const rel of files) {
const pkg = rel.startsWith("src/") ? "src" : "webview-ui"
const source = fs.readFileSync(path.join(ROOT, rel), "utf8")
const sf = ts.createSourceFile(
rel,
source,
ts.ScriptTarget.Latest,
true,
rel.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
)

const visit = (node) => {
if (ts.isCallExpression(node)) {
const callee = node.expression
const isT =
(ts.isIdentifier(callee) && callee.text === "t") ||
(ts.isPropertyAccessExpression(callee) && callee.name.text === "t")
const [keyArg, optionsArg] = node.arguments
if (
isT &&
keyArg &&
ts.isStringLiteralLike(keyArg) &&
keyArg.text.includes(":") &&
optionsArg &&
ts.isObjectLiteralExpression(optionsArg) &&
!optionsArg.properties.some(ts.isSpreadAssignment)
) {
// i18next splits on the first ":" and joins the rest with "."
const [ns, ...rest] = keyArg.text.split(":")
const keyPath = rest.join(".")
const resource = getResource(pkg, ns)
if (resource) {
const passed = new Set(
optionsArg.properties.map(
(p) => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text,
),
)
// A plural key lives under key_one, key_other, and so on.
const values = [keyPath, `${keyPath}_zero`, `${keyPath}_one`, `${keyPath}_other`]
.map((k) => lookup(resource, k))
.filter((v) => typeof v === "string")
const missing = new Set(values.flatMap(placeholders).filter((name) => !passed.has(name)))
if (missing.size > 0) {
const { line } = sf.getLineAndCharacterOfPosition(node.getStart())
problems.push(
`${rel}:${line + 1} t("${keyArg.text}") does not pass: ${[...missing].join(", ")}`,
)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}
ts.forEachChild(node, visit)
}
visit(sf)
}

if (problems.length > 0) {
console.error(`Found ${problems.length} t() call(s) missing an interpolation variable:\n${problems.join("\n")}`)
process.exit(1)
}
console.log(`OK: checked ${files.length} files, no missing interpolation variables.`)
15 changes: 15 additions & 0 deletions src/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
apiProviderTestSelectors,
apiSpecTsIgnoreConfig,
} from "@roo-code/config-eslint/api-provider-hygiene"
import { createI18nDotKeyConfig, getI18nDotKeySelectors } from "@roo-code/config-eslint/i18n-keys"
import { createProviderIdentifierConfig } from "@roo-code/config-eslint/provider-identifiers"
import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers"

Expand All @@ -27,6 +28,8 @@ const testPathTraversalSelectors = [
},
]

const i18nDotKeyConfig = createI18nDotKeyConfig(`${import.meta.dirname}/i18n/locales/en`)

// Test-utils rollout guardrail: lanes converted to the shared reset helper
// stay converted. Use clearAllMocks from test-utils/reset.
const testResetHelperSelector = {
Expand All @@ -40,7 +43,19 @@ const testResetHelperSelector = {
export default [
...config,
createProviderIdentifierConfig({ providerIdentifiers, retiredProviderIdentifiers }),
i18nDotKeyConfig,
apiProviderHygieneConfig,
{
// A later block replaces no-restricted-syntax for the same files, so repeat the i18n selectors.
...apiProviderHygieneConfig,
rules: {
"no-restricted-syntax": [
"error",
...apiProviderHygieneConfig.rules["no-restricted-syntax"].slice(1),
...getI18nDotKeySelectors(i18nDotKeyConfig),
],
},
},
{
rules: {
"prefer-const": ["error", { destructuring: "all" }],
Expand Down
Loading
Loading