fix(cli): honour --no-metadata-forms whatever --objects-only is set to - #16120
fix(cli): honour --no-metadata-forms whatever --objects-only is set to#16120os-litant wants to merge 2 commits into
--no-metadata-forms whatever --objects-only is set to#16120Conversation
…et to
`os i18n extract --no-metadata-forms` gated only the
`<locale>.metadata-forms.generated.ts` companion. The stack module's renderer
had a third mode, `kind: 'full'`, that serialised the whole `TranslationData`
— the Studio metadata-form baseline included — and `--no-objects-only`
selected it. So the two flags stopped being independent as soon as the second
was passed, in both directions:
* `--no-metadata-forms --no-objects-only` suppressed the companion and wrote
the same keys into `<locale>.objects.generated.ts`. Driven on a one-object,
one-app stack with `i18n.defaultLocale: 'zh-CN'`: 776 leaves emitted, 773
of them the baseline the flag had just switched off. Those 773 are English
— the default locale is filled from the source labels — so a non-English
default locale shipped the platform's English Studio strings inside its own
application bundle.
* `--no-objects-only` alone wrote those 773 keys twice, once per module.
The renderer's three modes are now a partition of one locale's generated
leaves: `'full'` becomes `'stack'` and omits `metadataForms`, so every leaf has
exactly one module it can land in and `--metadata-forms` is the only control
over the baseline. Neither flag's documented meaning changes and no precedence
is invented between them — the overlap was in the emitter. `--json` carries
the same payload the files carry, for the reason its own help gives.
No bundle in this repository moves: all nine extract configs run under the
default `--objects-only`, whose module is byte-for-byte unchanged.
The regression pin spawns the real CLI and takes a group census of the bytes it
wrote. The sibling pin that mirrors the emit rule and checks file NAMES was
green throughout: the file set was right in every combination, and only the
content of one file was wrong.
Fixes #14894
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 683f6210a7fe2897cde22563dd386aa55b1574de && git checkout 683f6210a7fe2897cde22563dd386aa55b1574de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7ffbd65eaac1919e85dcb597c996d1177b54dbb ac8add2e4f42e3d38e2813b7a165e8e0e8111878 && git checkout -B drift-repro f7ffbd65eaac1919e85dcb597c996d1177b54dbb && git merge --no-ff ac8add2e4f42e3d38e2813b7a165e8e0e8111878
node scripts/docs-audit/affected-docs.mjs --json f7ffbd65eaac1919e85dcb597c996d1177b54dbb
|
os-litant
left a comment
There was a problem hiding this comment.
Contract review (clause ②) — PR #16120 / card #14894, at head 5da3bfea1e6
Clause ② — mechanical limb: YES, and not on the unclear-call rule the declaration leans on: a concrete key is REMOVED from a published payload. For the --no-objects-only input class the --json payload's bundles.<locale> loses its metadataForms key (measured: merge-base ["objects","apps","metadataForms"], 776 string leaves → PR head ["objects","apps"], 3 leaves), and the emitted <locale>.objects.generated.ts loses the same group with its export type narrowed to Omit<TranslationData, 'metadataForms'> — a consumer that reads .metadataForms off that module stops compiling (tsc probe: TS2339), while a consumer typed as TranslationData still compiles (same probe, assignment line clean).
Clause ② — conformance limb: YES, as declared: on the file face the --no-objects-only class moves from "baseline inlined into the objects module (and, with the flag on, duplicated in the companion)" to "baseline in the companion only" — measured, both directions, below. One correction to the declaration: on the --json face the same class moves to a THIRD verdict, "baseline nowhere", which no published face had for that class before this diff. That is the blocking item.
Verdict: CHANGES REQUESTED — one blocking item, reproduced below. Submitted as COMMENT because GitHub refuses APPROVE / REQUEST_CHANGES on a same-account PR; the verdict line above is the verdict.
Implemented-by: claude/issue-14894-i18n-extract-no-metadata-forms (mode:subagent dev, per its claim comment)
Reviewed-by: session 01D47qPfEWVPmhguWgBZCi5N — a context-isolated contract-review subagent of the same dispatching session, fed the card, the PR and the diff only. Under references/contract-review.md the dispatching seat must weigh that identity pair itself; this is a measured second opinion, not a self-issued clear.
Blocking item 1 — the --json face drops the metadata-forms leaf class (silent data loss on a documented face)
Reproduction (the PR's own e2e fixture: one object, one app, i18n.defaultLocale: 'zh-CN'; leaves counted structurally by importing each emitted module / parsing the payload and walking string leaves — never the printed count, which is #16121):
tsx packages/cli/bin/run-dev.js i18n extract stack.config.ts --locales=zh-CN --json --no-objects-only
| tree | bundles["zh-CN"] keys |
leaves | metadataFormsCounts["zh-CN"] |
|---|---|---|---|
merge-base 2648774b967 (sources swapped in by blob, 420ac867dfb / 28a77c4739c, verified) |
objects, apps, metadataForms |
776 (2 + 1 + 773) | 773 |
PR head 5da3bfea1e6 (blobs 6899355f09b / e1802bb2788, restored and git diff HEAD empty) |
objects, apps |
3 | 773 |
PR head, same run plus --no-metadata-forms |
objects, apps |
3 | 773 |
The last two rows are byte-identical in bundles: on this face --metadata-forms now controls nothing in either position. The files for the same flags carry 776 leaves across two modules (3 in the stack module, 773 in the companion); the JSON carries 3, and the payload's own metadataFormsCounts reports 773 keys that are in it nowhere.
Why this blocks rather than trails:
- It is a documented face.
content/docs/protocol/kernel/i18n-standard.mdx§ CLI Tools: "Print the skeletons as JSON instead of writing files" (os i18n extract --json), and the flag's own help says the same. Before this diff,--json --no-objects-onlywas the one route by which the baseline skeleton reached the JSON face; after it, there is none. - It falsifies the PR's own invariants. "
--metadata-formsis the only control over the baseline" — on this face it is no control at all. "--jsoncarries the same payload the files carry" — 3 vs 776. The changeset's "Nothing published loses content" — 773 leaves are lost with no replacement in the payload. - It is exactly where the partition claim stops holding. The three kinds partition a locale's leaves only where each kind has a home. On the file face they do (verified below). On the
--jsonface only the'objects'/'stack'cell is emitted; the'metadataForms'cell has no home, and this diff removed the only one it had. - The pin cannot see it. The
--jsoncase ini18n-extract-metadata-forms-flag.e2e.test.tsis driven with--no-metadata-formsonly, where omission is correct; its comment says "the sub-tree the emitter refuses to write must not arrive here" — but with the flag ON the emitter does write that sub-tree (to the companion), and the pin never drives that case.
What closes it: give the third kind a JSON home gated by the SAME emitsMetadataForms(locale) predicate that gates the companion file — a sibling key beside bundles mirroring the companion, or bundles.<locale>.metadataForms restored under the flag; the shape is yours/the maintainer's, the invariant is the PR's own ("the same payload the files carry"). Then extend the --json pin to the flag-ON case (baseline present, > 100 leaves, same instrument as the file pin) beside the existing flag-OFF case, and correct the three sentences in item 2 (PR body, changeset, and the --json comment in extract.ts). Note the default --objects-only never carried the companion on --json either — pre-existing and outside this card, but the same predicate covers it for free.
The partition property — verified, with its boundary stated
- Static.
stackAuthoredSubtreeis a rest-spread minusmetadataForms, so every other group the extractorsetDeeps (objects,apps,pages,dashboards,flows,datasets, …) stays in kind'stack'; kind'metadataForms'renders exactly the complement; kind'objects'rendersdata.objectsalone — a proper subset of'stack', which is the documented meaning of--objects-only(it dropsapps/pages/… by design; pre-existing, #3762). So{stack, metadataForms}is a partition of one locale's generated leaves and'objects'is a sub-selection of one cell; the three are not three disjoint cells and the docblock should not imply they are. - Dynamic, file face,
--no-objects-only(flag ON): stack module 3 leaves (objects2,apps1) + companion 773 = 776 = the extractor's owncounts["zh-CN"]; groups disjoint; no leaf dropped, none twice. Merge-base: 776 + 773 with the same 773 in both files. Broken shape this control would show: a total under 776 (a dropped class) or over (a duplicate). --jsonface: one cell only — blocking item 1.
Reproduction direction — both driven
File face, all four combinations before and after (leaves: objects module / companion): A --no-metadata-forms --no-objects-only 776/– → 3/–; B --no-objects-only 776/773 → 3/773; C --no-metadata-forms 2/– → 2/– (byte-identical); D default 2/773 → 2/773 (byte-identical). --no-objects-only's own meaning (apps in the module) holds in every cell before and after, so the one-directional claim holds on the file face: --no-metadata-forms is the flag that broke when the other joined it, and the reverse combination shows the duplication half, not a broken --no-objects-only. On the --json face the interaction was symmetric all along — --no-metadata-forms was ignored there before (both positions gave 776) and is ignored after (both give 3).
"Nothing in this repository moved" — holds
Flags read the way the gate reads them (flagsFromDocstring): eight docstrings spell --objects-only --no-metadata-forms; platform-objects spells neither and takes both defaults, so its objects module and its companion are on unchanged paths. pnpm check:i18n exit 0 on the PR head (broken shape: any out of date: line across the nine sets).
Changeset level — patch, no banner: acceptable once item 1 is closed; its text is wrong as delivered
'full'→'stack'is internal:renderTranslationModuleis not reachable from@objectstack/cli'sexports(.,./console,./hook-body;src/index.tsdoes not re-exportutils/i18n-extract), and no caller outside the command exists in the repo.- File face: export name kept, type narrows to
Omit<…>that still assigns toTranslationData(probe above) —patchmatches the cited precedent (.changeset/i18n-walk-one-key-one-demand.md). - JSON face as delivered: a removed key with no replacement — a narrowing, and "Nothing published loses content" is false. With a JSON home added it becomes a relocation like the file face and
patchstands; either way the sentence must go.check-changeset-no-majorandcheck-adr-0087-registrationexit 0 as declared (no banner, so no disposition marker is owed); if the maintainer grades the JSON removal breaking instead, the launch-window carrier is a**BREAKING**banner plus one<!-- adr-0087: … -->marker from theCATEGORIESconst —no-migration-prescriptionis the fitting category, since nothing authorable is retired.
The blast-radius narrowing — sound
Re-derived independently (grep over packages/cli/test for i18n-extract|renderTranslationModule|commands/i18n/extract|'i18n', 'extract'|i18n extract|stackAuthoredSubtree|narrowToCommittedSections) → the same 20 files. Indirect reach checked rather than assumed: the changed functions have exactly one caller (the extract command); i18n-coverage.ts imports only the unchanged collectExpectedEntries; no other kind: 'full' / objectsOnly: false caller of the renderer exists in the repo (the one in i18n-extract-emitted-files.test.ts calls the test's own mirror). The command-spawning gates (check:i18n, check:i18n-coverage, check:i18n-walk-parity, check:i18n-stale-fill) exercise it end to end. Ran: 20 files / 301 tests pass (178 s). pnpm --filter @objectstack/cli typecheck (test layer included) exit 0; ESLint on the four changed TS files: 0 errors, 0 warnings.
Gates
Union derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands and asserted against its own count line: 56 command(s) — 27 pnpm, 29 direct node; 56 run, 56 exit 0 on the PR head, exit codes captured before any pipe, after a full workspace build (so check:i18n-coverage and check:dual-build-cjs-loads went straight to a real verdict). The derivation flagged the tree 3 commits behind origin/main with scripts/check-published-files.mjs changed in that range, so that family was re-run on GitHub's merge commit 507920a6a44 (merge diff byte-identical to the PR diff; none of the three main commits touches packages/cli or the extractor): exit 0. The Artifact rosters block was run separately: 37 families, 34 exit 0, three not a pass (listed below).
NOT MEASURED — by name, each with its own verdict line
- Full 136-file
@objectstack/clisuite — NOT MEASURED; verdict: the 20-file narrowing is sound (derivation above), so no verdict is withheld on it; CI runs the farm. node scripts/check-partof-closing-keyword.mjs— exit 2 NOT WIRED (no PR context locally); verdict: none;pnpm check:partof-closing-keywordexit 0 is--self-testonly (#16030) and grades the checker, not this PR.node scripts/check-single-claim-paths.mjs— exit 2 NOT WIRED; verdict: none; same--self-test-only caveat for its pnpm spelling.pnpm --filter @objectstack/spec run check:react-declaration-parity— exit 1 "this gate did NOT run" (needs a browser-producedsdui.manifest.json); verdict: none; the diff touches no React blocks.- The six workflow-valued families (
check-cross-package-test-inputs --union-into …,check-shard-attestation×3,check-test-completeness×2) — NOT MEASURED; verdict: none; they take a value that exists only in a CI run. - The always-runs tail beyond ESLint on the four changed files — NOT MEASURED; verdict: none.
Non-blocking observations
Wrote … (776 keys)is printed for a file holding 3 leaves — #16121, pre-existing, unchanged here; it is why every number above was counted off the bytes.- Unmeasured, out of this card, for filing: under
--no-objects-only --source-hashes,committedSourceHashesnarrows the provenance table to theobjectsandmetadataFormssections only, soapps.*(and other stack-authored) leaves that ARE committed in the stack module get no provenance record. Pre-dates this diff (#12559's narrowing); worth a card now that the stack module is the declared home of those leaves.
Generated by Claude Code
Contract review (clause ②) — PASS on content; NOT LANDABLE at this head — PR #16120 at
|
| # | claim | reading | verdict |
|---|---|---|---|
| 1 | The defect was in the emitter: kind: 'full' gave the baseline a second home; the two flags' documented meanings never overlapped |
Card + control reproduce; the driven table (776 → 3 leaves; 773 English baseline leaves in a zh-CN bundle) confirms both halves, including the double-emission under --no-objects-only alone. No precedence invented. |
correct |
| 2 | Renderer's three kinds are now a partition; --metadata-forms is the only control over the baseline; --json carries what the files carry |
Diff read: stackAuthoredSubtree strips metadataForms; 'stack' renders it; --json uses the same helper. |
correct |
| 3 | Default --objects-only path byte-identical; all nine in-repo extract configs use it; pnpm check:i18n green |
Pinned ("unchanged under the default --objects-only, in both flag positions"). | correct |
| 4 | Pins: command-level census e2e (4 combinations + --json) plus unit pins on the partition; ablation 2+2 red with the two unreached cases green |
Discriminating direction. | correct |
| 5 | Changeset @objectstack/cli patch, no BREAKING banner, on the i18n-walk-one-key-one-demand precedent |
Accepted with one note: a stack that regenerates under --no-objects-only sees its module's export type narrow (a consumer reading xxTranslations.metadataForms off that module would now get a type error). The baseline is not lost — it sits in the companion the same command writes — and the changeset says so word for word; the fixed group makes the level moot in effect. Not blocking. |
accepted |
② semver
patch accepted per row 5.
⛔ Why it cannot land at 5da3bfea — the red is this PR's own
Lint & Repo Gates (job 101412652558) fails one case of the dispatch-gates self-test, quoted from the log:
✗ every in-tree directory this tree's sources create is covered by a tracked ignore rule, or is tracked itself
— EXPOSED: packages/cli/test/.tmp-i18n-14894 (packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts:83)
The new e2e test creates its fixture root inside the tree (FIXTURE_DIR = join(HERE, '.tmp-i18n-14894'), mkdirSync(FIXTURE_DIR, …)) and no tracked ignore rule covers .tmp-*. Not red on main's own runs; the sibling e2e in the same PR family (lint-eval-generator-load-envelope.e2e.test.ts) uses mkdtempSync(join(tmpdir(), …)), and the repo's tracked rules cover tmp/ and *.tmp, not a dot-prefixed .tmp-* directory. Patch round to the dispatching seat (session_01D47qPfEWVPmhguWgBZCi5N / os-litant): place the fixture under the system temp dir (as the sibling does, and as outRoot already is) or under a covered tmp/ root — ⛔ not by adding a bespoke ignore rule for one test. Re-run node scripts/pm/dispatch-gates.mjs --self-test locally before pushing.
A test-placement-only head moves no reviewed source; this PASS is extended on request once pushed, then landing (0 governed paths) follows on green. No needs:contract-review label was hung on either carrier (the seat left it to the PM); nothing to strip.
Generated by Claude Code
…ompanion's own predicate Review of the first commit found the `--json` face left worse than it was found. That commit stopped the `kind: 'full'` fold everywhere, including here, and this face has no second file for the baseline to move to — so it was dropped outright. Driven on the same one-object, one-app `defaultLocale: 'zh-CN'` fixture, `--json --no-objects-only` with `--metadata-forms` ON and with `--no-metadata-forms` returned payloads equal in every field but `duration`: 3 leaves in `bundles`, no baseline in either, and `metadataFormsCounts` reporting 773 in both. On that face the flag decided NOTHING — the mirror image of the defect this card reports, and it falsified three sentences the PR and changeset had already made. The payload now mirrors the FILE SET: `bundles` is the stack module and a new `metadataForms` map is the companion, keyed by the locales whose companion would be written and gated by `emitsMetadataForms(locale)` — the SAME predicate, deliberately not a second one. The map is always emitted; an empty one reads as "no baseline in this run", where a missing key would be indistinguishable from an older CLI. The `--json` pin drove `--no-metadata-forms` only, so it was green for both states of a flag that did nothing. It now drives BOTH and asserts the axis: the two payloads must differ once `duration` is dropped, the flag-ON payload must carry the baseline under its own key with a leaf count matching `metadataFormsCounts`, and the flag-OFF payload must carry no baseline at all. Also corrected, in the renderer's docs and the unit pins: the three kinds are NOT three disjoint cells. `'objects'` is a sub-selection of `'stack'`. The invariant that holds is about the PAIR a run emits — the module it writes and the companion beside it are disjoint, and under `'stack'` the two together are everything the extractor built (3 + 773 = 776, the extractor's own count). Part of #14894 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Contract review (clause ②) — delta at head
|
| # | change | reading | verdict |
|---|---|---|---|
| 1 | --json payload gains a top-level metadataForms map (baseline keyed by locale), present only for the locales whose companion file would be written, gated by the same emitsMetadataForms predicate as the file |
The seat's own review caught that the first cut left the baseline with no JSON home and the flag deciding nothing on that face (payloads equal but duration). The fix mirrors the file set on the JSON face with one predicate, not two. Clause ② limb 1: a new key on a published JSON payload — yes by the mechanical floor, additive; the map is always emitted (empty = "no baseline in this run"), so an older-CLI consumer cannot be confused by absence. |
correct |
| 2 | "Partition" wording retracted in code comments and pins: 'objects' is a sub-selection of 'stack'; the invariant is about the pair a run writes (3 + 773 = 776) |
Precision correction; the census pin is renamed accordingly. | correct |
| 3 | --json pin now drives both flag states and asserts the axis (payloads differ; duration dropped) |
The right shape — a one-state pin cannot detect a flag that does nothing. | correct |
| 4 | Changeset updated: the JSON face's move stated, patch kept |
Accepted as before; the additive JSON key strengthens rather than weakens the patch reading (nothing removed on the JSON face except the fold the flag should have suppressed). |
accepted |
Content verdict on 5da3bfea extends to ac8add2e with the JSON key admitted as an additive widening.
⛔ Still not landable — the placement blocker from 5556462409 is unchanged
packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts at this head still reads FIXTURE_DIR = join(HERE, '.tmp-i18n-14894') / mkdirSync(FIXTURE_DIR, …) (lines 66/83): the in-tree fixture root no tracked ignore rule covers. Lint & Repo Gates is in progress on this head and will red on the same dispatch-gates self-test case as before. Patch round still owed to the dispatching seat (session_01D47qPfEWVPmhguWgBZCi5N / os-litant): put the fixture root under the system tmpdir (outRoot already is), re-run node scripts/pm/dispatch-gates.mjs --self-test locally, push. A placement-only head is then extended and landed on green (0 governed paths). needs:contract-review is hung on both carriers now (the seat hung it after the first verdict) and stays until that head.
Generated by Claude Code
os-litant
left a comment
There was a problem hiding this comment.
Contract review (clause ②) — delta re-review, PR #16120 / card #14894, at head ac8add2e4f4
Clause ② — mechanical limb: YES, on concrete keys. The --json payload GAINS a new top-level metadataForms key — a new key on a published payload, the mechanical floor's always-yes trigger — shaped Record<locale, TranslationData['metadataForms']>, present in every run (an empty map when no companion is written), keyed only by the locales whose <locale>.metadata-forms.generated.ts would be written; for the --no-objects-only class bundles.<locale> still loses its metadataForms sub-key (content relocated to the new key, measured 3 + 773 = 776), and the emitted module's export type still narrows to Omit<TranslationData, 'metadataForms'>.
Clause ② — conformance limb: YES. On the file face the --no-objects-only class moves from "baseline inlined (and duplicated)" to "baseline in the companion only", unchanged since the first commit and re-measured on this head. On the --json face the class now reaches two flag-dependent verdicts — baseline under its own key with --metadata-forms, absent under --no-metadata-forms — where every prior code state gave one flag-independent answer.
Verdict: CHANGES REQUESTED — the contract content is CLEARED (my blocking item is closed and verified below), but this head is NOT LANDABLE: CI's Lint & Repo Gates is red on the PR's own e2e fixture placement (blocking item 2).
Implemented-by: claude/issue-14894-i18n-extract-no-metadata-forms (mode:subagent dev, per its claim comment)
Reviewed-by: session 01D47qPfEWVPmhguWgBZCi5N — a context-isolated contract-review subagent of the same dispatching session, fed the card, the PR and the diff only. As in round one: the dispatching seat weighs that identity pair itself; this is a measured second opinion, not a self-issued clear.
Blocking item 1 (round one) — CLOSED, verified
The gating predicate is the same one, not a copy. emitsMetadataForms is defined once (extract.ts:212, flags['metadata-forms'] && metadataFormsCounts[locale] > 0) and is the predicate at all four sites — the source-hash section list (:257), the new JSON key (:299, .filter((l) => emitsMetadataForms(l))), the dry-run print (:334) and the companion file (:361). git grep "metadata-forms']" over packages/cli/src at the head hits that one definition and nothing else, so no second predicate exists to drift.
The three-state table HELD, on my own runs of --json --no-objects-only in both flag states (fixture: one object, one app, defaultLocale: 'zh-CN'; leaves counted structurally off the payload; the base and first-commit rows from the blob-swapped sources of round one, hashes verified):
| code state | flag ON | flag OFF | equal once duration is dropped? |
|---|---|---|---|
base 2648774b967 |
bundles = objects, apps, metadataForms, 776 leaves; no top-level metadataForms key |
same | yes — flag ignored |
first commit 5da3bfea1e6 |
bundles = objects, apps, 3 leaves; no top-level key |
same | yes — flag ignored, opposite direction |
head ac8add2e4f4 |
bundles 3 leaves + metadataForms: { "zh-CN": … } 773 leaves |
bundles 3 leaves + metadataForms: {} |
no — the flag moves the payload |
Qualifying my round-one "byte-identical": in the first two rows the raw bytes differ, and duration (wall clock) is the only differing field — equality holds exactly once it is dropped. The reading that the first commit swapped one wrong answer for another, rather than breaking a working face, is correct: the base row's flag-OFF payload is 776 leaves including the baseline the operator switched off.
The extended pin's ablation — confirmed, and it fails on the flag axis. Both legs on the committed head, mutation proven on disk each time (injected text ×1, original .filter((l) => emitsMetadataForms(l)) ×0, blob 5c5e3c50c0a moved), restored from HEAD and re-hashed to 5c5e3c50c0a with git diff HEAD empty:
.filter(() => false)(baseline never in JSON — the first commit's behaviour):1 failed | 3 passed; the red is the--jsoncase atexpect(Object.keys(on.metadataForms)).toEqual(['zh-CN'])— received[]— the flag-ON assertion..filter(() => true)(predicate ignored — baseline in both states):1 failed | 3 passed; the red is the--jsoncase atexpect(off.metadataForms['zh-CN']).toBeUndefined()— received the 27-group map — the flag-OFF assertion.
The three file-face cases stayed green in both legs (selectivity), and the first assertion to fail in each leg is the one about the flag state that leg breaks — not duration, not the bundles shape, not the count.
The three falsified sentences are corrected, in both carriers. Changeset: "the only control over it on both faces"; "--json … mirrors that file set … That map is new"; "Nothing published loses content" replaced by a per-face What content moves where paragraph that states the JSON relocation. PR body: the same three, in their own section, plus the file-face table unchanged and the three-state table above. The over-claim I flagged in round one — "the three kinds are a partition" — is also withdrawn in the renderer docblock, the changeset and the test names (sub-tree selection, emits every leaf exactly once across the pair a stack run writes), in favour of the pair invariant that actually holds.
Blocking item 2 (new) — CI is red on this head, on the PR's own test placement
Lint & Repo Gates (job 101422068406) concluded failure at ac8add2e4f4. Reproduced locally with CI's own spelling on this head:
$ pnpm check:pm-dispatch-gates # exit 1
✗ every in-tree directory this tree's sources create is covered by a tracked ignore rule, or is tracked itself
— EXPOSED: packages/cli/test/.tmp-i18n-14894 (packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts:83)
✗ dispatch-gates self-test: 1 of 1511 case(s) failed.
FIXTURE_DIR = join(HERE, '.tmp-i18n-14894') (line 66) is created in-tree at line 83; the tracked .gitignore covers *.tmp and tmp/, not a dot-prefixed .tmp-* directory. The Director's comment on this PR asked for exactly this patch at 5da3bfea; the second commit did not carry it, and the case is not in the derived gate union (it is a whole-tree self-test step of lint.yml), which is why "56/56 exit 0" is true and the job is still red. Fix as the Director prescribed: keep the fixture under packages/cli (the test's own comment explains why — bundle-require resolves @objectstack/spec from the config's directory) but under a covered root, e.g. packages/cli/tmp/… (tmp/ is tracked-ignored) — ⛔ not a bespoke ignore rule for one test. A placement-only push moves no reviewed source; this clear extends on it once pnpm check:pm-dispatch-gates is green.
Changeset level, re-judged against the new key — patch stands
The new top-level key is additive: a --json consumer that ignores it is unaffected. The only field removed for any input class (bundles.<locale>.metadataForms, --no-objects-only) is relocated to the new key under the same flag, so content moves and nothing is lost — the same shape as the file face, and the cited precedent (.changeset/i18n-walk-one-key-one-demand.md, patch) covers a changed emitted-key set. The key exists to make a flag do what its --help says, which is a bug fix, and a bug fix in a released package takes patch here. 'full' → 'stack' remains internal. If the maintainer reads a new payload field as a feature, minor with no banner is the alternative; no **BREAKING** banner and no ADR-0087 marker is owed either way — nothing authorable is retired and no consumer's compile breaks except one reading .metadataForms off the --no-objects-only module, which round one already graded. check-empty-changeset, check-changeset-no-major and check-adr-0087-registration exit 0 on this head; CI's Check Changeset is green.
The delta did not disturb what round one cleared
File face on this head: A --no-metadata-forms --no-objects-only 3 leaves; B --no-objects-only 3 + companion 773; C --no-metadata-forms 2; D default 2 + 773 — identical to the first commit, so the reproduction direction, the pair invariant (3 + 773 = 776 = the extractor's own count) and "nothing in this repository moved" (pnpm check:i18n exit 0) all stand. Blast radius re-run on the head: 20 files / 301 tests pass; pnpm --filter @objectstack/cli typecheck exit 0 (test layer included); ESLint on the four changed TS files 0 errors / 0 warnings.
Gates, on this head
Union re-derived (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands) — the same 56 families as round one, asserted against its own count line: 56 run, 56 exit 0 (29 direct node, 27 pnpm, exit codes captured before any pipe, workspace built first). Rosters block separately: 37 families, 34 exit 0, three not a pass (below). The derivation now flags the tree 12 commits behind origin/main (658262e462b) with four derived-from files changed there; the three runnable families among them (check:published-files, docs-audit/check-affected-docs.mjs, docs-audit/check-drift-comment.mjs) were re-run on a fresh local merge of current origin/main with this head — a clean merge whose diff is byte-identical to the PR diff, and main's packages/cli changes touch only explain.ts / generate.ts and their pins, never the extractor — all exit 0. GitHub's own merge commit for this head (683f6210a7f) is likewise a clean merge. The fourth stale file is lint.yml itself; CI's run on this head is the reading for its always-runs tail — all green except the one red in blocking item 2, with Test Core (1/6) still in progress at the time of reading.
NOT MEASURED — by name, each with its own verdict line
- Full 136-file
@objectstack/clisuite — NOT MEASURED; verdict: the 20-file narrowing stands from round one; CI's sixTest Coreshards are the farm (five green, one in progress at reading). node scripts/check-partof-closing-keyword.mjs— exit 2 NOT WIRED; verdict: none; the pnpm spelling's 0 is--self-testonly (#16030).node scripts/check-single-claim-paths.mjs— exit 2 NOT WIRED; verdict: none; same caveat.pnpm --filter @objectstack/spec run check:react-declaration-parity— exit 1 "did NOT run"; verdict: none; no React blocks in the diff.- The six workflow-valued families (
check-cross-package-test-inputs --union-into …,check-shard-attestation×3,check-test-completeness×2) — NOT MEASURED; verdict: none; CI-only values. - The always-runs tail beyond ESLint on the four files and
pnpm check:pm-dispatch-gates— NOT MEASURED locally; verdict: CI's run on this head stands in, green except blocking item 2.
Generated by Claude Code
Fixes #14894
os i18n extract --no-metadata-formswas honoured only while the default--objects-onlywas in effect. The flag gated themetadata-forms.generated.tscompanion and nothing else, while the stack module's renderer had a third mode —kind: 'full'— that serialised the WHOLETranslationData, the Studio metadata-form baseline included, and--no-objects-onlyselected it.Two commits. The second is the patch round from contract review, and it is not a polish pass: review found the first commit had made the
--jsonface worse, and falsified three sentences this body used to make. Both are recorded below.Driven — the file face, with its control
Fixture: one object, one app,
i18n.defaultLocale: 'zh-CN',--locales=zh-CN. Every number is a LEAF COUNT taken structurally off the emitted module, not an impression of a diff.--no-metadata-forms --no-objects-onlyobjects2,apps1,metadataForms773objects2,apps1--no-objects-only(baseline ON)--no-metadata-forms(the card's control, default--objects-only)objectsonlyobjectsonlyThe reported flag interaction reproduces in exactly the direction the card describes: the flag works alone and stops working the moment
--no-objects-onlyjoins it. The control confirms the card's other half — dropping--no-objects-onlyremoves the block and takes theappskey with it. The emitted file went from 1716 lines to 33, and the 773 inlined leaves were English: the default locale is filled from the source labels and the registry authors them in English, so azh-CNdefault locale shipped the platform's English Studio strings inside its own bundle. (The card counted 761 on 17.2.0; the baseline is registry-driven and has grown since.)Driven — the
--jsonface, in both flag states, across all three code statesThis is the blocking item from review.
--json --no-objects-only, run with--metadata-formsON and with--no-metadata-forms, on each code state in turn — the same file restored fromHEADbetween states, hashes checked:2648774b967(base, pre-PR)bundles=objects,apps,metadataForms— 776 leaves5da3bfea1e6(first commit)bundles=objects,apps— 3 leaves, no baseline anywhereac8add2e4f4(head)bundles3 leaves +metadataFormszh-CN773 leavesbundles3 leaves +metadataForms{}In the first two rows the two payloads were equal in every field but
duration, which is wall clock. So the first commit did not merely fail to fix this face — it stopped the fold here too, and this face has no second file for the baseline to move to, so the baseline was dropped outright whilemetadataFormsCountswent on reporting 773.The fix: the payload now mirrors the FILE SET.
bundlesis the stack module, and a newmetadataFormsmap is the companion — keyed by the locales whose companion would be written, gated byemitsMetadataForms(locale), the SAME predicate and deliberately not a second one. The map is always emitted; an empty one reads as "no baseline in this run", where a missing key would be indistinguishable from an older CLI.Three sentences this body and the changeset made, now corrected: "
--metadata-formsis its only control" was false on the--jsonface and is true only as of the second commit; "--jsoncarries the same payload the files carry" was false in exactly the way above; and the changeset's "Nothing published loses content" was false — the first commit removed the baseline from the JSON payload with nowhere for it to go. The changeset now states what moves where, per face.The two flags, and why no precedence was invented
--objects-onlypicks the STACK MODULE's sub-tree —objectsalone, or everything the stack authors. Its help says "disable to include apps/dashboards"; it never promised the baseline.--metadata-formsdecides whether the registry-driven baseline is emitted AT ALL — into its own companion file, or (as of the second commit) its own JSON key. It is now the only control over the baseline on both faces.These do not overlap and never needed a winner. The overlap was in the emitter:
kind: 'full'gave the baseline a SECOND home, and the two homes then disagreed about which flag governed it.'objects'is a SUB-SELECTION of'stack', not a sibling. The invariant that holds is about the PAIR a run emits — the module it writes and the companion beside it are disjoint, and under'stack'the two together are everything the extractor built: 3 + 773 = 776, the extractor's own count for that run, none dropped and none duplicated.What moved in this repository: nothing
All nine
i18n-extract.config.tsdocstrings run under the default--objects-only(flags read out of each viaflagsFromDocstring), and that path's module, export name and type signature are byte-for-byte unchanged.pnpm check:i18nexit 0 on the committed tree — no bundle regenerates, so no committed key count moves.Tests
i18n-extract-metadata-forms-flag.e2e.test.ts) spawns the real CLI and takes a group census of the bytes it wrote, in all four file-face flag combinations and--jsonin BOTH flag states. The--jsoncase originally drove--no-metadata-formsonly, and that is why it was green while the flag did nothing on that face: a pin that exercises one state of a flag can never detect that the flag is ignored. It now asserts the axis — the two payloads must differ oncedurationis dropped, the flag-ON payload must carry the baseline under its own key with a leaf count equal tometadataFormsCounts, and the flag-OFF payload must carry no baseline at all.stackAuthoredSubtree, asserted as a census (which groups, how many leaves) — a duplicate group is invisible to a substring assertion, and a duplicate was half of what this closes.--jsonfix, both directions, on the committed head with the mutation proven on disk each time (injected text counted, predicate count checked to 0, blob hash moved, restored fromHEADand re-hashed):.filter(() => false)— the baseline never in JSON, i.e. the first commit's behaviour — reddens the pin,1 failed | 3 passed;.filter(() => true)— the predicate ignored, the baseline in both states — also reddens it,1 failed | 3 passed. In both legs the three file-face cases stay green, which is the selectivity the pin should have. That the ONE-state version could not have caught this is not an argument but a reading: it was green 4/4 at5da3bfea1e6, on the code the table above shows was ignoring the flag.6899355fto4f9230d7, unit2 failed | 18 passed, e2e2 failed | 2 passed, restored andgit diff HEADempty.packages/cli/src— vitest imports the source,bin/run-dev.jsruns it through tsx — not through a package'sexportsintodist, so there is no built copy that could go stale.packages/cli/testthat reach the changed modules, derived by grep — 20 files / 301 tests, green on the head.pnpm --filter @objectstack/cli buildandtypecheckgreen on the head, test-layer typecheck included.Gates, on the head
ac8add2e4f4Union re-derived there with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— an identical family list to the first round, asserted against its own reconciliation line: 56 families, 56 commands printed, 56 run, all exit 0, exit codes captured before any pipe. The workspace was built first this time, so nothing came back exit 3. Check Changeset's three members are among them and green here:check-empty-changeset --base origin/main,check-changeset-no-major --base origin/main,check-adr-0087-registration --base origin/main, pluscheck:changeset-gate-self-testsandcheck-changeset-fixed.The Artifact rosters block was run separately on the same head: 37 families, 34 exit 0, three NOT MEASURED and none of them a pass —
check-partof-closing-keyword.mjsandcheck-single-claim-paths.mjsat exit 2 NOT WIRED (no PR context locally; their pnpm spellings exit 0 but resolve to--self-testonly), and@objectstack/spec check:react-declaration-parityat exit 1 printing "this gate did NOT run", needing a browser-producedsdui.manifest.jsonthis repo does not contain.ESLint: population read from ESLint's own config (the four TS files produce result rows; the changeset
.mdis reported by ESLint itself as "File ignored because no matching configuration was supplied"), count from--format json— 4 files, 0 errors, 0 warnings — and the config registers noparserOptions.projectand no typed rules, so no rule reads a file other than the one being linted and this diff cannot move any untouched file's verdict.Files changed
Changeset
@objectstack/cli: patch, no**BREAKING**banner. Calibrated against this repo's own precedent for the same surface:.changeset/i18n-walk-one-key-one-demand.mdchanges which keysos i18n extractemits and takespatch.'full'to'stack'is internal —renderTranslationModuleis not on the package'sexports.Clause ②
Clause-②: yes
--no-objects-onlyclass the--jsonbundlesvalue LOSES itsmetadataFormskey (objects, apps, metadataFormsat 776 leaves, toobjects, appsat 3), the payload GAINS a top-levelmetadataFormskey, and the emitted module's type narrows to anOmitofTranslationDatawithoutmetadataForms. Probed with tsc on the emitted module: a consumer typed asTranslationDatastill compiles (exit 0), one reading.metadataFormsoff the module gets TS2339. Nothing unclear about it.--jsonthe class reaches a THIRD verdict. On the file face--no-objects-onlymoves between two already-published verdicts — baseline inlined in the stack module, baseline in its own companion. On--jsonthe same class now also reaches a verdict no face had before: with--no-metadata-forms, the baseline is nowhere in the payload. That face never honoured the flag at all, in either direction, until this PR.Grading yes buys one review round and costs nothing; the diff was not shaped to keep either limb no. The
needs:contract-reviewlabel is not applied by this seat.Two notes for whoever merges
Fixes #14894and commit 2 carriesPart of #14894. Concatenated by a default squash they contradict each other, and this branch's convention is that card relations are declared once — here, in this body's first line. Neither trailer should have been written; they cannot be removed without a force-push, which is barred on this lane. Take the squash body from this PR body, not from the concatenated commit messages.os i18n extract --checkfailure prints a regenerate command with--locales=empty and every other flag dropped — following it yields a different bundle that fails --check again #14895 is not addressed here — same reporter and command family, different defect (the remedy line--checkprints), and the triage comment asks for this card first.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N