Skip to content

Enforce the translation output checklist as an automated check, not a prose list #67

Closed

Description

@os-zhuang

Blocked-by: #66

Problem

docs/TRANSLATION.md (landing in #65) ends with a pre-PR checklist a translation pass is expected to satisfy: code fences byte-identical to the English source, the page's URL set a subset of the English page's, frontmatter keys matching, MDX component names unchanged, no <script / javascript: / on*=, length within ±40%.

A prose checklist is the weakest form of every one of those rules. The translation pass is an agent with an editor, and the failure mode is not malice but helpfulness — fixing a link it believes is broken, repairing a code sample, restructuring a table while translating. Those produce a PR that reads like a translation and is not one.

The <script / javascript: / on*= items are a different weight class: the English MDX is authored by anyone who can open a PR, it is fed to a model, and the output is committed to a public site. A prompt instruction not to follow embedded instructions is necessary and is not a control. The control is refusing to commit output that fails validation.

Required outcome

A check that runs on translation PRs and fails them when any checklist item is violated, so the list in docs/TRANSLATION.md becomes a description of an enforced rule rather than an aspiration.

Constraints, inherited from the two checks landing in #65 and non-negotiable for the same reasons:

  • Dependency-free, network-free, credential-free. It must run on a fork PR and add no secret to this public repository. Parse frontmatter as text; do not add a YAML dependency.
  • Locales come from apps/docs/lib/i18n.ts, which AGENTS.md names as the authority — do not hardcode the list.
  • Each rule must be demonstrated able to go red. A validator observed only green is indistinguishable from one that cannot fail; include fixtures that trip each rule.

Scope note

Serial with #66 — same file surface (.github/scripts/, .github/workflows/translations.yml, docs/TRANSLATION.md). Do not start before #65 merges.

Not dispatched: objectos currently has no lane or seat in the pm-dispatch protocol, which is open as objectstack-ai/objectstack#9551. This card is filed unassigned and stays in the backlog until that is settled.

Activity

  1. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Unblocked: #66 is closed (#65 merged as a132376).

    Premise re-verified on the merged ref rather than assumed — the upstream merge that unblocks a card is the merge most likely to have changed it:

    • The pre-PR checklist this card proposes to enforce is present in docs/TRANSLATION.md on origin/main, unchanged from the version this card was written against. The card's scope still stands.
    • The file surface it will touch (.github/scripts/, .github/workflows/translations.yml) is now settled on main; the serial constraint against docs: make translations a generated artifact with a freshness gate #65 is discharged.
    • The two blocking verdicts of the existing gate were confirmed able to fail on merged main — an orphaned translation and an unstamped one each exit 1. So the new validators are additive to a gate that demonstrably works, not a repair of one that does not.

    Still not dispatched: this repository has no lane or seat in the pm-dispatch protocol (objectstack-ai/objectstack#9551). Filing and unblocking a card is within a PM seat's remit; dispatching work in an unowned repository is the improvisation that produced the deviation recorded on #66, so it waits on that decision.


    Generated by Claude Code

  2. self-assigned this
    on Aug 18, 2026
  3. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 3
    Session: session_01CJPxtTxoxTUnjNdTbiEaRa
    Branch: claude/issue-67-translation-output-validators
    Worktree: objectos-issue-67
    Domain: n/a — objectos has no lane or seat (objectstack-ai/objectstack#9551 still open). Dispatch authorized by the maintainer under the standing 维护者直派通道, 原话: 「现在允许你派 objectos 的活」.
    File surface: .github/scripts/, .github/workflows/translations.yml, docs/TRANSLATION.md
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — this repository holds no contract surface.
    Serial constraints cleared: the blocking predecessor #66 is closed (#65 merged as a132376); premise re-verified on the merged ref in the comment above. Batch siblings #61 (apps/docs/**) and #64 (content/docs/architecture.mdx) are disjoint on files. One declared read-coupling: #61 may edit apps/docs/lib/i18n.ts, which the scripts in this card's file surface parse with a regex over languages: [...]. File-surface disjointness does not cover it; both dispatches carry the warning, and whichever lands second re-runs the other's checks.


    Generated by Claude Code

  4. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Confirming the pipefail defect #74 reports, independently measured, because it is a defect I shipped and it should be in the record whatever happens to that PR.

    translations.yml on main runs the freshness gate as node ... | tee -a "$GITHUB_STEP_SUMMARY". The default shell for a run: step is bash -e {0} — no pipefail — so the step takes tee's exit status:

    $ node .github/scripts/check-translations.mjs >/dev/null 2>&1; echo $?
    1
    $ bash -e -c 'node .github/scripts/check-translations.mjs 2>/dev/null | tee /dev/null >/dev/null; echo $?'
    0
    

    Measured with a deliberately unstamped file present, so the gate genuinely had a blocking verdict to report. It exits 1 as a script and passes as a job. Both blocking verdicts — unstamped and orphan — have been unenforceable since #65 merged.

    This one is mine, and the way I missed it is worth naming because it is the same trap the check exists to prevent: before merging #65 I did verify the gate could go red, by making an orphan and an unstamped file and checking the exit code — of the script. I never checked that the job fails. "The script exits 1" and "the gate blocks" are different claims, and only the second one matters. I asserted the opposite about GitHub's default shell earlier in this session; that assertion was wrong.

    Nothing is currently slipping through: main has 0 unstamped and 0 orphaned files, so the gate has had nothing to catch. The exposure is any future PR that introduces one.

    The fix belongs where #74 put it — same file, same defect class, and it would be worse to add a fourth piped step next to three broken ones. If #74 does not land for any reason, this must be extracted into its own card rather than going down with it. Recording that here so it cannot become invisible.


    Generated by Claude Code

  5. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report

    {
      "issue": 67,
      "status": "done",
      "branch": "claude/issue-67-translation-output-validators",
      "pr": "https://github.com/objectstack-ai/objectos/pull/74",
      "premise_still_valid": true,
      "summary": "Added .github/scripts/check-translation-output.mjs, a third zero-dependency check wired into translations.yml, enforcing all six checklist rules from docs/TRANSLATION.md (fence, url, frontmatter, component, unsafe, length). Shape confirms the PM's guess (third script, wired into the existing job) but rejects the diff-only scan: the whole corpus is always scanned and only BLOCKING is scoped to the PR diff, because the ownership check is inert until TRANSLATION_BOT_LOGIN is set (#68) so hand-edited locale files reach main today, and scanning 335 pages costs milliseconds. unsafe blocks corpus-wide including English sources (corpus is clean of it: 0 findings); fidelity blocks on changed locale files and reports the 91 pre-existing findings. Two rules needed calibration rather than literal implementation: the +-40% length band applied against 1.0 fails 77 of 256 translations because correct zh-Hans runs 0.54x and fr 1.13x, so the band is normalized per locale via LOCALE_EXPANSION (--calibrate recomputes; uncalibrated locales are reported, never judged); and URL comparison strips fragments, because translated heading ids mean a translation MUST change its anchors. Also fixed a bounded in-place defect in the same file surface and same defect class: the default run: shell is bash -e with no pipefail, so 'node ... | tee' reported tee's status and the existing Freshness gate could not fail the job.",
      "tests": "All on 751312f (final commit; type-check/build re-run with --force after a turbo cache hit replayed another worktree's log). --self-test: 20/20 cases, each asserting the EXACT rule set fired, every one of the 6 rules demonstrated red, plus 4 green controls (clean pair, URL subset allowed, translated same-page anchor, URL closed by full-width punctuation) — 'code fence edited' fired [fence], 'script tag in the ENGLISH source' fired [unsafe], 'body truncated to half' fired [length]. Reverse-verified the scoping both directions: --files naming resources/support.de.mdx exits 1 with exactly that file's fence finding; --files naming a clean locale file exits 0 despite 91 corpus findings; --report exits 0. Workflow run block simulated under the real Actions shell (bash --noprofile --norc -eo pipefail): push path exit 0, pull_request path exit 0, and a corpus-blocking run piped to tee now exits 1 (before the fix: 0). Existing checks unchanged and passing: check-translations.mjs 'translations gate passed' EXIT=0 (79 English, 256 translations); check-translation-ownership.mjs EXIT=0 with the var unset, EXIT=0 for a human touching no artifacts, EXIT=1 for the bot touching site code. pnpm turbo run type-check --force EXIT=0, pnpm turbo run build --force EXIT=0. pnpm turbo run test executes ZERO tasks (no test task exists in this repo) — the --self-test workflow step is the only executable guarantee, which is why it runs before the corpus. No control bytes in any changed file.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #75: 91 fidelity findings on 84 files on main (73 fence, 12 length, 6 url), 83 of which are NOT stale — they carry a current source_sha and the freshness gate passes them; includes support.de.mdx advertising ObjectOS version 10.2.0 in a code fence where English says 14.7.0, and five locales of license.mdx inventing a /docs/reference/security cross-reference the English page never makes. Not fixable here: translations are generated artifacts and the ownership check rejects a PR that touches them.",
        "not filed, recorded for the PM's own tooling: the GitHub MCP body field truncates at a literal '<script' token. Issue #67's stored body is intact and renders in full on the web page, but both issue_read and list_issues returned it cut off mid-sentence at exactly that byte. Reading #67 through the API alone would have lost the Required outcome and Constraints sections."
      ]
    }

    Notes not in the JSON:


    Generated by Claude Code

  6. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — #74.

    Reviewed against GitHub and against the branch itself, not against the report.

    The pipefail fix — verified as a job, not as a script

    This is the check I owed, and it is the one I got wrong last time. Same failing condition (an unstamped file in the corpus), the two shells side by side:

    OLD  bash -e {0}                          job exit=0    ← the defect
    NEW  bash --noprofile --norc -eo pipefail  job exit=1    ← fixed
    

    shell: bash is on all three piped steps. The gate now actually blocks. Previously it exited 1 as a script and passed as a job, which is the same failure class the card exists to prevent — a check that cannot go red.

    Accepting the in-place fix rather than splitting it out: same file surface, same defect class, and adding a fourth piped step next to three broken ones would have been the worse call. The comment left above the steps explains why shell: bash is load-bearing, which is the part that survives someone tidying the workflow later.

    The validator earns its place, demonstrated on a real file

    The argument for this check is that provenance cannot answer "is this actually a translation of that page". Verified on the headline case rather than on a fixture:

    • content/docs/resources/support.de.mdx line 27 advertises ObjectOS version: 10.2.0; the English source line 23 says 14.7.0 — inside a fenced code block, in a support-ticket template a customer is meant to copy.
    • That file's source_sha is f6f76ab1…, and sha256sum of the current English source is f6f76ab1… — identical. The freshness gate passes it and always would have.

    83 of the 84 files with findings are in that state. That is the gap, measured, not argued.

    Self-test

    20 cases, each asserting the exact rule set it fires, so a fixture going red for the wrong reason fails. All six rules demonstrated able to fail, including unsafe on an English source, plus four green controls (clean pair, URL subset, translated same-page anchor, URL closed by full-width punctuation). Run from the branch; the last lines are the ones quoted in the PR body.

    That it caught a defect in itself while being written — the clean pair tripping length because it was judged against another locale's band — is the sort of thing a self-test is for.

    The two calibrations, checked rather than accepted

    Length. My concern going in was circularity: the band is derived from a corpus that contains 12 length findings, so systematically short translations could calibrate their own shortness into normality. It is computed as the median, which answers most of that — a dozen outliers in 256 do not move it. A residual remains and is worth stating: a locale whose corpus is uniformly biased would still calibrate that bias in, and the median cannot see it. Not a blocker; nothing in this repository is in that state today. Applying the band against 1.0 instead would fail 77 of 256 files and report translating into Chinese as a defect, which is not a usable gate.

    Uncalibrated locales are reported, never judged — "no band is better than a wrong one" is the right default.

    URLs. Fragments stripped, page targets still compared. Correct: a translated heading id must change, so comparing fragments would demand every translation link to anchors it does not have. The rule still catches the invented /docs/reference/security cross-reference in five locales of license.mdx, which is the class that matters.

    Shape decisions

    The card left two questions open and both answers are better than my guess. Scanning the whole corpus while scoping only blocking to the diff is right for a reason I had not thought through: fidelity is a relation between two files, so an English-only edit breaks it in six siblings the PR deliberately left alone — blocking those would contradict "stale is reported, not blocking". And unsafe blocking corpus-wide is right because a diff-scoped security rule is defeated by ordering.

    Not bumping GUIDE_REV is also correct — this changes how the guide is enforced, not what to translate; bumping it would mark all 256 files for retranslation.

    Other checks

    Item Reading
    Scope 3 files, exactly the declared surface
    CI build and Ownership & freshness both green on 751312f
    Existing checks Both still pass unchanged, and ownership still fails in the direction it should
    Read-coupling with #61 Did not fire — #73 leaves lib/i18n.ts byte-identical. Both cards verified this independently

    Out-of-scope findings

    Landing

    Flipping to ready. Not merging — objectos has no merge queue (objectstack-ai/objectstack#9551).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions