You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Enforce the translation output checklist as an automated check, not a prose list #67
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.
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 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.
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.
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:
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.
{
"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."
]
}
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
The GitHub MCP truncation at a literal <script token, recorded but not filed. I filed it as GitHub MCP truncates an issue body at a literal <script token — the card reads fine on the web and short through the API objectstack#9557 — it is a PM-tooling hazard, and a sharp one: this card's own body reads short through issue_read, cut off exactly where it lists <script, losing the entire Required outcome and Constraints sections. The stored body is intact (confirmed against the rendered page). The dispatch clause telling the dev to read the card on GitHub and check for truncation is the only reason this run did not silently build from a truncated spec.
Activity
os-zhuang commentedon Aug 18, 2026
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:
docs/TRANSLATION.mdonorigin/main, unchanged from the version this card was written against. The card's scope still stands..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.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
os-zhuang commentedon Aug 18, 2026
Claim: PM loop round 3
Session:
session_01CJPxtTxoxTUnjNdTbiEaRaBranch:
claude/issue-67-translation-output-validatorsWorktree:
objectos-issue-67Domain: 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.mdContainer & model: M,
mode:subagent,model: opusClause-②: 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 editapps/docs/lib/i18n.ts, which the scripts in this card's file surface parse with a regex overlanguages: [...]. 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
os-zhuang commentedon Aug 18, 2026
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.ymlonmainruns the freshness gate asnode ... | tee -a "$GITHUB_STEP_SUMMARY". The default shell for arun:step isbash -e {0}— no pipefail — so the step takestee's exit status: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 —
unstampedandorphan— 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:
mainhas 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
os-zhuang commentedon Aug 18, 2026
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:
origin/mainwas stilla132376at push time andapps/docs/lib/i18n.tsis unchanged, so this PR landed first. Every docs page throws React hydration error #418 in the browser console #61 lands second and must re-run these three checks — the locale list is still parsed fromi18n.ts, never hardcoded.GUIDE_REVdeliberately not bumped. This PR changes how the guide is enforced, not what to translate; bumping it would mark all 256 translations for retranslation. Noted as a decision on 91 translation fidelity findings on main that the freshness gate passes as current #75 for whoever runs the pass that clears the debt.--force.Generated by Claude Code
os-zhuang commentedon Aug 18, 2026
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:
shell: bashis 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: bashis 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.mdxline 27 advertisesObjectOS version: 10.2.0; the English source line 23 says14.7.0— inside a fenced code block, in a support-ticket template a customer is meant to copy.source_shaisf6f76ab1…, andsha256sumof the current English source isf6f76ab1…— 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
unsafeon 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
lengthbecause 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/securitycross-reference in five locales oflicense.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
unsafeblocking corpus-wide is right because a diff-scoped security rule is defeated by ordering.Not bumping
GUIDE_REVis also correct — this changes how the guide is enforced, not what to translate; bumping it would mark all 256 files for retranslation.Other checks
buildandOwnership & freshnessboth green on751312flib/i18n.tsbyte-identical. Both cards verified this independentlyOut-of-scope findings
main. Correctly not fixed here: translations are generated artifacts and the ownership check would reject a PR touching them.<scripttoken, recorded but not filed. I filed it as GitHub MCP truncates an issue body at a literal<scripttoken — the card reads fine on the web and short through the API objectstack#9557 — it is a PM-tooling hazard, and a sharp one: this card's own body reads short throughissue_read, cut off exactly where it lists<script, losing the entire Required outcome and Constraints sections. The stored body is intact (confirmed against the rendered page). The dispatch clause telling the dev to read the card on GitHub and check for truncation is the only reason this run did not silently build from a truncated spec.Landing
Flipping to ready. Not merging — objectos has no merge queue (objectstack-ai/objectstack#9551).
Generated by Claude Code