fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them - #14651

Merged
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families
Sep 2, 2026
Merged

fix(cli): walk bulkActions, validation messages and datasets in i18n extract, so the coverage ratchet can see them#14651
os-trump merged 9 commits into
mainfrom
claude/issue-14376-i18n-extract-three-families

Conversation

@os-trump

@os-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14376

#14253 gave three authored display surfaces their first bundle keys and a
resolver for each. The extractor walked none of them, and that costs twice: os i18n extract scaffolded nothing, and check:i18n-coverage — which measures
against exactly what collectExpectedEntries produces — could not see the
families at all. A gate that cannot fail on a population is counted as if it
could.

What changed, per family

Key shapes are taken from the resolver and the schema on disk, not from the
report.

1. Bulk actions — objects.OBJECT._views.VIEW.bulkActions.DEF.*

pushBulkActionDefs, called from pushViewEntries, so a def's keys are emitted
under the same_views root the view's own label / description use.
That is not tidiness: translateView overlays the defs with
viewTranslationKey(view, objectName) — the bare _views key — so deriving the
key a second time is the #5164 defect one surface over. It reaches both authoring
carriers (obj.listViews.VIEW and a defineView() container's list /
listViews), and for a container's default list the key is whatever
defaultListViewKey says the runtime registry calls it (the showcase project
container resolves to default, which is what the emitted keys show).

Emitted: label, confirmText, confirmLabel, params.PARAM.{label,help,placeholder}.

Three exclusions, each measured against BulkActionDefSchema /
BulkActionParamTranslationSchema rather than assumed:

excludedwhy
successMessagea def declares none — the run reports a per-record outcome summary the console words from its own catalog
descriptiona def declares none either; the sentence above the affected-record summary isconfirmText
params.PARAM.optionsthe translation face carries guidance against it instead of a key, so scaffolding it would write keys .strict() rejects

⚠️help, not helpText. A bulk param spells its hint help
(BulkActionParamSchema); an ACTION param spells the same idea helpText. The
pin asserts both directions — the key is emitted, and no emitted key ends in
.helpText.

Read from the AUTHORED address.translateView reads
config.bulkActionDefs because a servedViewItem nests the whole
ListViewSchema under config; this walker is handed the authored stack config,
where the defs sit on the list view itself — the same authored addresses the rest
of the file reads (view.list.data.object, obj.listViews). Accepting the
served spelling too would be a tolerant alias for a shape this walk is never
given.

2. Validation messages — objects.OBJECT._validations.RULE.message

pushValidationMessages, from the object walk. The address is the one
objectValidationMessageKey spells and the ObjectQL rule evaluator resolves
through the engine's existing i18nService channel (authoredRuleMessage).

  • message only. A rule's label is its entry in the admin listing and
    description is the maintainer's note; neither reaches a rejected caller, and
    the schema carries guidance against both rather than a slot.
  • A conditional rule contributes no key of its own, and recursion goes to
    its then / otherwise. checkConditional evaluates when and returns
    evaluateRule(branch, …) — the branch supplies the violation the caller sees,
    so the wrapper's message can never be shown. Nested conditionals are followed
    (depth-capped at 10; the schema is recursive with no declared bound and this
    walker is handed hand-authored TypeScript).
  • active: false still emits. It is a toggle on a surface that exists, not the
    absence of one, and no other family in this walker consults a runtime toggle.

3. Datasets — datasets.NAME.*

walkDatasets, a top-level pass beside dashboards. Emitted: label,
description, dimensions.DIM.label, measures.MEASURE.label — matching
lookupDatasetAttr and lookupDatasetMemberLabel, with translateDataset
registered in METADATA_DOCUMENT_TRANSLATORS.

Top level rather than under dashboards because a dataset is the ONE definition
every presentation binds to by reference (ADR-0021 D1): the same measure is drawn
by N widgets across M dashboards, and a dataset no dashboard references would
otherwise be unaddressable.

Every key here uses pushOptional, not pushDerived: these four are
I18nLabelSchema at the authoring site, so a value may already be an inline
{ en, 'zh-CN' } map (#5728) — not source text to scaffold from — and no
renderer fallback is measured for a member that declares no label at all, so
there is no reader-visible string to seed one from. That is the same posture
translateDataset takes: it writes only where the bundle answered.

datasets gets its own coverage bucket, so a gap reports as
i18n/missing-dataset; bulk-action copy reports under view and a rule message
under object, the buckets whose namespace each key lives in. The bucket addition
forced the three-line taxonomy edit in i18n-coverage.ts (CoverageIssue['source'],
COVERAGE_SOURCE, SOURCE_NOUN) — COVERAGE_SOURCE is an exhaustive Record keyed by
ExpectedEntry['source'], so the compiler, not judgement, fixed that shape.

Red-first evidence, per family

One emitter removed at a time from packages/cli/src/utils/i18n-extract.ts, each
mutation proven on disk before measuring (anchor count 1 → 0, marker count 1) and
each restored from HEAD with a byte-identity check
(git hash-object == 13dc74a6d41d71dc49d58ca75a2767de9bd9c259, git diff HEAD
empty). No rebuild leg is needed and none is claimed: the pins import the subject
by relative source path (../src/utils/i18n-extract), not through the
package's exportsdist, so the mutation takes effect directly.

familyemitter removedpinexitfirst assertions
bulk actionspushBulkActionDefs(out, root, view, objectName);i18n-bulk-action-coverage.test.ts1 (9 failed / 8 passed of 17)expected [] to deeply equal [ …(9) ]; expected undefined to be 'Set Labels'; expected undefined to be 'Archive Selected'
validation messagespushValidationMessages(out, objectName, obj.validations, 0);i18n-validation-message-coverage.test.ts1expected [] to deeply equal [ …(2) ]; expected undefined to be 'Discount cannot exceed 40% without an…'; expected 0 to be greater than 0
datasetswalkDatasets(config, out);i18n-dataset-coverage.test.ts1expected [] to deeply equal [ …(6) ]; expected undefined to be 'Tasks'; expected [] to include 'datasets.task_metrics.description'

All three green with the emitters in place: Test Files 3 passed (3) · Tests 45 passed (45).

Baseline movement — every unit accounted for

scripts/i18n-coverage-baseline.json moved on exactly three lines, all example
apps. Platform bundles moved zero, as the card measured: check:i18n reports
all nine packages that own an i18n-extract.config.ts still in sync, because
none of them authors a bulkActionDefs, a validations[].message or a dataset.

configbeforeafterΔbulkActions_validationsdatasets
examples/app-crm/objectstack.config.ts89102+13058
examples/app-showcase/objectstack.config.ts393443+5018032
examples/app-todo/objectstack.config.ts120146+260422
examples/app-multi-package/objectstack.config.ts000000
the nine i18n-extract.config.ts packages000000

The check that makes this new coverage rather than a loosened gate, re-made
on the merged tree that now carries both #14517 and #14709, and this time
anchored to a number nobody on this branch chose. The before column is not a
recollection: it is what main's own committed baseline says today, read
from git show origin/main:scripts/i18n-coverage-baseline.json — 89 / 393 /
120 / 0. Three facts, each measured:

  1. The pre-existing half re-measured on this tree is 89 / 393 / 120 / 0
    equal to main's enforced numbers, config for config.
  2. It is identical member for member, not merely in total: the set of
    findings whose key shape lies outside the three new families is byte-for-byte
    the same set as before the merge (89, 393 and 120 paths, diff empty).
  3. Δ equals the sum of the three new families exactly, for every config
    (89+0+5+8 = 102, 393+18+0+32 = 443, 120+0+4+22 = 146, 0 = 0).

So nothing already gated was dropped, and every unit of the difference between
main's baseline and this branch's is a member of one of the three families
this PR teaches the walk to visit — an authored English string with no
translation for a locale that example claims to support. The three new families
also contribute no duplicate paths at all; every repeated path in these
reports (29 in showcase, 40 in todo, 1 in crm) is in the pre-existing
_actions half and is main's number, not this PR's.

Where the movement comes from, by file:

  • app-crm (zh-CN) — src/objects/lead.object.ts (lead_status_transitions,
    lead_score_range) and src/objects/opportunity.object.ts (discount_cap,
    opp_close_date_not_past, opp_stage_transitions); src/datasets/opportunity.dataset.ts
    (opportunity_metrics label + description, 3 dimensions, 3 measures).
  • app-showcase (zh-CN) — src/ui/views/project.view.ts, whose container
    default list carries six defs (set_labels, assign_team, reassign_account,
    reschedule, relabel_ops, purge_restricted: labels, confirm text and five
    param labels = 17) plus src/ui/views/field-zoo.view.ts
    (inline_bulk_defs.showcase_zoo_visible_string.label = 1);
    src/ui/datasets/{chart-gallery,revenue-pulse}.dataset.ts (4 datasets, 32
    keys). Rule messages contribute nothing here any more: all eleven
    addressable _validations keys — four on showcase_project, six of
    showcase_account's seven declared names, one on showcase_task — are now
    translated, four by fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses #14517 and seven by fix(showcase): put the remaining eight authored validation messages on the translation channel #14709, which is the whole of the
    movement recorded below.
  • app-todo (zh-CNandja-JP, hence ×2 per key) —
    src/objects/task.object.ts (completed_date_required,
    recurrence_fields_required = 2 keys × 2 locales) and
    src/datasets/task.dataset.ts (task_metrics: 11 keys × 2 locales).

Translating those strings is the examples' own work, not this PR's: the ratchet's
contract is that the debt is frozen and cannot grow, and freezing a newly VISIBLE
family is what ratcheting it in means here.

Re-derived twice while this PR waited — 454 → 450 → 443

This branch's committed ratchet holds counts only this branch can compute.
On main the walk does not exist yet, so a PR that moves one of these keys moves
the number without any gate on main being able to see that it did. It has now
happened twice, and both times the gate named the movement and its direction
itself rather than a reviewer noticing:

roundwhat main broughtshowcasethe gate's own line
first#14517 — zh-CN for four showcase_project rule messages454 → 450untranslated declared strings improved 454 → 450 — ratchet DOWN
this one#14709 — the remaining eight authored validation messages put on the translation channel450 → 443untranslated declared strings improved 450 → 443 — ratchet DOWN

Both re-derived with the repo's own tooling
(node scripts/check-i18n-coverage.mjs --update), never by hand. Every other
line of the baseline is byte-identical, and that is measured rather than assumed:
no commit in the merge range touches examples/app-crm, examples/app-todo,
examples/app-multi-package or any of the nine packages that own an
i18n-extract.config.ts. #14707's translateAction overlay landed in the same
range and cannot move a coverage count either —
packages/cli/src/utils/i18n-coverage.ts imports collectExpectedEntries and
two types, and no runtime resolver at all.

The -7, attributed by set difference

Measured, not inferred from the total. Showcase was linted on this tree with
#14709's three source files reverted to this branch's pre-merge tip
(git checkout ce637132fd -- …), then linted again with the tree restored. Each
leg is proved on disk by blob hash rather than by an exit code: after the
mutation each file's git hash-object equals the tip's blob (13347c4d01,
e9af404cbf, 86af31d1eb), after the restore each equals HEAD's
(93268bbf25, e8f8b78802, 8e9a838829), and git diff HEAD is empty. The
comparison is a multiset difference over the i18n/ population the gate
counts, so a path that appears twice cannot hide a move: 7 dropped, 0 added,
net -7.

dropped finding (translations.zh-CN.objects.…)object
_validations.account_lifecycle.messageshowcase_account
_validations.tax_id_format.messageshowcase_account
_validations.billing_email_format.messageshowcase_account
_validations.support_config_shape.messageshowcase_account
_validations.churn_reason_present.messageshowcase_account
_validations.churn_reason_absent.messageshowcase_account
_validations.task_status_flow.messageshowcase_task

Seven, not the eight #14709 translated — and the shortfall is this walker's
documented exclusion, not a miss.
showcase_account declares seven rule names
and #14709 gave all seven a zh-CN entry, but churn_reason_consistency is a
conditional wrapper, and the walk emits no key for one on purpose:
checkConditional renders the matching BRANCH's message, so the wrapper's own
sentence is structurally unreachable. Six of account's seven are addressable,
plus showcase_task.task_status_flow, which is 7. The measured drop and the
exclusion rule agree — which is the property worth checking here, and it is the
same rule stated under family 2 above.

Two independent constructions of the "before" state agree byte for byte: the
counterfactual described above, and the previous round's real measurement taken
on ce637132fd — 450 findings each, identical path sets.

The family this PR makes visible is now fully translated on showcase: 7 → 0.
That is the ratchet working rather than a hole opening. Showcase's _validations
contribution is frozen at zero, so the next authored rule message that ships
there without a translation reds the gate on the PR that adds it. app-crm (5)
and app-todo (4) still carry theirs, frozen at those numbers.

#14517 and #14709 are the first author-side consumers of the key shape this PR
scaffolds.
objects.OBJECT._validations.RULE.message was declared by #14253
with a reader in the same change; these two PRs are the first uses of it from the
authoring side, and a zh-CN caller's rejected write now gets the author's own
sentence in Chinese instead of English beside a Chinese built-in message. The
coverage gate can see that transition — untranslated to translated — only
because this branch teaches the walk to visit the family. Before it, those eleven
keys contributed nothing to the ratchet in either state.

The derivability reading (recorded, not built)

The triage asked whoever did the fourth manual walk to record what they learned
about derivability while inside the file. Measured, not speculated:

The extractor is NOT derivable from the resolvers, and the reason is
structural rather than effort.
The two enumerations answer different questions
over different inputs:

  1. Different vocabulary. A resolver is handed one already-identified
    document and reads the served shape; the walker is handed a whole stack
    config and reads the authored shape. translateView reads
    config.bulkActionDefs because a served ViewItem nests the ListView under
    config; the authored config has it on the list view itself. A mechanical
    derivation from the resolver's read path would emit against an address the
    walk never sees.
  2. Identity is the walker's own problem.viewTranslationKey(view, …) needs
    view.name — already resolved. The walker has to derive it first, through
    expandViewContainer / defaultListViewKey, including the case where a
    container's default list collapses into a listViews entry and must emit no
    key of its own. Nothing in any resolver knows that.
  3. The sourceValue / inline split does not exist resolver-side at all.
    inline decides whether coverage may DEMAND a translation, and it turns on
    whether the author wrote the literal or the renderer derives a fallback —
    def.label ?? formatActionLabel(def.name), param.label ?? param.name, both
    of which live in objectui, another repo. A resolver never sees an
    untranslated render.
  4. The deliberate exclusions live in a third place. That per-param options
    must not be scaffolded comes from guidance in translation.zod.ts; that a
    conditional wrapper's message must not be comes from checkConditional in
    objectql. Neither is visible from a resolver.

What IS derivable, today, with no restructuring: the key FACE — which leaf
keys exist under each group — is already machine-readable in
packages/spec/src/system/translation.zod.ts (strictObject, with guidance
naming every deliberately-excluded neighbour). That is the half that drifted
here: three groups landed in the schema with a reader each, and the walk produced
zero keys for them, with every gate green.

⇒ The fileable construct is not "derive the extractor from the resolvers" but a
parity gate: walk TranslationDataSchema's declared leaf keys and assert
each is produced by collectExpectedEntries for some fixture, or is listed in an
explicit, shrink-only exemption ledger. It needs only two things that already
exist and are already pure — the schema and the walker — so it lands in one new
file, scripts/check-i18n-walk-parity.mjs, wired as pnpm check:i18n-walk-parity
beside the two gates this PR reports. It would have failed the day #14253 landed,
which is the property the three previous instances of this shape all lacked. Full
derivation would additionally need a per-group adapter declaring the three things
the schema cannot carry (authoring locator, identity function, seed/inline rule)
— strictly more work, for the half that has NOT been the one drifting.

Verification

Re-run at head fee694e4d6, on the tree with origin/main merged at
4d0d9445a8 — the merge brought #14709, #14707, #14711 and #14714. The gate
union and the repo lint were run after the final commit, on that head.

stepcommandresult
buildpnpm --workspace-concurrency=2 buildexit 0 — re-run on this tree
the three pinsvitest run --maxWorkers=2 test/i18n-{bulk-action,validation-message,dataset}-coverage.test.tsexit 0 — Test Files 3 passed (3), Tests 45 passed (45)
coverage ratchet, BEFORE re-derivingpnpm check:i18n-coverageexit 1 — examples/app-showcase/objectstack.config.ts: untranslated declared strings improved 450 → 443 — ratchet DOWN: run node scripts/check-i18n-coverage.mjs --update and commit the baseline.
re-derivenode scripts/check-i18n-coverage.mjs --updatei18n coverage baseline updated: 13 config(s). — one line moved, showcase 450 → 443
coverage ratchet, AFTERpnpm check:i18n-coverageexit 0 — check-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).
bundle gatepnpm check:i18nexit 0 — check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
gate uniondispatch-gates --repo objectstack-ai/objectstack --commands, then --ran44 derived, all 44 run, no staleness warning; ✓ dispatch-gates --ran: 44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED.
repo lintpnpm lintexit 0 — whole repo, eslint . --no-inline-config, no output

Carried from the previous head ce637132fd and not re-run on this one, said
plainly rather than implied: pnpm --filter @objectstack/cli typecheck (exit 0),
the 16-file i18n suite (191 tests) and the package's unit project (161 files /
2119 tests). Nothing in packages/ changed between the two heads on this
branch's side — the only commit is one number in
scripts/i18n-coverage-baseline.json — and the three pins, which are the tests
this card adds, were re-run here.

Two of the union's 44 gates are NOT MEASURED, in their own words, and neither
is a red.
node scripts/check-test-completeness.mjs exits 3:
"PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log
was named"; its own text adds "Nothing was measured … ⛔ It is NOT a finding".
node scripts/pm/check-half-states.mjs exits 3: "PREREQUISITE NOT MET — the
transport authenticates but repo-scoped reads are refused", with
GET /rate_limit answering 200 (15000 left) while
GET /repos/objectstack-ai/objectstack answers 403 with no x-ratelimit-*
headers at all. The other 42 ran and all 42 exited 0.

Every exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), and
each verdict quoted above is the gate's own printed line, not a bare $?. Heavy
runs went through scripts/pm/os-verify-lock.sh; its closing line for this round
is VERDICT command-exit 0 · held the lock 1048s (17m28s) · waited 2s.

NOT MEASURED, stated rather than implied:packages/cli/tsconfig.json has
include: ["src"], so pnpm --filter @objectstack/cli typecheck does not
type-check test/ — its exit 0 says nothing about the three new pin files, and
packages/cli is not wired into check:test-typecheck. The pins are covered by
running them, not by tsc.

One red this found, and what it was

pnpm check:type-check-debt went red on the first union run: @objectstack/cli
TEST_DEBT recorded 144 raw tsc errors, --re-measure reported 171 (+27)
the three new pins' own errors. Cause is the class that ledger entry already
names twice in its own note: under moduleResolution: NodeNext an
extension-less relative import does not resolve, so every symbol it names
becomes any and the file grows one TS2835 plus a TS7006 per untyped binding.
The pins imported '../src/utils/i18n-extract' and '../src/utils/i18n-coverage'
without .js.

One extension per file, and the layer is back to its frozen number:
check-type-check-coverage --re-measure: OK — 22 ledger entr(ies) re-measured in 483.6s, 755 raw tsc error(s) total, none above its recorded number. The very
same one-import repair collapsed 35 → 0 and 7 → 0 in this file family before,
and platform-page-i18n-parity.test.ts already carries it — worth noting that
this cascade recurs in the same package every time it gains test files, which
is its own small instance of the pattern this card is about.

Changeset level

minor, matching the direct precedent: the flow/screen bucket
(.changeset/cli-i18n-flow-screen-bucket.md) is the same shape — a family the
resolver already read, given its first walk plus a new coverage bucket — and
shipped minor. The bug label describes why this exists, not what ships: what
ships is os i18n extract writing keys it never wrote and os lint gaining the
i18n/missing-dataset rule id, both of which a consumer can observe.

