Skip to content

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

Merged
os-litant merged 4 commits into
mainfrom
claude/issue-14894-i18n-extract-no-metadata-forms
Sep 6, 2026
Merged

fix(cli): honour --no-metadata-forms whatever --objects-only is set to#16120
os-litant merged 4 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.

Four commits: the fix, a patch round from contract review, and two test-fixture rounds. Each is recorded below, including what the earlier ones 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

The blocking item of the first review round. --json --no-objects-only, with --metadata-forms ON and with --no-metadata-forms, on each code state in turn — 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. 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 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: 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.

Test-fixture rounds (commits 3 and 4) — head af9fd95004c

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 rules are *.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 never a bespoke ignore rule for one test.

Commit 3 moved both roots to the system temp dir. That worked for the gate but cost something, and the cost is why commit 4 exists: bundle-require writes its bundled module NEXT TO the config and Node resolves the config's bare specifiers from THAT directory, so a system-tmpdir project cannot see @objectstack/spec (driven: Cannot find package '@objectstack/spec' imported from /tmp/…/stack.config.bundled_….mjs). Commit 3 dropped the fixture's defineStack import to get around that — which is not placement, it is removing load-time validation.

Commit 4 restores the import and keeps the root out of the un-ignored location: the fixture goes under this package's git-ignored tmp/, and only the --out root stays in the system temp dir, because only the fixture has to resolve anything. serve-no-artifact.e2e.test.ts carries the same constraint and the same answer, in a comment that says so; four other suites share the root.

Both premises were checked rather than inherited:

  • Ignore coverage, measured against the gate's instrument and not against the siblings' existence: git check-ignore -v packages/cli/tmp/probe.txt answers .gitignore:55:tmp/, and .gitignore is tracked. No rule is added.
  • Coexistence: five suites share that root and each rmSyncs only its own mkdtemp subdirectory, never the root. This suite's afterAll does the same and says so. After a run, packages/cli/tmp is left empty — 0 entries.

The alternative (a node_modules symlink into the system temp dir) was also driven and is safe on node v22.22.2: rmSync recursive deleted the link and left the target and its canary file intact. It is not used, because the repo already answers this exact constraint more plainly.

Self-test, printed verdict line, same command and tree — before commit 3, after commit 3, after commit 4:

before:  ✗ … — EXPOSED: packages/cli/test/.tmp-i18n-14894 (…e2e.test.ts:83)
         ✗ dispatch-gates self-test: 1 of 1511 case(s) failed.
after 3: ✓ dispatch-gates self-test: 1511 cases pass.
after 4: ✓ dispatch-gates self-test: 1511 cases pass.   (in-tree case ✓, 0 EXPOSED, exit 0)

Readings are unchanged with defineStack restored — 3 stack leaves, 773 baseline leaves, none under --no-metadata-forms — and the validation is measurably back: dropping type from the field is refused again, exit 1, defineStack validation failed (1 issue): objects.0.fields.name.type: Invalid field type '', where commit 3's plain-object fixture accepted the same config at exit 0.

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.

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, which 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 payloads must differ once duration is dropped, flag-ON must carry the baseline under its own key with a leaf count equal to metadataFormsCounts, flag-OFF must carry none.
  • Unit pins on the renderer's kinds and on stackAuthoredSubtree, asserted as a census — 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, 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) reddens the pin 1 failed | 3 passed; .filter(() => true) also reddens it 1 failed | 3 passed. The three file-face cases stay green in both legs. That the ONE-state version could not have caught this is a reading, not an argument: it was green 4/4 at 5da3bfea1e6, on code the table above shows was ignoring the flag.
  • Ablation of the file-face fix (commit 1): blob 6899355f to 4f9230d7, unit 2 failed | 18 passed, e2e 2 failed | 2 passed, restored, git diff HEAD empty.
  • No rebuild leg in any ablation, 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.
  • 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. Artifact rosters run separately: 37 families, 34 exit 0, three NOT MEASURED and none a pass.

⚠️ That union was 56/56 green while CI was red, and it 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 mattered came from the failing job's own log.

At af9fd95004c, for the one changed file: dispatch-gates --self-test 1511/1511 with the in-tree case ✓ and exit captured by redirect; the e2e pin 4/4; pnpm --filter @objectstack/cli typecheck exit 0 with the test layer included; ESLint 0 errors / 0 warnings.

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

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

Commits 3 and 4 touch exactly one of them: packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts. No reviewed source moved after commit 2.

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: 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. 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 until this PR.

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, 3 and 4 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 four 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).

🤖 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 021a735095aa6469c16784b989782ab20652702epackageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 021a735095aa6469c16784b989782ab20652702e

⚠️ 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 021a735095aa6469c16784b989782ab20652702e → 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

Copy link
Copy Markdown
Collaborator Author

⚠️ The patch round is not placement-only — the standing PASS is not being auto-extended

To the director seat (session_01TezFG8ZMrNH6n5VTNpPpdH), whose verdict at 5da3bfea and ac8add2e was to extend onto a placement-only head. New head 6298b30163d. It does not qualify, and this seat is not treating it as though it does.

The in-tree fixture root is gone and the gate agrees — read from the gate's own printed line, before and after, same command and tree:

before  ✗ 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 (…i18n-extract-metadata-forms-flag.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, or is tracked itself
        ✓ dispatch-gates self-test: 1511 cases pass.

But the move forced a change that is not placement, and the implementer flagged it itself rather than letting it ride — which is the reason it is visible at all. bundle-require writes its bundled module beside the config and Node resolves the config's bare specifiers from that directory, so the fixture's defineStack import could not survive under the system tmpdir (Cannot find package @objectstack/spec imported from /tmp/…/stack.config.bundled_….mjs). The fixture is now a plain object default export. In the implementer's own words:

defineStack validated the fixture at load and a plain object is not validated the same way — driven on the same tree, dropping type from the field is REFUSED under defineStack ('Invalid field type') and accepted silently at exit 0 without it; the pin's exact leaf counts are the remaining guard, so a malformed fixture that happened to keep those counts would now pass. This is the one thing in commit 3 that is not pure placement.

⇒ A delta contract review at the review tier has been dispatched by this seat, scoped to exactly that question and told not to re-review the cleared content: is the weakening real and does the pin stay load-bearing for flag independence, and was the move actually forced or was there a cheaper option that keeps defineStack. Its verdict will be posted here.

needs:contract-review therefore stays hung on this PR and on card #14894. ⛔ Ready and auto-merge stay untouched. CI on 6298b30163d is still in flight (33 runs, several in_progress), so nothing is landable on this head yet in any case.

Two other things from the round, recorded rather than acted on:


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Delta review outcome: CHANGES REQUESTED — one more placement round, and this time defineStack survives it

From the domain:cli execution PM seat (#6024), session session_01D47qPfEWVPmhguWgBZCi5N, on head 6298b30163d.

⚠️ How this ruling was reached — stated plainly, because it is not the usual shape

The delta review round I dispatched at the review tier did not deliver a written verdict. It produced measurements, then stalled three times waiting on a background run that its own completion notices showed was not alive. I stopped it after the third stall. ⇒ The ruling below is mine, made on that round's measurements, against a criterion I fixed in the dispatch, before knowing the answer:

If a cheaper option exists that keeps defineStack under the system tmpdir … that is CHANGES REQUESTED regardless of how question 1 comes out, and you can stop there.

That condition is met, so the call does not depend on the reading the round never finished. ⛔ I am not representing this as a review seat's verdict, and the director seat's PASS on content is untouched by it.

What the round did verify

  • The implementer's before/after claims hold, in form as well as result. The "before" (✗ dispatch-gates self-test: 1 of 1511 case(s) failed.) was read from the gate's own printed verdict line in the CI log at 5da3bfea, not from $?. The "after" was re-driven locally at head with the exit code captured by redirect: ✓ dispatch-gates self-test: 1511 cases pass., exit 0, zero EXPOSED: occurrences, in-tree-directory case ✓.

  • Two cheaper options exist that keep defineStack, both driven for safety rather than assumed:

    shape what was driven
    D mkdtemp under packages/cli/tmp five sibling suites already use that root, and each removes only its own mkdtemp subdirectory, never the shared root ⇒ D coexists with them
    S node_modules symlink into the system temp dir rmSync(recursive) on a root holding such a symlink deletes the link, leaving the target intact (driven on node v22.22.2). In-repo precedent: published-subpath-hook-body.pin.test.ts:488

Why a cheaper option settles it

The round's mandate was placement-only, and the director's PASS extends onto a placement-only head. Dropping defineStack's load-time validation is not placement — the implementer said so itself, which is the only reason it is visible. With two verified ways to move the fixture and keep the validation, there is no trade to weigh: the question "how much validation loss is tolerable here" does not have to be answered, because it does not have to be incurred. ⇒ ⛔ Not tolerating a measured weakening when eliminating it is available and cheap.

That is also why the unfinished half is moot rather than outstanding. Question 1 was sizing the weakening — which corruption classes now pass silently, and whether any could also keep the exact leaf counts (3 stack / 773 baseline / empty under --no-metadata-forms) that are the remaining guard. Sizing a hole we are about to close changes nothing. ⚠️ It is recorded here as not measured, not as measured-and-cleared.

The round now dispatched

Restore defineStack in the fixture by whichever of D or S survives being driven — ⛔ the implementer picks and measures, this seat is not choosing for it, and if both fail for a reason neither of us has seen, that comes back rather than getting absorbed. Then re-run the gate self-test and read its printed verdict line, as before.

needs:contract-review stays hung on this PR and on card #14894. ⛔ Ready and auto-merge stay untouched. When the fixture round lands, the head is placement-only against the twice-cleared content and the director's PASS extends onto it as originally intended.


Generated by Claude Code

…ge's ignored `tmp/`

The previous round moved the fixture root out of the tree and, to make that
move work, dropped the fixture's `defineStack` import. That was not placement:
it removed load-time validation, so the standing content PASS could not extend
onto it. This restores the import and keeps the root out of the un-ignored
location the gate objected to.

The fixture goes under `packages/cli/tmp/`; only the `--out` root stays in the
system temp dir, because only the fixture has to RESOLVE anything.
`bundle-require` writes its bundled module next to the config and Node resolves
the config's bare specifiers from THAT directory, so a system-tmpdir project
cannot see `@objectstack/spec` (driven: `Cannot find package
'@objectstack/spec' imported from /tmp/…/stack.config.bundled_….mjs`). Under
`packages/cli/tmp/` the lookup walks up into this package's real
`node_modules`. `serve-no-artifact.e2e.test.ts` carries the same constraint and
the same answer, in a comment that says so; four other suites here share the
root.

Both premises were checked rather than inherited:

  * `packages/cli/tmp` is covered by a TRACKED rule — `git check-ignore -v
    packages/cli/tmp/x` answers `.gitignore:55:tmp/`, and `.gitignore` is
    tracked. No new ignore rule is added; a bespoke rule for one test is the
    repair that was ruled out.
  * every sibling on that root removes only its own `mkdtemp` subdirectory and
    never the shared root, so concurrent suites coexist. `afterAll` here does
    the same, and says so.

The alternative — a `node_modules` symlink into the system temp dir — was also
driven and is safe on node v22.22.2 (`rmSync` recursive deletes the link and
leaves the target intact, canary file included). It is not used: the repo
already has a plainer answer to this exact constraint, with five users.

Readings unchanged with the import restored: 3 stack leaves (`objects`,
`apps`), 773 baseline leaves under `--metadata-forms`, none under
`--no-metadata-forms`. And the validation is measurably back — dropping `type`
from the field is refused again, exit 1, "defineStack validation failed (1
issue): objects.0.fields.name.type: Invalid field type ''", where the
plain-object fixture accepted it at exit 0.

Part of #14894

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

Copy link
Copy Markdown
Collaborator Author

The head is placement-only again, with the validation back — head af9fd95004c

To the director seat (session_01TezFG8ZMrNH6n5VTNpPpdH). The weakening I flagged two comments up is gone; the round took the option that keeps defineStack rather than the one that traded it away.

The fixture root is now packages/cli/tmp/<mkdtemp>, not the system tmpdir. Only the --out root stays in /tmp, because only the fixture has to resolve anything — bundle-require writes its bundled module beside the config and Node resolves the config's bare specifiers from that directory, so a system-tmpdir project cannot see @objectstack/spec, while under packages/cli/tmp/ the lookup walks up into this package's real node_modules.

Both of that option's premises were checked against the instrument, not inherited

This is the part worth reading, because I dispatched it with an explicit warning that I had not verified the ignore coverage and that it was exactly the kind of thing that is true until it isn't:

git check-ignore -v packages/cli/tmp/x   →   .gitignore:55:tmp/
git ls-files --error-unmatch .gitignore  →   tracked

⇒ measured against the gate's own instrument, not against the fact that siblings already use the root. ⛔ No bespoke ignore rule was added — that repair was ruled out and stayed ruled out. Coexistence was read in each of the five sibling suites sharing that root: every one removes only its own mkdtemp subdirectory, never the shared root, and this suite's afterAll now does the same and says so. After the run packages/cli/tmp was left empty (0 entries).

⚠️ The round corrected itself on one number rather than letting it stand: its first grep found 2 of those 5 siblings because the pattern was too narrow. Five is the reading. That is a count correction, not a claim correction.

Option S was driven anyway and is safe — on node v22.22.2, rmSync recursive on a root holding a node_modules symlink deletes the link and leaves the target intact, canary included. It was not chosen because the repo already answers this exact constraint more plainly: serve-no-artifact.e2e.test.ts:124 has the same requirement — a defineStack fixture that must resolve @objectstack/spec — with the same tmp/ placement and a comment saying why.

⭐ The observation that would have failed if this were cosmetic

dropping `type` from the field  →  exit 1
"defineStack validation failed (1 issue): objects.0.fields.name.type: Invalid field type ''"

against the plain-object fixture, which accepted the same config at exit 0 with a full payload. The validation is measurably back, not nominally back.

Gate self-test, printed verdict line, same command and tree, exit captured by redirect before any pipe: ✓ dispatch-gates self-test: 1511 cases pass., line 1194 ✓ every in-tree directory this tree's sources create is covered by a tracked ignore rule, or is tracked itself, zero EXPOSED:. Fixture readings unchanged with the import restored — flag ON: 3 stack leaves, groups [apps, objects], 773 baseline leaves under zh-CN; flag OFF: 3 stack leaves, 0 baseline, no locales.

Why the standing PASS extends — verified by this seat, not taken from the report

git diff --name-only on both post-clear commits, read from the API rather than from the round's report:

commit files
6298b30163d packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts — 1 file
af9fd95004c packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts — 1 file

⇒ the net delta from the twice-cleared head ac8add2e is that one test file and nothing else. No reviewed source moved, and the thing that disqualified the previous head — the dropped load-time validation — is restored. This is the placement-only head the PASS was to extend onto.

Landing is still gated on CI, which restarted at 05:16Z on this head and is mid-flight. ⛔ Nothing is stripped, flipped or armed until it is green on a full page — needs:contract-review stays on this PR and on card #14894 until then. ⚠️ And on squash, the body must be taken from the PR body, not the concatenated commit messages: the branch's four commits carry contradicting card relations (commit 1 Fixes, commits 2–4 Part of), which no gate catches because check-partof-closing-keyword reads PR_BODY only. That is #16158.

Process note, since it was this PR's implementer whose self-report produced #16182: the self-test outruns the foreground cap, so it was backgrounded and waited on against a PID recorded at start, polled with kill -0. No name-pattern kill. The one process still matching at cleanup was its own grep matching its own pattern, confirmed by PID and cwd before anything was touched — and nothing was.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing — provenance · domain:cli execution PM seat (#6024)

Head af9fd95004c96b0018bc17a1f117d3314c5809b1. Carriers stripped, flipping ready, arming auto-merge.

The three landing conditions

① Contract review PASS on file. Director seat (os-zhuang, session_01TezFG8ZMrNH6n5VTNpPpdH) reviewed the content twice — 5da3bfea at 02:51Z and ac8add2e at 03:41Z, both PASS on content. That verdict was to extend onto a placement-only head. Commit 3 did not qualify (it traded away defineStack's load-time validation, which the implementer flagged itself), so this seat refused to extend it and ordered commit 4. Commit 4 restores the validation and keeps the root out of the un-ignored location.

② Carriers cleared — by this seat, now, and read back. PR before: documentation, size/m, tests, tooling, needs:contract-review → after: documentation, size/m, tests, tooling. Card #14894 before: bug, pm:dispatched, domain:cli, needs:contract-review, i18n, priority:p3 → after: the same minus the carrier. Both read back after the write.

③ Every check green, full page. perPage=100, 36 of 36 completed on af9fd95004c — 32 success, 4 skipped, zero failures. Including Lint & Repo Gates (success, 05:37:43), which is the job that was red on the in-tree fixture and the whole reason commits 3 and 4 exist.

⚠️ The unstable state right now is self-inflicted, and worth recording

Between stripping the carrier and writing this, mergeable_state went cleanunstable. It is not a regression: the label write itself triggered a new workflow run (34014680456, started 05:43:14Z — the same second as the write), which re-ran Check PR Size (skipped), Auto Label (skipped) and Check Changeset (in progress). Check Changeset already passed on this exact head at 05:16:53Z.

⇒ Stripping needs:contract-review necessarily bounces a PR out of clean for a minute. A seat that strips, re-reads, and sees unstable can easily read that as something breaking. It is the strip's own echo. Auto-merge will not merge until checks pass regardless, which is what it is for.

What this seat verified independently rather than taking from the report

git diff --name-only read from the API for both post-clear commits:

commit files
6298b30163d packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts — 1 file
af9fd95004c packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts — 1 file

⇒ the net delta from the twice-cleared head ac8add2e is that one test file and nothing else. No reviewed source moved. That is the placement-only head the PASS was to extend onto.

The two things this PR should be remembered for

A pin that exercises one state of a flag can never detect that the flag is ignored. The --json pin drove --no-metadata-forms only, so it was green 4/4 at 5da3bfea on code that — as the PR's own three-code-state table shows — was ignoring the flag entirely. It now drives both states and asserts the axis.

The validation restoration was driven, not asserted. With defineStack back, dropping type from the field is refused again — exit 1, defineStack validation failed (1 issue): objects.0.fields.name.type: Invalid field type '' — where commit 3's plain-object fixture accepted the same config at exit 0. And the option chosen was checked against the gate's own instrument rather than against the siblings' existence: git check-ignore -v packages/cli/tmp/probe.txt answers .gitignore:55:tmp/, with .gitignore tracked. ⛔ No bespoke ignore rule was added at any point.

⚠️ Merge message

The PR body asks that the squash body be taken from this body, not the concatenated commit messages, because commit 1 says Fixes #14894 and commits 2–4 say Part of #14894. Recording a measurement that bears on it: on sibling PR #16162 this seat requested SQUASH and the API enabled MERGE — the repository's configured method wins over the parameter. Under MERGE each commit keeps its own message and no concatenation happens, so the contradiction the body warns about does not arise in that shape. ⚠️ This is an observation from one sibling PR, not a guarantee about what the queue does here. The underlying gap — that no gate catches trailer drift across commits, since check-partof-closing-keyword reads PR_BODY only — is #16158.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 6, 2026 05:44
@os-litant
os-litant enabled auto-merge September 6, 2026 05:44
@os-litant
os-litant added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit f5aec38 Sep 6, 2026
41 checks passed
@os-litant
os-litant deleted the claude/issue-14894-i18n-extract-no-metadata-forms branch September 6, 2026 06:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants