mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(client): route UI copy through locale
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
||||
# AGENTS.md — Repository scripts
|
||||
|
||||
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer.
|
||||
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. Source-ownership gates use syntax-aware discovery, guard against an empty or narrowed corpus, and test every admitted/excluded form that changes their detection boundary.
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('gate graph validation', () => {
|
||||
expect(ids).toEqual([
|
||||
'rescope-vendor', 'knip', 'publint', 'constraints', 'application-entrypoints',
|
||||
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
|
||||
'optional-dependency-imports', 'client-packages', 'cordis-config',
|
||||
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'cordis-config',
|
||||
'runtime-closure', 'vendored-links',
|
||||
])
|
||||
expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({
|
||||
@@ -136,6 +136,15 @@ describe('gate graph validation', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps hard-coded Client UI copy enforcement in %s',
|
||||
(mode) => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('client-ui-i18n')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps application entrypoint enforcement in %s',
|
||||
(mode) => {
|
||||
|
||||
@@ -275,6 +275,7 @@ function ciSharedStaticGates(): Gate[] {
|
||||
label: 'optional dependency imports',
|
||||
}),
|
||||
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
|
||||
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
]
|
||||
}
|
||||
@@ -640,6 +641,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
label: 'optional dependency imports',
|
||||
}),
|
||||
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
|
||||
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findUiI18nViolations } from './verify-client-ui-i18n.ts'
|
||||
|
||||
function messages(source: string): string[] {
|
||||
return findUiI18nViolations('packages/client/ui-example/src/client/View.tsx', source)
|
||||
.map(violation => violation.text)
|
||||
}
|
||||
|
||||
describe('Client UI i18n source check', () => {
|
||||
it('rejects direct JSX copy and copy-bearing attributes', () => {
|
||||
expect(messages(`
|
||||
const View = ({ ready }: { ready: boolean }) => <section aria-label="Overview">
|
||||
<span>Hard-coded text</span>
|
||||
<input placeholder={ready ? 'Search now' : ` + "`Wait ${'${ready}'}`" + `} />
|
||||
<div runningSummary="Still working" />
|
||||
</section>
|
||||
`)).toEqual(['Overview', 'Hard-coded text', 'Search now', 'Wait', 'Still working'])
|
||||
})
|
||||
|
||||
it('rejects copy kept in label data and copy helper returns', () => {
|
||||
expect(messages(`
|
||||
const TABS = [{ id: 'summary', label: 'Summary' }]
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'done') return 'Complete'
|
||||
return 'Still running'
|
||||
}
|
||||
function duration(): string { return 'Not recorded' }
|
||||
function mode(): string { return 'compact' }
|
||||
function Dialog({ closeLabel = 'Close dialog' }: { closeLabel?: string }) { return closeLabel }
|
||||
`)).toEqual(['Summary', 'Complete', 'Still running', 'Not recorded', 'Close dialog'])
|
||||
})
|
||||
|
||||
it('accepts translated copy, dynamic values, structural attributes, and language tokens', () => {
|
||||
expect(messages(`
|
||||
const View = ({ t, value }: { t: (key: string) => string; value: string }) => (
|
||||
<section className="root" role="region" aria-label={t('overview')}>
|
||||
<span>{t('status.complete')}</span>
|
||||
<code>null</code>
|
||||
{value === 'pending' && <output>{value}</output>}
|
||||
<output>{value}</output>
|
||||
</section>
|
||||
)
|
||||
`)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not inspect locale dictionary owners', () => {
|
||||
expect(findUiI18nViolations(
|
||||
'packages/client/ui-example/src/client/locales.ts',
|
||||
'export const en = { title: "Hard-coded by design" }',
|
||||
)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Reject product UI copy embedded directly in Client source.
|
||||
*
|
||||
* Locale dictionaries are the only source files allowed to own translated
|
||||
* text. Presentation code receives copy through its typed `t` seat or through
|
||||
* an already-localized prop. This check covers JSX text and copy-bearing
|
||||
* attributes, plus the common data/helper forms that feed them.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const MINIMUM_CLIENT_UI_SOURCES = 400
|
||||
|
||||
const COPY_ATTRIBUTES = new Set([
|
||||
'alt',
|
||||
'aria-description',
|
||||
'aria-label',
|
||||
'aria-valuetext',
|
||||
'cancelLabel',
|
||||
'closeLabel',
|
||||
'confirmLabel',
|
||||
'copyLabel',
|
||||
'description',
|
||||
'emptyLabel',
|
||||
'label',
|
||||
'placeholder',
|
||||
'title',
|
||||
'truncatedLabel',
|
||||
])
|
||||
const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Placeholder|Summary|Text|Title|Tooltip)$/
|
||||
|
||||
const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|placeholder|title|tooltip)(?:s|_.*)?$/i
|
||||
const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|placeholder|title|tooltip|tabs)$/i
|
||||
const IMMUTABLE_LANGUAGE_TOKENS = new Set([
|
||||
'Function',
|
||||
'K',
|
||||
'M',
|
||||
'Symbol',
|
||||
'false',
|
||||
'function()',
|
||||
'n',
|
||||
'null',
|
||||
'true',
|
||||
'undefined',
|
||||
])
|
||||
const LOCALE_KEY = /^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$/
|
||||
|
||||
/** One hard-coded product-copy occurrence. */
|
||||
export interface UiI18nViolation {
|
||||
/** One-based source column. */
|
||||
column: number
|
||||
/** Repository-relative source path. */
|
||||
file: string
|
||||
/** One-based source line. */
|
||||
line: number
|
||||
/** Why this literal is treated as product copy. */
|
||||
reason: string
|
||||
/** Compact literal text for the diagnostic. */
|
||||
text: string
|
||||
}
|
||||
|
||||
function localeOwner(file: string): boolean {
|
||||
const normalized = file.replaceAll('\\', '/')
|
||||
const base = normalized.slice(normalized.lastIndexOf('/') + 1)
|
||||
return base === 'locale.ts'
|
||||
|| base === 'locales.ts'
|
||||
|| normalized.includes('/locales/')
|
||||
}
|
||||
|
||||
function containsProductText(text: string): boolean {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
return normalized !== ''
|
||||
&& !IMMUTABLE_LANGUAGE_TOKENS.has(normalized)
|
||||
&& !LOCALE_KEY.test(normalized)
|
||||
&& /\p{L}/u.test(normalized)
|
||||
}
|
||||
|
||||
function translationCall(node: ts.CallExpression): boolean {
|
||||
const callee = node.expression
|
||||
return ts.isIdentifier(callee)
|
||||
? callee.text === 't'
|
||||
: ts.isPropertyAccessExpression(callee) && callee.name.text === 't'
|
||||
}
|
||||
|
||||
function propertyName(node: ts.PropertyName | ts.BindingName): string | undefined {
|
||||
return ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined
|
||||
}
|
||||
|
||||
function copyAttribute(name: string): boolean {
|
||||
return !name.endsWith('Key')
|
||||
&& (COPY_ATTRIBUTES.has(name) || COPY_ATTRIBUTE_SUFFIX.test(name))
|
||||
}
|
||||
|
||||
function compactText(text: string): string {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}...`
|
||||
}
|
||||
|
||||
function looksLikeNaturalText(text: string): boolean {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
return /\s|[\u3400-\u9fff]/u.test(normalized) || /^[A-Z]/.test(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find hard-coded product copy in one Client source file.
|
||||
* @param file - repository-relative path used in diagnostics.
|
||||
* @param sourceText - TypeScript or TSX source.
|
||||
* @returns violations in source order.
|
||||
*/
|
||||
export function findUiI18nViolations(file: string, sourceText: string): UiI18nViolation[] {
|
||||
if (localeOwner(file)) return []
|
||||
const source = ts.createSourceFile(
|
||||
file,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
)
|
||||
const violations = new Map<number, UiI18nViolation>()
|
||||
|
||||
const report = (
|
||||
node: ts.Node,
|
||||
text: string,
|
||||
reason: string,
|
||||
naturalOnly = false,
|
||||
): void => {
|
||||
if (
|
||||
!containsProductText(text)
|
||||
|| (naturalOnly && !looksLikeNaturalText(text))
|
||||
|| violations.has(node.getStart(source))
|
||||
) return
|
||||
const position = source.getLineAndCharacterOfPosition(node.getStart(source))
|
||||
violations.set(node.getStart(source), {
|
||||
column: position.character + 1,
|
||||
file,
|
||||
line: position.line + 1,
|
||||
reason,
|
||||
text: compactText(text),
|
||||
})
|
||||
}
|
||||
|
||||
const collectExpression = (
|
||||
node: ts.Expression,
|
||||
reason: string,
|
||||
naturalOnly = false,
|
||||
): void => {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
report(node, node.text, reason, naturalOnly)
|
||||
return
|
||||
}
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
report(
|
||||
node,
|
||||
[node.head.text, ...node.templateSpans.map(span => span.literal.text)].join(''),
|
||||
reason,
|
||||
naturalOnly,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (ts.isCallExpression(node)) {
|
||||
if (translationCall(node)) return
|
||||
return
|
||||
}
|
||||
if (
|
||||
ts.isParenthesizedExpression(node)
|
||||
|| ts.isAsExpression(node)
|
||||
|| ts.isSatisfiesExpression(node)
|
||||
|| ts.isNonNullExpression(node)
|
||||
) {
|
||||
collectExpression(node.expression, reason, naturalOnly)
|
||||
return
|
||||
}
|
||||
if (ts.isConditionalExpression(node)) {
|
||||
collectExpression(node.whenTrue, reason, naturalOnly)
|
||||
collectExpression(node.whenFalse, reason, naturalOnly)
|
||||
return
|
||||
}
|
||||
if (ts.isBinaryExpression(node)) {
|
||||
if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
|
||||
collectExpression(node.right, reason, naturalOnly)
|
||||
} else if (
|
||||
node.operatorToken.kind === ts.SyntaxKind.PlusToken
|
||||
|| node.operatorToken.kind === ts.SyntaxKind.BarBarToken
|
||||
|| node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
|
||||
) {
|
||||
collectExpression(node.left, reason, naturalOnly)
|
||||
collectExpression(node.right, reason, naturalOnly)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ts.isArrayLiteralExpression(node)) {
|
||||
for (const element of node.elements) {
|
||||
if (ts.isExpression(element)) collectExpression(element, reason, naturalOnly)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(node)) {
|
||||
for (const property of node.properties) {
|
||||
if (ts.isPropertyAssignment(property)) {
|
||||
const name = propertyName(property.name)
|
||||
const propertyOwnsCopy = name !== undefined
|
||||
&& (COPY_NAME.test(name) || COPY_SUFFIX.test(name))
|
||||
collectExpression(property.initializer, reason, naturalOnly || !propertyOwnsCopy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enclosingFunctionName = (node: ts.Node): string | undefined => {
|
||||
let current = node.parent
|
||||
while (!ts.isSourceFile(current)) {
|
||||
if (ts.isFunctionDeclaration(current) || ts.isMethodDeclaration(current)) {
|
||||
return current.name === undefined ? undefined : propertyName(current.name)
|
||||
}
|
||||
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
|
||||
const parent = current.parent
|
||||
return ts.isVariableDeclaration(parent) ? propertyName(parent.name) : undefined
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const hasExplicitStringReturn = (node: ts.Node): boolean => {
|
||||
let current = node.parent
|
||||
while (!ts.isSourceFile(current)) {
|
||||
if (
|
||||
ts.isFunctionDeclaration(current)
|
||||
|| ts.isMethodDeclaration(current)
|
||||
|| ts.isArrowFunction(current)
|
||||
|| ts.isFunctionExpression(current)
|
||||
) return current.type?.kind === ts.SyntaxKind.StringKeyword
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isJsxText(node)) report(node, node.text, 'JSX text')
|
||||
|
||||
if (ts.isJsxAttribute(node)) {
|
||||
const name = node.name.getText(source)
|
||||
if (copyAttribute(name) && node.initializer !== undefined) {
|
||||
if (ts.isStringLiteral(node.initializer)) report(node.initializer, node.initializer.text, `${name} attribute`)
|
||||
else if (ts.isJsxExpression(node.initializer) && node.initializer.expression !== undefined) {
|
||||
collectExpression(node.initializer.expression, `${name} attribute`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isJsxExpression(node)
|
||||
&& node.expression !== undefined
|
||||
&& (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))
|
||||
) collectExpression(node.expression, 'JSX child')
|
||||
|
||||
if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) {
|
||||
const name = propertyName(node.name)
|
||||
if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
|
||||
collectExpression(node.initializer, `${name} property`)
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
|
||||
const name = propertyName(node.name)
|
||||
if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
|
||||
collectExpression(node.initializer, `${name} value`)
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isBindingElement(node) && node.initializer !== undefined) {
|
||||
const name = propertyName(node.name)
|
||||
if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
|
||||
collectExpression(node.initializer, `${name} default value`)
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isReturnStatement(node) && node.expression !== undefined) {
|
||||
const name = enclosingFunctionName(node)
|
||||
if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
|
||||
collectExpression(node.expression, `${name} return value`)
|
||||
} else if (file.endsWith('.tsx') && hasExplicitStringReturn(node)) {
|
||||
collectExpression(node.expression, 'string return value', true)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column)
|
||||
}
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return [...new Set([
|
||||
...globSync('packages/client/*/src/**/*.tsx', { cwd: root }),
|
||||
...globSync('packages/client/ui-*/src/**/*.{ts,tsx}', { cwd: root }),
|
||||
...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }),
|
||||
])]
|
||||
.map(file => file.split(sep).join('/'))
|
||||
.filter(file => !file.endsWith('.d.ts'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = sourceFiles()
|
||||
if (files.length < MINIMUM_CLIENT_UI_SOURCES) {
|
||||
throw new Error(
|
||||
`verify-client-ui-i18n: discovery narrowed to ${files.length} source file(s); expected at least ${MINIMUM_CLIENT_UI_SOURCES}.`,
|
||||
)
|
||||
}
|
||||
const violations = files.flatMap(file =>
|
||||
findUiI18nViolations(file, readFileSync(resolve(root, file), 'utf8')))
|
||||
if (violations.length > 0) {
|
||||
console.error(`verify-client-ui-i18n: ${violations.length} hard-coded UI string(s):`)
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
` ${violation.file}:${violation.line}:${violation.column} ${violation.reason}: ${JSON.stringify(violation.text)}`,
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`verify-client-ui-i18n: ${files.length} Client UI source file(s) use locale-owned copy.`)
|
||||
}
|
||||
|
||||
if (import.meta.filename === resolve(process.argv[1] ?? '')) main()
|
||||
Reference in New Issue
Block a user