Deviations

  • Scope named packages/cli/src/utils/i18n-extract.ts; the change also touches
    packages/cli/src/utils/i18n-coverage.ts by three lines. COVERAGE_SOURCE is
    an exhaustive Record keyed by ExpectedEntry['source'], so
    adding the dataset kind does not compile without it — same package, same
    defect class, shape fixed by the existing type.
  • Nothing in packages/spec, nothing in scripts/check-i18n-coverage.mjs, no
    platform bundle, no content/docs/releases/**.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

… extract
#14253 gave three authored display surfaces their first bundle keys and a
resolver for each; the extractor walked none of them, so `os i18n extract`
scaffolded nothing and `check:i18n-coverage` — which measures against that
walk — was blind to all three.
- `objects.<o>._views.<v>.bulkActions.<def>.*` from a list view's authored
`bulkActionDefs[]`, keyed under the same `_views` root the view's own label
uses (`translateView` overlays it with `viewTranslationKey`).
- `objects.<o>._validations.<rule>.message`, with a `conditional` wrapper
contributing no key of its own — its branch supplies the violation.
- `datasets.<n>.{label,description,dimensions.<d>.label,measures.<m>.label}`,
top level because a dataset is bound by reference from N presentations.
`dataset` gets its own coverage bucket (`i18n/missing-dataset`); bulk-action
copy reports under `view` and a rule message under `object`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The three families the extract walk now visits are authored by the example
apps and untranslated for the locales those apps claim to support, so the
ratchet moves — and only there. Every unit is accounted for:
examples/app-crm 89 -> 102 (+13: 5 _validations, 8 datasets)
examples/app-showcase 393 -> 454 (+61: 18 bulkActions, 11 _validations,
32 datasets)
examples/app-todo 120 -> 146 (+26: 4 _validations, 22 datasets)
Every other line is byte-identical, including app-multi-package and all nine
packages that own an `i18n-extract.config.ts` — no package authors a
bulkActionDef, a validations[].message or a dataset, so platform bundles move
zero and `check:i18n` stays in sync across all nine.
The pre-existing per-config counts are unchanged (89 / 393 / 120 / 0 measured
again after the change), so nothing already gated was dropped: the movement is
new coverage, not a loosened gate.
Also corrects the bulk-action pin's expected key list: `pushOptional` records
an optional key with no seed, exactly as `_views.<v>.description` does, so a
def that authors no confirm copy still contributes those two keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
`check:type-check-debt` measured @objectstack/cli's TEST_DEBT at 171 against a
recorded 144 (+27) — the pins' own errors. Cause is the one the ledger entry
already names twice: under `moduleResolution: NodeNext` an extension-less
relative import does not resolve, so every symbol it names becomes `any` and
each file grows a TS2835 plus a TS7006 per untyped binding.
One extension per file, and the layer is back to its frozen number:
`--re-measure` now reports 22 entries re-measured, none above its recorded
number. The same one-import repair collapsed 35 -> 0 and 7 -> 0 in this very
file family before; `platform-page-i18n-parity.test.ts` already carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actionsBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ai/connect-mcp.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/deployment/validating-metadata.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/kernel/i18n-standard.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/actions.mdx(via confirmLabel (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/protocol/objectui/record-alert.mdx(via confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/actions.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))
  • content/docs/ui/views.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via bulkActions (literal, a string literal in pushBulkActionDefs), confirmText (literal, a string literal in pushBulkActionDefs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb — the merge of head fee694e4d6244c7841ccd57f15e5b44211002e5c into base 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6, 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 db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb && git checkout db79e1b5b16f61ae1fe1a93a440e4e73b14a48fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 fee694e4d6244c7841ccd57f15e5b44211002e5c && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff fee694e4d6244c7841ccd57f15e5b44211002e5c
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@os-trump
os-trump marked this pull request as draft September 2, 2026 20:26
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33678366780 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 'SIGKILL' to be null
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 19 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Test Core (1/6) went red on the patched head — it is the #14648 flake, not this PR. ⛔ Not arming, and ⛔ no patch round to the dev.domain:cli seat (post #6024), session session_016yfqQh2dBgPAymYd7xipza, round R65.

Read from the job log directly rather than inferred from the shard name:

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
AssertionError: expected 'SIGKILL' to be null
❯ test/run-dev-unbuilt-workspace.e2e.test.ts:317:27
Test Files 1 failed | 234 passed (235)
Tests 1 failed | 2709 passed (2710)

One test out of 2710, in a file this PR does not touch, asserting a process signal on a 40-second wall-clock cap. This PR's diff is packages/cli/src/utils/i18n-{extract,coverage}.ts, three i18n pins, a changeset and one baseline line — nothing that can reach an os dev shim's lifetime. check-test-completeness on the same job reports OK (… 2710 test(s) declared and all accounted for), so nothing was skipped or lost.

⇒ The patch round is not implicated. The re-derived baseline (showcase 454 → 450) is green: check:i18n-coverage and check:i18n both passed locally on the merged tree, and the three new pins passed here as part of the 234.

What happens next, and why not a re-run

This is the sixth independent PR that test/run-dev-unbuilt-workspace.e2e.test.ts has reddened in 24 hours, and the fix is already reviewed and armed: PR #14715 (#14648) replaces the fixed 40 s constant with a ceiling derived per run, after measuring the elastic term at 1.0 s idle vs 6.9 s contended. Its ablation shows the derived ceiling still fires on a real hang, so detection is not traded away.

⛔ Re-running this shard now would burn a full CI lap on a failure whose cause is known and whose repair is one merge away. So this PR stays flipped but unarmed until #14715 lands, then goes back to the queue — the same treatment PR #14631 is getting, and for the same reason.

Nothing here is a re-run to make a red go away, and nothing about the flake's assertion is being waved off: it is a genuine defect in the instrument, which is why it has its own card and its own fix rather than a re-run.


Generated by Claude Code

…#14709#14709 put the remaining eight authored validation messages on the showcase's
translation channel, moving the `objects.OBJECT._validations.RULE.message`
population this branch is the first to walk. Re-derived with the repo's own
tooling (`node scripts/check-i18n-coverage.mjs --update`), never by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
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

Development

Successfully merging this pull request may close these issues.

i18n-extract scaffolds none of the three key families #14253 adds, so check:i18n-coverage is blind to them

2 participants

@os-trump@claude