Skip to content

fix(cli,i18n): finish the --json truncation sweep, and close #3762 — where the debt was 1 string, not 231 - #3803

Merged
os-zhuang merged 4 commits into
mainfrom
claude/unify-object-ui-types-spec-53x3pt
Jul 28, 2026
Merged

fix(cli,i18n): finish the --json truncation sweep, and close #3762 — where the debt was 1 string, not 231#3803
os-zhuang merged 4 commits into
mainfrom
claude/unify-object-ui-types-spec-53x3pt

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Two follow-ups to #3780, one per commit. Both turned out to be smaller in the work and larger in the finding than the issue text implied.


1 — --json truncation: every command, and the second document it was hiding

#3780 routed three commands through emitJson. The other ~100 emission sites still wrote machine output with console.log, which on a pipe is cut off at one 64 KiB buffer when the command exits right after.

It is invisible to whoever writes it: stdout to a TTY is synchronous, so every interactive run looks perfect while every scripted consumer — the only audience --json has — gets invalid JSON.

The exit does not have to be explicit. oclif ends failing commands with handle()Exit.exit()process.exit() and flushes nothing on that path (flush() runs only on execute()'s success path), so a plain this.exit(1) — or any thrown error — truncates identically. Measured, not assumed:

after the write piped bytes (300 009 expected)
process.exit(1) 65 536
throw 65 536
natural return 300 009
process.exitCode = 1 300 009

73 of the 104 sites had that shape. Even lint was only half fixed by #3780: --eval --json writes a whole corpus report and was still on console.log.

  • All 104 sites go through emitJson; formatOutput's json/yaml branches through the same drain-aware emitText--format yaml truncated too — which makes it async, so 32 call sites now await it.
  • Control flow is untouched. A following this.exit(1) stays; it is simply safe once the buffer has drained.
  • Output bytes are unchanged. Roughly half the sites emitted compact and half indented; { compact: true } preserves whichever each had, so this is a truncation fix and not an output-format change. Unifying that split is worth doing — as its own decision.
  • An ESLint rule (the root script runs --no-inline-config, so there is no per-site opt-out) rejects console.log(JSON.stringify(…)) under packages/cli/src and un-awaited formatOutput. Verified to fire on both — and it earned its place immediately: see CI below.

The second defect underneath it

Draining the write made something visible that truncation had been hiding. Because this.exit(1) throws, a command whose body sits in one try unwinds its inner "report and stop" into the outer catch, which reports again — so os validate --json on a failing config printed two JSON documents, which is neither valid JSON nor valid JSONL. Nine commands had this shape; their catches now re-throw the exit signal (isExitSignal) rather than describing it as a failure.

os validate --json, 900 schema errors, piped:

bytes parses
before 131 072 (exactly two buffers, cut mid-string)
truncation fix alone 1 514 711 (two documents)
both fixes 1 514 648 (one document, 900 errors)

Pinned end to end — a real command, a real pipe, a payload past several buffers — and the integration test was run against origin/main sources to confirm it goes red.

Deliberately not fixed by forcing stdout into blocking mode process-wide, which would be one line and cover every command including future ones: the same binary runs os serve / os dev, and a blocking write to a pipe with a slow reader blocks the event loop — trading truncated JSON for a server that stalls on its own logs.


2 — #3762's remainder: the debt was 1 string, not 231

The open item read: platform-objects is 77 strings short per locale in apps.*/dashboards.*, and its --objects-only extract cannot scaffold them — needs an emit decision (drop --objects-only, or a companion .apps.generated.ts) before any translating.

Measured, the premise did not hold. Of the 77 declared keys per locale, 76 were already translated in the hand-authored <locale>.ts files and had been for months. Exactly one was genuinely missing — apps.studio.navigation.nav_app_builder.label, absent in all four locales including en.

The 231 was a measurement artifact: the config declares SETUP_APP / STUDIO_APP / ACCOUNT_APP and SystemOverviewDashboard, but its translations merge baseline listed only the two generated subtrees, so coverage counted every hand-authored app/dashboard key as untranslated.

Neither proposed emit is right, and the second would have caused damage

The Setup app is a shell of empty group anchors; its ~25 menu entries are contributed at runtime by SETUP_NAV_CONTRIBUTIONS and by capability plugins (ADR-0029 D7). A bundle generated from a static walk of SETUP_APP is therefore structurally incomplete — regenerating over the hand-authored files would have deleted 40 live nav translations per locale. Dropping --objects-only fails differently: kind: 'full' folds all 803 metadata-form keys into <locale>.objects.generated.ts and renames the export the baseline imports.

The split is correct as it stands, and is now written down in the config: objects/metadataForms are generated and gated by the bundle-drift check; apps/dashboards/pages are hand-authored and gated by the coverage ratchet. What was wrong was only that the baseline omitted the hand-authored half.

  • Extract config's translations carries the per-locale assemblers, with objects/metadataForms still pinned to the committed generated files. Safe for the emit — --objects-only writes data.objects alone — and check:i18n stays in sync across all nine packages.
  • nav_app_builder translated in all four locales, wording harvested from the repo's own precedent for "builder" (构建器 / ビルダー / generador).
  • nav_workflows removed from all four: its entry is gone from STUDIO_APP and nothing contributes to that app, so the translation was dead.
  • Ratchet baselined 231 → 0 (repo total 996 → 765), making platform-objects the ninth package where the ratchet is a strict gate. Verified red on a single removed translation, green on restore.
  • A local, CLI-independent parity test walks the statically declared Studio and Account navigation plus the dashboard's widgets and asserts a translation in every locale — and the reverse, that no translation outlives its nav item. Both directions verified to fail before they pass.

An untranslated nav id is invisible in the UI: it falls back to the app's own English label, so a Chinese Studio menu shows one English entry among thirty. That is why this needs a gate and not a one-time sweep.


CI — two failures on the first push, both mine, both fixed

Recorded because the second one is a lesson about the gate, not a hiccup.

ESLint. #3789 landed os validate's four authoring lints on main after this branch forked, and its --json branch was written with console.log(JSON.stringify(…)). The new rule caught it on the merge — precisely the case the rule exists for, the pattern coming back one command at a time. Converted.

Test Core — my own control test. It failed with expected 300017 to be less than 300017: on the runner, the console.log pattern delivered the whole payload. Whether a run truncates is a race between the writer's exit and the reader's drain, and the first version used a 300 KB payload with a continuously-reading execFileP — it truncated at 65536 locally and did not on a runner whose reader kept up. A gate that goes red by machine speed is no better than one that cannot go red, so this was a defect in my test, not a flake to retry.

Rewritten to remove the race rather than widen the tolerance: never read the pipe until the child has exited, so the kernel buffer caps at its capacity and everything still queued in userspace dies with process.exit. Payload raised to 4 MB — ~50× margin over even a 1 MiB pipe-max-size — and only the shortfall is asserted, since the exact byte count legitimately varies (measured 80 640–117 184 delivered).

That technique is valid only for the broken pattern, and the comment now says so: pointing it at emitJson hangs forever, because awaiting the write callback is what the fix does and with no reader the write never completes. Verified rather than assumed — still running after 8 s. That deadlock is the fix working.


Verification

Local, after merging main (c7f4417):

check result
packages/cli vitest 682 passed
packages/platform-objects vitest 239 passed
tsc --noEmit (both packages) clean
root eslint . --no-inline-config clean
check-i18n-bundles 9 packages, all in sync
check-i18n-coverage 12 configs, none new

Every new gate was checked in both directions — red before the fix, green after — rather than only confirming the green.

Not in scope

  • The ~30 human-facing console.log paths in the CLI, which are unaffected, and os serve / os dev logging.
  • Setup's ~25 runtime-contributed nav entries. A static gate for them needs either an objectql dependency in platform-objects (it depends only on spec and metadata-core) or extractor support for navigationContributions — a real follow-up, not something to half-do here.
  • Unifying the CLI's compact-vs-indented --json split, kept out so this stays a pure truncation fix.

claude added 2 commits July 28, 2026 05:44
…econd document it was hiding

#3780 routed three commands through `emitJson`. The other ~100 emission sites
still wrote machine output with `console.log`, which on a PIPE is cut off at one
64 KiB buffer when the command exits right after. It is invisible to whoever
writes it: stdout to a TTY is synchronous, so every interactive run looks
perfect while every scripted consumer — the only audience `--json` has — gets
invalid JSON.

The exit need not be explicit. oclif ends failing commands with `handle()` →
`Exit.exit()` → `process.exit()` and flushes nothing on that path (`flush()`
runs only on `execute()`'s success path), so a plain `this.exit(1)` or any
thrown error truncates identically — 73 of the 104 sites had that shape.
`lint` was only half fixed by #3780: `--eval --json` writes a whole corpus
report and was still on `console.log`.

- All 104 sites go through `emitJson`; `formatOutput`'s json/yaml branches
  through the same drain-aware `emitText` (`--format yaml` truncated too, and
  it is now async — 32 call sites await it).
- Control flow is untouched. A following `this.exit(1)` stays; it is simply
  safe once the buffer has drained.
- Output bytes are unchanged. Roughly half the sites emitted compact and half
  indented; `{ compact: true }` preserves whichever each had, so this is a
  truncation fix and not an output-format change. Unifying that split is worth
  doing as its own decision.
- An ESLint rule (`--no-inline-config`, so no per-site opt-out) rejects
  `console.log(JSON.stringify(…))` under packages/cli/src and un-awaited
  `formatOutput`.

Draining the write exposed a second defect underneath it. Because
`this.exit(1)` THROWS, a command whose body sits in one `try` unwinds its inner
"report and stop" into the outer `catch`, which reports again: `os validate
--json` on a failing config printed TWO JSON documents — neither valid JSON nor
valid JSONL. Truncation had been hiding the second one. Nine commands had this
shape; their catches now re-throw the exit signal (`isExitSignal`) rather than
describing it as a failure.

Measured on `os validate --json`, 900 schema errors, piped:

  before               131072 bytes (exactly two buffers, cut mid-string)  unparseable
  truncation fix only  1514711 bytes (two documents)                       unparseable
  both fixes           1514648 bytes (one document, 900 errors)            parses

Verified both directions rather than only the green one: the integration test
goes red against origin/main sources, and a control case asserts the
`console.log` pattern it replaced genuinely truncates, so the gate cannot
quietly stop testing anything.

Deliberately NOT fixed by forcing stdout into blocking mode process-wide, which
would be one line and cover every command including future ones: the same
binary runs `os serve` / `os dev`, and a blocking write to a pipe with a slow
reader blocks the event loop — trading truncated JSON for a server that stalls
on its own logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW
Closes the rest of #3762, recorded as "platform-objects is 77 strings short per
locale in apps.*/dashboards.*, and its --objects-only extract cannot scaffold
them — needs an emit decision (drop --objects-only, or a companion
.apps.generated.ts) before any translating."

Measured, the premise did not hold. Of the 77 declared keys per locale, 76 were
ALREADY translated in the hand-authored <locale>.ts files and had been for
months. Exactly one was genuinely missing —
apps.studio.navigation.nav_app_builder.label, absent in all four locales
including en. The 231 was a measurement artifact: this config declares
SETUP_APP / STUDIO_APP / ACCOUNT_APP and SystemOverviewDashboard, but its
`translations` merge baseline listed only the two GENERATED subtrees, so
coverage counted every hand-authored app/dashboard key as untranslated.

Neither proposed emit is right, and the second would have caused damage. The
Setup app is a shell of empty group anchors; its ~25 menu entries are
contributed at RUNTIME by SETUP_NAV_CONTRIBUTIONS and by capability plugins
(ADR-0029 D7), so a bundle generated from a static walk of SETUP_APP is
structurally incomplete — regenerating over the hand-authored files would have
DELETED 40 live nav translations per locale. Dropping --objects-only fails
differently: kind:'full' folds all 803 metadata-form keys into
<locale>.objects.generated.ts and renames the export the baseline imports.

The split is correct as it stands, and is now written down in the config:
objects/metadataForms are generated and gated by the bundle-drift check;
apps/dashboards/pages are hand-authored and gated by the coverage ratchet. What
was wrong was only that the baseline omitted the hand-authored half.

- Extract config's `translations` carries the per-locale assemblers, with
  objects/metadataForms still pinned to the committed generated files. Safe for
  the emit — --objects-only writes data.objects alone, so nothing added here
  can reach a generated bundle; check:i18n stays in sync across all nine
  packages.
- nav_app_builder translated in all four locales, wording harvested from the
  repo's own precedent for "builder" (构建器 / ビルダー / generador).
- nav_workflows removed from all four: its entry is gone from STUDIO_APP and
  nothing contributes to that app, so the translation was dead.
- Coverage ratchet baselined 231 -> 0 (repo total 996 -> 765), making
  platform-objects the ninth package where the ratchet is a strict gate.
  Verified red on a single removed translation, green on restore.
- A local, CLI-independent parity test walks the statically declared Studio and
  Account navigation plus the dashboard's widgets and asserts a translation in
  every locale — and the reverse, that no translation outlives its nav item.
  Both directions verified to fail before they pass.

An untranslated nav id is invisible in the UI: it falls back to the app's own
English label, so a Chinese Studio menu shows one English entry among thirty.
That is why this needs a gate and not a one-time sweep.

Setup's ~25 runtime-contributed entries stay out of scope. A static gate for
them needs either an objectql dependency in this package (it depends only on
spec and metadata-core) or extractor support for navigationContributions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 6:15am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/platform-objects.

19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/platform-objects)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli)
  • content/docs/releases/v16.mdx (via @objectstack/cli)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl and removed documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
claude added 2 commits July 28, 2026 06:07
…ate's new lint path

Two CI failures on #3803, both real.

1. ESLint — #3789 landed `os validate`'s four authoring lints on main after this
   branch forked, and its `--json` branch was written with
   `console.log(JSON.stringify(…))`. The new rule caught it on the merge, which
   is exactly the case the rule exists for: the pattern coming back one command
   at a time, written by someone with no reason to suspect it. Converted to
   `await emitJson(…)`.

2. Test Core — the CONTROL case in emit-json-pipe.test.ts failed with
   "expected 300017 to be less than 300017". Whether a run truncates is a RACE
   between the writer's exit and the reader's drain: it truncated at 65536
   locally and delivered the whole payload on a runner whose reader kept up.
   A gate that goes red by machine speed is no better than one that cannot go
   red, so this was my defect, not a flake to retry.

   Rewritten to remove the race rather than widen the tolerance: never read the
   pipe until the child has exited, so the kernel buffer caps at its capacity
   and everything still queued in userspace dies with process.exit. Payload
   raised to 4 MB, ~50x margin over even a 1 MiB pipe-max-size; the exact byte
   count is allowed to vary because only the shortfall is the claim. Measured
   80640-117184 bytes delivered across runs, and stable over repeated runs.

   The technique is valid ONLY for the broken pattern, and the comment now says
   so — pointing it at `emitJson` hangs forever, because awaiting the write
   callback is what the fix does and with no reader the write never completes.
   Verified rather than assumed: still running after 8s. That deadlock is the
   fix working.

Merges origin/main (c7f4417). Full check after the merge: eslint clean, cli tsc
clean, cli 682 tests, platform-objects 239 tests, i18n bundle gate 9/9 in sync,
coverage ratchet none-new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 06:27
@os-zhuang
os-zhuang merged commit 4921a95 into main Jul 28, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/unify-object-ui-types-spec-53x3pt branch July 28, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants