diff --git a/scripts/docs-i18n/doc_chunked_raw.go b/scripts/docs-i18n/doc_chunked_raw.go index 58175ecc2294..be1deb14ee50 100644 --- a/scripts/docs-i18n/doc_chunked_raw.go +++ b/scripts/docs-i18n/doc_chunked_raw.go @@ -78,6 +78,9 @@ func validateDocBodyFencedLiterals(source, translated string) error { } sourceStructure := summarizeDocChunkStructure(source) translatedStructure := summarizeDocChunkStructure(translated) + if !sameI18NProtocolMarkers(source, translated) { + return fmt.Errorf("i18n placeholder mismatch") + } if !slices.Equal(sourceStructure.listShapes, translatedStructure.listShapes) { return fmt.Errorf("list structure mismatch: source=%v translated=%v", sourceStructure.listShapes, translatedStructure.listShapes) } @@ -236,6 +239,12 @@ func validateDocChunkTranslation(source, translated string) error { sourceLower := strings.ToLower(source) translatedLower := strings.ToLower(translated) for _, token := range docsProtocolTokens { + if token == "__OC_I18N_" { + if !sameI18NProtocolMarkers(source, translated) { + return fmt.Errorf("protocol token leaked: %s", token) + } + continue + } tokenLower := strings.ToLower(token) if strings.Contains(sourceLower, tokenLower) { continue @@ -278,6 +287,16 @@ func validateDocChunkTranslation(source, translated string) error { return nil } +func sameI18NProtocolMarkers(source, translated string) bool { + if !sameStringMultiset(placeholderRe.FindAllString(source, -1), placeholderRe.FindAllString(translated, -1)) { + return false + } + sourceResidual := placeholderRe.ReplaceAllString(source, "") + translatedResidual := placeholderRe.ReplaceAllString(translated, "") + return strings.Count(strings.ToLower(sourceResidual), "__oc_i18n_") == + strings.Count(strings.ToLower(translatedResidual), "__oc_i18n_") +} + func sameStringMultiset(left, right []string) bool { if len(left) != len(right) { return false diff --git a/scripts/docs-i18n/doc_mode_test.go b/scripts/docs-i18n/doc_mode_test.go index e3c3ea80ed1c..89c2c661cf4b 100644 --- a/scripts/docs-i18n/doc_mode_test.go +++ b/scripts/docs-i18n/doc_mode_test.go @@ -546,6 +546,44 @@ func TestValidateDocChunkTranslationRejectsInventedI18NPlaceholder(t *testing.T) } } +func TestValidateDocChunkTranslationRejectsAdditionalI18NPlaceholder(t *testing.T) { + t.Parallel() + + source := "```text\n__OC_I18N_900000__\n```\n" + translated := "```text\n__OC_I18N_900000__\n```\n__OC_I18N_900014__\n" + + err := validateDocChunkTranslation(source, translated) + if err == nil { + t.Fatal("expected additional i18n placeholder to be rejected") + } + if !strings.Contains(err.Error(), "protocol token leaked: __OC_I18N_") { + t.Fatalf("expected i18n placeholder leakage error, got %v", err) + } +} + +func TestValidateDocChunkTranslationRejectsMalformedI18NPlaceholder(t *testing.T) { + t.Parallel() + + for _, leaked := range []string{"__oc_i18n_900014__", "__OC_I18N_invalid__"} { + err := validateDocChunkTranslation("Regular paragraph.\n", "Обычный абзац.\n"+leaked+"\n") + if err == nil { + t.Fatalf("expected malformed i18n placeholder %q to be rejected", leaked) + } + } +} + +func TestValidateDocBodyFencedLiteralsRejectsRestoredPlaceholderLeak(t *testing.T) { + t.Parallel() + + source := "Before.\n\n```ts\nconst value = \"\";\n```\n\nAfter.\n" + translated := "До.\n\n__OC_I18N_900014__\n\nПосле.\n" + + err := validateDocBodyFencedLiterals(source, translated) + if err == nil { + t.Fatal("expected restored placeholder leak to be rejected") + } +} + func TestValidateDocChunkTranslationRejectsHeadingLoss(t *testing.T) { t.Parallel() @@ -2142,7 +2180,7 @@ func TestProcessFileDocUsesFieldLevelFrontmatterTranslation(t *testing.T) { if !strings.Contains(text, "在 Fly.io 上部署 OpenClaw") { t.Fatalf("expected translated read_when entry in output:\n%s", text) } - if !strings.Contains(text, "prompt_version: 16") { + if !strings.Contains(text, "prompt_version: 17") { t.Fatalf("expected prompt version 15 in output metadata:\n%s", text) } } diff --git a/scripts/docs-i18n/util.go b/scripts/docs-i18n/util.go index 6b93f67914d4..4f9fd97b6eb8 100644 --- a/scripts/docs-i18n/util.go +++ b/scripts/docs-i18n/util.go @@ -12,7 +12,7 @@ import ( const ( workflowVersion = 16 - promptVersion = 16 + promptVersion = 17 docsI18nEngineName = "codex" envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER" envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL" diff --git a/scripts/docs-i18n/util_test.go b/scripts/docs-i18n/util_test.go index cc147f62d277..057355453986 100644 --- a/scripts/docs-i18n/util_test.go +++ b/scripts/docs-i18n/util_test.go @@ -8,7 +8,7 @@ import ( func TestCacheNamespaceIncludesPromptVersion(t *testing.T) { t.Parallel() - if want := "prompt=16"; !strings.Contains(cacheNamespace(), want) { + if want := "prompt=17"; !strings.Contains(cacheNamespace(), want) { t.Fatalf("expected cache namespace to contain %q, got %q", want, cacheNamespace()) } }