Skip to content

fix(cli): match i18n-extract's per-component walk to translatePage's - #13215

Queued
os-trump wants to merge 5 commits into
mainfrom
claude/issue-13109-i18n-extract-nested-children
Queued

fix(cli): match i18n-extract's per-component walk to translatePage's#13215
os-trump wants to merge 5 commits into
mainfrom
claude/issue-13109-i18n-extract-nested-children

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#13109

translatePage descends a container's declared properties.children recursively, so
pages.PAGE.components.ID.KEY resolves for a nested component id. collectExpectedEntries
in packages/cli/src/utils/i18n-extract.ts still iterated regions[].components[] and
stopped — the extractor OMITTED keys the resolver reads, which is the second half of the
failure pair PAGE_COMPONENT_COPY_KEYS' own JSDoc names. The KEY list is shared and both
sides import it; the WALK was written twice.

Premise, verified against the tree before writing anything

Read from packages/spec/src/system/i18n-resolver.ts and packages/lint/src/page-walk.ts
at origin/main33184fd, not from the card:

translatePage (the resolver)walkPageComponents (@objectstack/lint)
rootsregions[].components[]regions[].components[]and slots.SLOT
descentproperties.children onlyproperties.children, properties.items[].children, properties.body, properties.footer
source-authored pages (kind: html/react/jsx)walked like any other pageskipped
depthcapped at 32, cycle-guardeduncapped, no cycle guard
repeated idregion level wins outright; among nested, document ordernot applicable

The dispatched premise HOLDS, with one correction: walkPageComponents is wider on four
axes, not three (properties.body and properties.footer were not named), and it is also
narrower on one — it skips source-authored pages, which translatePage does not.

Why the walk is matched by hand rather than reused

Reuse was the cheaper edit and the file already has the precedent one pass down (the object
sections pass, which reuses walkPageComponents because a duplicated walk "produced a dead
rule once already"). It is refused here on measurement, not taste:

  • emitting its four extra shapes would offer keys the resolver ignores — the other half
    of the same documented pair, which is exactly what this card warns against;
  • adopting its source-authored page skip would drop region-level keys that resolve today,
    a regression at the level the extractor already covered.

So the new pass mirrors translatePage exactly: roots regions[].components[], descent
properties.children only, the same depth cap, the same ancestor cycle guard, and the same
ruled collision arbitration. Two consequences worth naming:

  • a nestedpage:header IS offered. The page-name route (pages.PAGE.title) addresses
    THE page's header and stops at region level, so a nested one is reachable by the id route
    only. Region-level page:header stays skipped, as before.
  • a region-level page:header's id still blocks a nested namesake even though it emits
    nothing, because the resolver counts it as region-level. Missing that would offer a key
    the resolver ignores.

MAX_NESTED_COMPONENT_DEPTH is deliberately not exported by packages/spec, so the CLI
mirrors the number. The deep-chain test below is what keeps the mirror honest; a shared walk
exported from packages/spec is the durable fix and is reported to the PM as out of scope
here (it is a packages/spec edit).

The test can fail for the reason this card exists

The existing guard in packages/cli/test/platform-page-i18n-parity.test.ts compares extractor
output against the shipped bundle, so it only ever sees what the extractor already emits — it
is structurally blind to this class. The new block is a differential, not a restatement of
either walk: it runs the real translatePage against a sentinel bundle, asks which components
it ACTUALLY rewrote, and compares that set against the ids the real extractor ACTUALLY offered.
It fails in both directions.

Reverse verification, both legs run from the committed state, each mutation and restore proven
on disk by blob hash (git hash-object against git rev-parse HEAD:PATH, plus an empty
git diff HEAD), never by an exit code. No rebuild is needed for either leg: the mutated
module is reached by a relative source import (../src/utils/i18n-extract.js), not through a
package exports map into dist/.

  • Narrower (the card's live defect) — restore i18n-extract.ts to the base blob
    b15473bb0d80: 5 of the 6 new tests go red, appliedButNotOffered grows from ['hdr'] to
    six ids.
  • Wider (the pair's other half) — inject a descent into properties.items[].children
    (marker grep-counted on disk, 1 occurrence; blob differs from HEAD): 2 tests go red with
    offeredButIgnored: ['tab_child'].

Restore verified after each leg: on-disk blob back to c920cb2a26aa and git diff HEAD empty.

The coverage denominator, measured — it does not move silently

os i18n coverage shares collectExpectedEntries, so nested copy that counted as neither
translated nor missing now counts. In this repo that is examples/app-showcase, and the ten
newly visible keys are real copy, in the shape the card describes:

  • showcase_command_center.components.cc_k1..cc_k6.label — six KPI blocks inside a
    type: 'flex' container's properties.children;
  • showcase_styling_gallery.components.cta_Free / cta_Solo / cta_Team / cta_Business.label
    four pricing CTA buttons nested two levels deep.

check:i18n-coverage moved 393 -> 403 on that config. The ten are translated in the same
change, so the frozen ratchet baseline is unchanged at 393 and the gate reads
OK (12 config(s), 602 baselined untranslated string(s), none new). The ratchet was not
raised.

Deviations from the dispatched file surface, declared

The dispatch named packages/cli/src/utils/i18n-extract.ts and
packages/cli/test/platform-page-i18n-parity.test.ts. Three more files are touched, each with
its reason:

  1. .changeset/i18n-extract-nested-component-walk.md — required; user-visible CLI behaviour.
  2. examples/app-showcase/src/system/translations/index.ts — the ten strings above. This is
    the growth branch's own prescribed remedy ("translate them"); --update is offered by that
    gate only for the not-baselined and improved branches, and freezing a growth would raise a
    shrink-only ratchet. The bundle already carries a pages.PAGE.components block written for
    exactly this dynamic when #6080 made the surface declared, so the form is pinned by the
    file itself. Serial-constraint note: the PM cleared i18n-extract.ts against all open PR
    heads; this file was not in that probe and this seat cannot run it (repo-scoped REST is 403
    here).
  3. scripts/check-type-check-coverage.mjs — one ledger number, plus a re-tally of its note.
    The new tests made @objectstack/cli's TEST_DEBT drift up by 4 (146 -> 150), because
    this test file's one extension-less relative import made the module any and every callback
    parameter implicitly any. Fixed at the root, the way this ledger's own note records
    #8612 doing twice: adding .js to the import. That took the file to zero and the entry to
    144, so --lower was run and the note re-tallied from the same measurement
    (TS7006 x59, TS2835 x56 across 23 files, TS2339 x24, TS2307 x3, TS18046 x2 = 144, measured
    directly over the 106-file test tree, not decremented). check:type-check-debt now reports
    surplus: none.

Gates

Union re-derived for the ACTUAL change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack after the last commit, and run at 9ac73cd0. Exit codes captured
before any pipe. All green except two that refuse to run and say so themselves — recorded as
NOT MEASURED, not as passes:

  • scripts/pm/check-half-states.mjs — exit 3, PREREQUISITE NOT MET: the container's GitHub
    token is a proxy placeholder (401 on /rate_limit), and the anonymous path is rate-limited.
  • scripts/check-test-completeness.mjs — exit 3, PREREQUISITE NOT MET: it grades a saved
    turbo run test log, which CI tees and passes on every invocation. Unreachable locally.

Green: check:nul-bytes, check:agent-test-spelling, check:bash32-floor,
check:changeset-gate-self-tests, check:cli-command-ids, check:cli-test-child-env,
check:cross-package-test-inputs, check:dual-build-cjs-loads, check:engine-double-contract,
check:entry-guard, check:examples-live-imports, check:i18n, check:i18n-coverage,
check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset,
check:page-declaration-shape, check:parse-guard, check:pm-dispatch-gates,
check:pm-half-states, check:pnpm-filter-targets, check:published-files,
check:query-options-erasure, check:react-page-adapter-contract, check:slot-lookup,
check:test-source-alias, check:type-check-coverage, check:type-check-debt,
check:type-source-resolution, check:watch-hint-literal, check:where-matcher,
check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity,
check-comment-mask-adoption, check-cross-package-test-inputs, check-empty-changeset,
check-keyed-text-bounds, check-plugin-teardown-shape, check-ratchet-remedy-authority,
check-shard-attestation, check-undeclared-dep-imports, docs-audit/check-affected-docs,
docs-audit/check-drift-comment, pm/bare-root-worklist --self-test,
pm/release-rehearsal-clone --self-test.

Tests, all at 9ac73cd0:

  • packages/cli i18n surface — 12 files, 144 tests, all passing. This is a declared
    narrowing
    of the package suite (185 files, ~13 min measured in this package's own
    vitest.config.ts header, which does not fit the container's foreground ceiling under
    contention). The narrowing is bounded by measurement, not guesswork: collectExpectedEntries
    is imported by exactly src/commands/i18n/extract.ts, src/utils/format.ts and
    src/utils/i18n-coverage.ts, and by nine test files — all nine plus three siblings are in
    the twelve above. CI runs the whole farm regardless.
  • packages/clitypecheck — clean, and stated honestly: that program is
    include: ["src"], so it does not read test/** (ledgered TEST_DEBT). Coverage of the
    new test file is the ledger re-measure above, plus a direct tsc over the file: 0 errors.
  • @objectstack/example-showcasetypecheck clean, 26 files / 364 tests passing.

Generated by Claude Code

…anslatePage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…ractor now offers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…r the TEST_DEBT ledger to the measurement
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a51da094b04a4510d220776def16e0405d8045d4packageMentionDocs.

Which tree this was computed on

This run read content/docs from f8972ced0ff0ef5b19fff20a1e46dc4b4e89e5ab — the merge of head 9ac73cd02d9e3cb416e4647adb213b985d286272 into base a51da094b04a4510d220776def16e0405d8045d4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f8972ced0ff0ef5b19fff20a1e46dc4b4e89e5ab && git checkout f8972ced0ff0ef5b19fff20a1e46dc4b4e89e5ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a51da094b04a4510d220776def16e0405d8045d4 9ac73cd02d9e3cb416e4647adb213b985d286272 && git checkout -B drift-repro a51da094b04a4510d220776def16e0405d8045d4 && git merge --no-ff 9ac73cd02d9e3cb416e4647adb213b985d286272
node scripts/docs-audit/affected-docs.mjs --json a51da094b04a4510d220776def16e0405d8045d4

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude