Skip to content

fix(cli): honour --no-metadata-forms whatever --objects-only is set to - #16120

Draft
os-litant wants to merge 3 commits into
mainfrom
claude/issue-14894-i18n-extract-no-metadata-forms
Draft

fix(cli): honour --no-metadata-forms whatever --objects-only is set to#16120
os-litant wants to merge 3 commits into
mainfrom
claude/issue-14894-i18n-extract-no-metadata-forms

Conversation

@os-litant

@os-litant os-litant commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14894

os i18n extract --no-metadata-forms was honoured only while the default --objects-only was in effect. The flag gated the metadata-forms.generated.ts companion and nothing else, while 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.

Three commits: the fix, a patch round from contract review, and a placement-only round. All three are recorded below, including what each of the first two got wrong.

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.

run objects module, before objects module, after companion
--no-metadata-forms --no-objects-only 776 leaves — objects 2, apps 1, metadataForms 773 3 leaves — objects 2, apps 1 not written (correct, both before and after)
--no-objects-only (baseline ON) 776 leaves, same three groups 3 leaves 773 leaves — before the fix the SAME 773 keys were in both files
--no-metadata-forms (the card's control, default --objects-only) 2 leaves, objects only 2 leaves, unchanged not written
default 2 leaves, objects only 2 leaves, unchanged 773 leaves, unchanged

The reported flag interaction reproduces in exactly the direction the card describes: the flag works alone and stops working the moment --no-objects-only joins it. The control confirms the card's other half — dropping --no-objects-only removes the block and takes the apps key 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 a zh-CN default 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 --json face, in both flag states, across three code states

This was the blocking item of the first review round. --json --no-objects-only, run with --metadata-forms ON and with --no-metadata-forms, on each code state in turn — the same files restored from HEAD between states, hashes checked:

code state flag ON flag OFF does the flag control anything?
2648774b967 (base, pre-PR) bundles = objects, apps, metadataForms — 776 leaves identical no — the flag was ignored here already
5da3bfea1e6 (commit 1) bundles = objects, apps — 3 leaves, no baseline anywhere identical no — still ignored, now in the opposite direction
ac8add2e4f4 (commit 2) bundles 3 leaves + metadataForms zh-CN 773 leaves bundles 3 leaves + metadataForms {} yes

In the first two rows the payloads were equal in every field but duration, which is wall clock. So commit 1 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 while metadataFormsCounts went on reporting 773.

The fix: the payload 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, gated by emitsMetadataForms(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, corrected in commit 2: "--metadata-forms is its only control" was false on the --json face; "--json carries the same payload the files carry" was false in exactly that way; and the changeset's "Nothing published loses content" was false — commit 1 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-only picks the STACK MODULE's sub-tree — objects alone, or everything the stack authors. Its help says "disable to include apps/dashboards"; it never promised the baseline.
  • --metadata-forms decides whether the registry-driven baseline is emitted AT ALL — into its own companion file, or its own JSON key. It is 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.

⚠️ Retracted in commit 2, in code, prose and test names: the three kinds are not three disjoint cells. '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.

Placement round (commit 3) — 6298b30163d

Lint & Repo Gates was red on this PR's own head for a reason that is not contract content: the new e2e created its fixture root inside the tree at packages/cli/test/.tmp-i18n-14894. Nothing tracked ignores .tmp-* (the repo's rules cover tmp/ and *.tmp), so dispatch-gates --self-test failed its "every in-tree directory this tree's sources create is covered by a tracked ignore rule, or is tracked itself" case, naming this file. ⛔ The repair is not a bespoke ignore rule for one test — it is not creating the directory in the tree at all. Both roots are now mkdtempSync under the system temp dir, which is where this file's --out root already was.

Driven locally on the same command, same tree, before and after:

before:  ✗ every in-tree directory this tree's sources create is covered by a tracked ignore rule…
           — EXPOSED: packages/cli/test/.tmp-i18n-14894 (…e2e.test.ts:83)
         ✗ dispatch-gates self-test: 1 of 1511 case(s) failed.
after:   ✓ every in-tree directory this tree's sources create is covered by a tracked ignore rule…
         ✓ dispatch-gates self-test: 1511 cases pass.

Zero EXPOSED: occurrences remain in the log, down from one.

⚠️ One adjustment the move forces, and it is not cosmetic. bundle-require writes its bundled module NEXT TO the config, and Node resolves the config's bare specifiers from THAT directory — so the fixture's defineStack import cannot survive the move (Cannot find package '@objectstack/spec' imported from /tmp/…/stack.config.bundled_….mjs). The fixture is now a plain object default export, which is what the command consumes anyway (normalizeStackInput on whatever the config exports), and every reading is unchanged: 3 stack leaves, 773 baseline leaves, empty under --no-metadata-forms, pin green 4/4.

The cost is recorded in the file rather than glossed: defineStack validated the fixture at load, and a plain object is not validated the same way — measured, dropping type from the field is refused under defineStack ("Invalid field type ''") and accepted silently without it. The exact leaf counts asserted in the pin are the remaining guard. Flagging it here because it is the one thing in commit 3 that is not pure placement, and the reviewer should decide whether it still counts as a placement-only head.

What moved in this repository: nothing

All nine i18n-extract.config.ts docstrings run under the default --objects-only (flags read out of each via flagsFromDocstring), and that path's module, export name and type signature are byte-for-byte unchanged. pnpm check:i18n exit 0 on the committed tree — no bundle regenerates, so no committed key count moves.

Tests

  • Command-level pin (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 --json in BOTH flag states. The --json case originally drove --no-metadata-forms only, 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 once duration is dropped, the flag-ON payload must carry the baseline under its own key with a leaf count equal to metadataFormsCounts, and the flag-OFF payload must carry no baseline at all.
  • Unit pins on the renderer's kinds and on 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.
  • Ablation of the --json fix, 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 from HEAD and re-hashed): .filter(() => false) — the baseline never in JSON, i.e. commit 1'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 at 5da3bfea1e6, on the code the table above shows was ignoring the flag.
  • Ablation of the file-face fix (commit 1): payload line reverted, blob 6899355f to 4f9230d7, unit 2 failed | 18 passed, e2e 2 failed | 2 passed, restored and git diff HEAD empty.
  • No rebuild leg in any ablation, and that is a property of the subject rather than an omission: both suites resolve it from packages/cli/src — vitest imports the source, bin/run-dev.js runs it through tsx — not through a package's exports into dist, so there is no built copy that could go stale.
  • Blast radius: the 20 test files under packages/cli/test that reach the changed modules, derived by grep — 20 files / 301 tests, green at ac8add2e4f4.

Gates

At ac8add2e4f4: union derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, asserted against its own reconciliation line — 56 families, 56 commands printed, 56 run, all exit 0, exit codes captured before any pipe. The Artifact rosters block was run separately: 37 families, 34 exit 0, three NOT MEASURED and none a pass (check-partof-closing-keyword.mjs and check-single-claim-paths.mjs at exit 2 NOT WIRED; @objectstack/spec check:react-declaration-parity at exit 1 printing "this gate did NOT run", needing a browser-produced sdui.manifest.json this repo does not contain).

⚠️ That union was 56/56 green while CI was red, and the reason is worth stating rather than burying: the failing self-test case is outside the derived union. A clean union is evidence about the union, not about the job. The verdict that actually mattered came from reading the failing job's own log.

At 6298b30163d, re-run for the one changed file: dispatch-gates --self-test 1511/1511 pass (the case that failed now passes), the e2e pin 4/4, pnpm --filter @objectstack/cli typecheck exit 0 with the test layer included, ESLint 0 errors / 0 warnings on that file.

Docs drift check named two hand-written pages. Both read, neither falsified: i18n-standard.mdx line 811 describes --json as "print the skeletons as JSON instead of writing files" — which this PR makes more true, not less — and line 873's "metadataForms are not checked" is about os i18n check's coverage rules, untouched here. No docs edited.

Files changed

.changeset/i18n-extract-metadata-forms-flag-independence.md
packages/cli/src/commands/i18n/extract.ts
packages/cli/src/utils/i18n-extract.ts
packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts
packages/cli/test/i18n-extract.test.ts

Commit 3 touches exactly one of them: packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts.

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.md changes which keys os i18n extract emits and takes patch. 'full' to 'stack' is internal — renderTranslationModule is not on the package's exports.

Clause ②

Clause-②: yes

  • Mechanical limb — yes, on concrete keys. For the --no-objects-only class the --json bundles value LOSES its metadataForms key (objects, apps, metadataForms at 776 leaves, to objects, apps at 3), the payload GAINS a top-level metadataForms key, and the emitted module's type narrows to an Omit of TranslationData without metadataForms. Probed with tsc on the emitted module: a consumer typed as TranslationData still compiles (exit 0), one reading .metadataForms off the module gets TS2339.
  • Conformance limb — yes, and on --json the class reaches a THIRD verdict. On the file face --no-objects-only moves between two already-published verdicts — baseline inlined in the stack module, baseline in its own companion. On --json the 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-review label is not applied or altered by this seat.

Note for whoever merges

Squash message. Commit 1 carries Fixes #14894; commits 2 and 3 carry Part 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. None of the three trailers should have been written; removing them needs a force-push, which is barred on this lane. Take the squash body from this PR body, not from the concatenated commit messages.

Scope note: #14895 is not addressed here — same reporter and command family, different defect (the remedy line --check prints), and the triage comment asks for this card first.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/protocol/kernel/i18n-standard.mdx (via metadataForms (literal, a string literal in renderTranslationModule; a string literal in stackAuthoredSubtree; a string literal on a changed line), os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))
  • content/docs/ui/translations.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))

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

  • content/docs/releases/v15.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))
  • content/docs/releases/v16.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))
  • content/docs/releases/v17.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))

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 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 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 d5d8d50db2b698107ee4b36ef7b987e6ed23da0cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 4a86e64ddebb05b83e3f704e549ccf7ec70fc097 — the merge of head 6298b30163d30f6fcba589b28729725e95744ab9 into base d5d8d50db2b698107ee4b36ef7b987e6ed23da0c, 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 4a86e64ddebb05b83e3f704e549ccf7ec70fc097 && git checkout 4a86e64ddebb05b83e3f704e549ccf7ec70fc097
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d5d8d50db2b698107ee4b36ef7b987e6ed23da0c 6298b30163d30f6fcba589b28729725e95744ab9 && git checkout -B drift-repro d5d8d50db2b698107ee4b36ef7b987e6ed23da0c && git merge --no-ff 6298b30163d30f6fcba589b28729725e95744ab9

node scripts/docs-audit/affected-docs.mjs --json d5d8d50db2b698107ee4b36ef7b987e6ed23da0c

⚠️ 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 d5d8d50db2b698107ee4b36ef7b987e6ed23da0c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-litant os-litant left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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-only was the one route by which the baseline skeleton reached the JSON face; after it, there is none.
  2. It falsifies the PR's own invariants. "--metadata-forms is the only control over the baseline" — on this face it is no control at all. "--json carries 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.
  3. 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 --json face only the 'objects'/'stack' cell is emitted; the 'metadataForms' cell has no home, and this diff removed the only one it had.
  4. The pin cannot see it. The --json case in i18n-extract-metadata-forms-flag.e2e.test.ts is driven with --no-metadata-forms only, 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. stackAuthoredSubtree is a rest-spread minus metadataForms, so every other group the extractor setDeeps (objects, apps, pages, dashboards, flows, datasets, …) stays in kind 'stack'; kind 'metadataForms' renders exactly the complement; kind 'objects' renders data.objects alone — a proper subset of 'stack', which is the documented meaning of --objects-only (it drops apps/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 (objects 2, apps 1) + companion 773 = 776 = the extractor's own counts["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).
  • --json face: 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: renderTranslationModule is not reachable from @objectstack/cli's exports (., ./console, ./hook-body; src/index.ts does not re-export utils/i18n-extract), and no caller outside the command exists in the repo.
  • File face: export name kept, type narrows to Omit<…> that still assigns to TranslationData (probe above) — patch matches 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 patch stands; either way the sentence must go. check-changeset-no-major and check-adr-0087-registration exit 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 the CATEGORIES const — no-migration-prescription is 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/cli suite — 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-keyword exit 0 is --self-test only (#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-produced sdui.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, committedSourceHashes narrows the provenance table to the objects and metadataForms sections only, so apps.* (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

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS on content; NOT LANDABLE at this head — PR #16120 at 5da3bfea (Fixes #14894)

Director seat, summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH (os-zhuang), 2026-09-06T02:50Z, batch review under the maintainer's 「按批次执行完所有的契约复审」. Tier fuse: get_session this session reads session_context.model = last_served_model = CONTRACT_REVIEW_TIER. Readings from the PR diff (5 files), card #14894 + triage 5549874476, the failing CI job's own log; dev report 5556280431 read afterwards as cross-check.

Implemented-by: session_01D47qPfEWVPmhguWgBZCi5N os-dev round (branch claude/issue-14894-i18n-extract-no-metadata-forms)
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH

Clause ② standing — yes (limb 2), correctly declared

Limb 1: renderTranslationModule / stackAuthoredSubtree live in packages/cli/src/utils/, not a published entry of @objectstack/cli; the kind union renaming 'full''stack' is intra-package. Limb 2: yes — for the --no-objects-only input class the emitted objects module and the --json bundles payload change content (the baseline leaves), and the emitted module's type narrows from TranslationData to Omit<TranslationData, 'metadataForms'>.

① Derived judgments

# 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

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — delta at head ac8add2e: PASS on content, STILL NOT LANDABLE (Fixes #14894)

Director seat, summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH (os-zhuang), 2026-09-06T03:40Z. Follows verdict 5556462409 (head 5da3bfea). This head is not a placement-only move — it is a hand edit, so the delta is re-reviewed rather than extended.

Implemented-by: session_01D47qPfEWVPmhguWgBZCi5N os-dev round
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH

Delta read (5da3bfea…ac8add2e, one commit)

# 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 os-litant left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --json case at expect(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 --json case at expect(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/cli suite — NOT MEASURED; verdict: the 20-file narrowing stands from round one; CI's six Test Core shards 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-test only (#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

Placement only — no reviewed source moves.

The fixture root was `packages/cli/test/.tmp-i18n-14894`, created by the test
at run time. Nothing tracked ignores `.tmp-*` (the repo's rules cover `tmp/`
and `*.tmp`), so `dispatch-gates --self-test` failed its "every in-tree
directory this tree's sources create is covered by a tracked ignore rule, or is
tracked itself" case, quoting this file. The repair is not a bespoke ignore
rule for one test: it is not creating the directory in the tree at all. Both
roots are now `mkdtempSync` under the system temp dir, which is where this
file's `--out` root already was.

That forces one adjustment, because the two are the same fact:
`bundle-require` writes its bundled module NEXT TO the config, and Node
resolves the config's bare specifiers from THAT directory — so the fixture's
`defineStack` import cannot survive the move. Measured: `Cannot find package
'@objectstack/spec' imported from /tmp/…/stack.config.bundled_….mjs`. The
config is now a plain object default export, which is what the command
consumes anyway (`normalizeStackInput` on whatever is exported), and every
reading is unchanged — 3 stack leaves, 773 baseline leaves, empty under
`--no-metadata-forms`.

⚠️ Recorded in the file rather than glossed: `defineStack` used to validate the
fixture at load and a plain object is not validated the same way. Measured on
this tree, dropping `type` from the field is refused under `defineStack`
("Invalid field type ''") and accepted silently without it. The exact leaf
counts asserted below are the remaining guard.

Part of #14894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants