fix(cli): widen check:app-nav-i18n from one app to the declared platform-app population - #17972
Conversation
…orm-app population
The gate advertises "every id labelled in every locale" and was structurally
blind to a whole app. Measured, not inferred: it printed a byte-identical
`OK (10 contributor(s), 54 merged setup nav id(s), 4 locale(s), ...)` line both
before and after the Account app's contributed `nav_connect_agent` label landed.
Six sites narrowed it to `setup`, and only three were the obvious filters:
- the contribution filter, the app-shell filter, the merged-app lookup;
- the LOCALE-FILE lookup `data.apps.<app>.navigation` -- widening the first
three without this one collects `account` ids and then looks for their
labels under `apps.setup.navigation`;
- the build prerequisite, a package path hard-coded to `@objectstack/setup`;
- the CONTRIBUTORS roster, which booted no package registering the Account
shell, so `account` had no merged app to judge at all.
The population is now declared with the criterion that decides it: an app is
judged iff the ADR-0048 platform-app loop registers its shell by default AND at
least one package contributes navigation into it at runtime. That is why
`studio` (static nav, not default-loaded, already owned by
`app-nav-translation-parity.test.ts`) and `crm_app` (an example app with no
locale source here) are out.
The per-contributor "landed at least one nav id" invariant is applied PER APP.
A flat union across apps reads as the natural generalisation and is a softening:
`@objectstack/mcp` serves both apps, and a union keeps it passing on its
`account` id alone after its `setup` contribution silently stops -- the exact
"fewer ids means fewer checks" false green that invariant exists to catch.
`--self-test` carries the union as a negative control.
Every verdict, the refusal advisory and the pass line now name the app they are
actually about, and the pass line carries a per-app id count -- a collapsed
total would rebuild the very blindness this change removes.
The `setup` judgement is unchanged: 54 merged ids before, 54 after.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewPR #17972 · card #17891 · head
① ⭐ The finding that overturns BOTH earlier counts — a narrowing by absenceTriage named three narrowing sites. This seat's claim pre-check found a fourth (
Verified here: ⭐ ⛔ No grep of A sixth diagnostic string was found the same way: ② The population is now decided by a written criterion, ⛔ not by a listThe order's acceptance item 1 demanded the criterion, not just the sites. Delivered at ③ ⭐ The trap it guarded, which the order did not nameThe per-contributor “landed at least one nav id” invariant had to stay per app. A flat union across apps reads as the obvious generalisation and is a softening of the existing The delivery does not merely avoid it — it constructs the union as a negative control in ④ Two directions, with the one moving number declared rather than smoothed⭐ The Must-catch (relayed): delete one of #17759's four
⑤ Fences — measured⛔ No new translation key in any locale. ⛔ The existing ⑥ Declared gaps, ⛔ not smoothed⭐ The one worth repeating: Also declared: the integration tier (no spawn entry, no Verdict and what remainsPASS. The card asked for one gate's population to be widened; what landed is a gate that states the rule deciding its own population, names the app it is actually failing on, and pins the wrong generalisation as wrong.
⇒ This seat cannot re-run them —
Generated by Claude Code |
Fixes #17891
Clause-②: no
packages/cli/scripts/check-app-nav-i18n.mjsis named "every id labelled in every locale" and was structurally blind to an entire app. It now judges the declared platform-app population — Setup and Account — with the criterion that decides that population written down beside it.1. Acceptance item 1 — the population, declared before any edit
This is the third independent count. Triage named 3 sites plus the declaration; the claim comment's pre-check found 2 more. Re-derived here on
origin/mainbdb247d9e, over the 662-line file.APP_NAMEoccurs 10× (grep -c APP_NAME= 10), and the full census of narrowing is wider than the spelling.Behavioural — these decide what is measured
:109const APP_NAME = 'setup'— the declaration every filter below reads:581if (contribution?.app !== APP_NAME) continue— the contribution filter:590if (app?.name !== APP_NAME) continue— the app-shell filter:601engine.registry.getApp(APP_NAME)— the merged-app lookup:628data?.apps?.[APP_NAME]?.navigation— the locale-file lookup:474join(CLI_ROOT,'node_modules','@objectstack','setup','dist','index.mjs')— a hard-coded package probe, not spelledAPP_NAMEat all:173–:243CONTRIBUTORSroster itself — ten entries, none of which registers the Account app shell. Narrowing by absence: nosetupliteral, noAPP_NAME, and no grep of either finds it⭐ The roster is a real seventh site, not a refinement of the others. Widening
:581/:590/:601/:628/:474alone leavesengine.registry.getApp('account')returningundefined, so the gate reports "theaccountapp is not registered at all" instead of judging a single label. The shell ships in@objectstack/account(createAccountAppPlugin), whichos serveloads in the ADR-0048 platform-app loop (packages/cli/src/commands/serve.ts:4001) and whichpackages/clialready depends on.Diagnostic — these decide which app the gate reports on
:162·:614·:621·:652·:660are the fiveAPP_NAME-interpolated strings the claim pre-check listed. ⭐ There is a sixth neither count named::496, insidebuildPrerequisiteText— "This gate boots the real Setup composition". It is plain prose rather than an interpolation, so a grep forAPP_NAMEmisses it, and after widening it would have described a Setup-only run while refusing on a missing Account build. All six now name the app they are actually about.Checked and rejected as sites — stated so the next reader does not re-open them
:519SetupAppTranslations— a setup-named export that in fact carries every app:en.tsdeclaresapps.accountandapps.setupside by side. A naming hazard, not a narrowing; the locale source was already app-agnostic and:628's subscript was the whole of it. Renaming it would touchpackages/platform-objects/**, which is fenced (app-nav-translation-parity 的反方向断言只走静态声明 —— 贡献式导航条目在它眼里不存在,而天真地扩到 ACCOUNT_APP 会把一个正确的翻译键判成孤儿(从 #17891 拆出) #17930).:262·:290–:320— self-test fixtures named for Setup that exercisecollectNavIds/missingLabels, both of which take the tree and the locale subtree as arguments. Already app-agnostic.:364·:412— the refusal fixture pins that the advisory names the path it was handed, deliberately not a re-derived one.The criterion — stated, not defaulted
An app is judged by this gate iff both halves hold:
packages/cli/src/commands/serve.ts; andmanifest.navigationContributions[].app), so a static walk cannot see every label it renders.Today that resolves to exactly
setupandaccount. ⛔ "All apps" is not the criterion, and each half excludes something real:studiofails both. The loop deliberately does not load it ("@objectstack/studio is intentionally NOT default-loaded"), andSTUDIO_APPdeclares its whole navigation statically with nothing contributing into it —app-nav-translation-parity.test.tsalready owns that verdict by a static walk. Adding it here would be a second owner for one route and would drag in a package the composition root never boots.crm_appfails (a) and has no locale source here at all: it is anexamples/app andSetupAppTranslationscarries noapps.crm_appsubtree to compare against.accountsatisfied both halves all along. It was invisible anyway.2. Acceptance item 2 — both directions fire
Every exit code below was captured before any pipe (
cmd > log 2>&1; EXIT=$?).The mutation: delete #17759's
enAccountnav_connect_agententry frompackages/platform-objects/src/apps/translations/en.ts, rebuild@objectstack/platform-objects, and prove the deletion reached the artifact the gate consumes. It was reverted; the diff of this PR does not touch that file.nav_connect_agentoccurrences in the source3 → 1; unique marker1 → 0;git diff --stat= 1 file, 10 deletionsos-verify-lock … VERDICT command-exit 0 · held the lock 27sablation-dist-preflight … --absent→ exit 0, "marker absent from all 66 built files"apps.account.navigation — locale `en` has no label for 1 runtime-merged nav id(s): nav_connect_agent contributed by @objectstack/mcp (CONNECT_AGENT_UI_BUNDLE) — author's literal "Connect an Agent"cmpexit 0, sha25660a5f61d…on both. That line ischeck-app-nav-i18n: OK (10 contributor(s), 54 merged `setup` nav id(s), 4 locale(s), every id labelled in every locale).— the card's reading, reproduced here rather than quotedgit checkout HEAD --→ blobda6690b9…equals the HEAD blob;git diff HEADempty; whole-treegit status --porcelainemptyVERDICT command-exit 0 · held the lock 28s; preflight (present mode) exit 0, "marker present in 6 built files"; ⛔ not skipped — a marker left indist/would have kept the mutation live for every later run in this worktreeMust not over-fire, unmutated tree:
The
setupverdict is unchanged and its count is unmoved: 54 → 54, exit 0 → exit 0. The contributor count moves10 → 11because the Account shell is a new roster entry — that is the one number this change necessarily moves, and it is stated rather than smoothed.account: 12is the eleven idsACCOUNT_APPdeclares plus the one@objectstack/mcpcontributes.3. The trap in widening, and the guard against it
The per-contributor "landed at least one nav id" invariant is the anti-false-green half of this gate. Widening it into one flat union across apps reads as the obvious generalisation and is a softening:
@objectstack/mcpserves both apps, so a union keeps it passing on itsaccountid alone after itssetupcontribution has silently stopped — the exact "fewer ids means fewer checks" false green the invariant exists to catch, restated one app wider.So the invariant is applied per app: each
CONTRIBUTORSentry declares itsapps, andcontributorsWithNoNavIdsis called once per app overcontributionsForApp(...)— the original helper is untouched.--self-testcarries the union as a negative control: it constructs the union projection and requires it to be the spelling that goes green, so the pin cannot pass by tautology.New
--self-testcoverage, all with their own negative controls: the union softening; a verdict that names the wrongappssubtree; a pass line that cannot notice an app leaving the population; and three reachability pins tyingAPPS,CONTRIBUTORSand the shell packages together.4. Published surface — measured, not assumed
Widening added
contributionsForAppandsummaryTextas module exports of the script. Measured againstpackages/cli/package.json:filesis["dist","README.md","CHANGELOG.md"]andexportsnames only.,./console,./hook-bodyand./package.json.packages/cli/scripts/**is not shipped and is not an entry point, and the file already exportedcollectNavIds,missingLabelsandcontributorsWithNoNavIdsbefore this PR. ⇒ No export was added to a published entry point, so the re-declaration the dispatch order reserved to the seat is not triggered. The changeset is written because the dispatch order requires one for@objectstack/cli; by the published-surface test alone nothing shipped moves.5. Fences observed
packages/platform-objects/**is not in this diff (app-nav-translation-parity 的反方向断言只走静态声明 —— 贡献式导航条目在它眼里不存在,而天真地扩到 ACCOUNT_APP 会把一个正确的翻译键判成孤儿(从 #17891 拆出) #17930 owns mechanism two). It was mutated and restored for the ablation only, proven byte-identical by blob hash and a whole-treegit status.nav_connect_agententry in the Account app has no translation key in any locale, so the same page renders 「连接智能体」 under Setup and "Connect an Agent" under Account for the same user #17759 already supplied the correct form; this is the last step of the standard repair order..claude/**,content/docs/releases/**: untouched.setupjudgement is never narrowed, skipped or softened — see §2 and §3.Acceptance notes
SetupAppTranslationsis a setup-named export carrying every app's subtree. Noted, not filed: the rename lands inpackages/platform-objects/**, which this card is fenced out of, and it is a naming observation rather than a defect — nothing resolves wrongly because of it. Carrier: none today; app-nav-translation-parity 的反方向断言只走静态声明 —— 贡献式导航条目在它眼里不存在,而天真地扩到 ACCOUNT_APP 会把一个正确的翻译键判成孤儿(从 #17891 拆出) #17930 is the next PR in that file's neighbourhood if a maintainer wants it swept.account: one booted composition cannot distinguish a gated-off contribution from a dead key, so the reverse direction for the new app stays outside this gate exactly as it does for Setup.packages/cli(os lint'snaming/namespace-prefixreports a legitimate cross-package name reuse as an intra-package duplicate, contradicting the ADR-0048 §3.4 sentence in its own message #17821 insrc/commands/, [finding] vitest 的 --project 过滤器落空即静默成功 —— 点名一个 integration 文件跑 --project unit,报它是通过的文件、执行零个用例,并把它从文件计数里减掉 #17853 invitest.config.ts). No file overlap. The vitest tier population is derived at config load, so tier file counts may move under this branch; ⛔ not reconciled here.6. Verification record
Every exit code captured before any pipe. All readings taken in the dedicated worktree at
04216260f, branched fromorigin/mainbdb247d9e.pnpm --filter @objectstack/cli run check:app-nav-i18n(self-test and real run)pnpm --workspace-concurrency=2 --filter '@objectstack/cli^...' build, under the shared heavy-verify lock, slotdev-17891VERDICT command-exit 0 · held the lock 405s)pnpm build --concurrency=2, under the lockVERDICT command-exit 0 · held the lock 473s; 73/73 tasks)pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2, under the lockpnpm --filter @objectstack/cli typecheck, under the lockpnpm lint(eslint . --no-inline-config), whole repo, not narrowednode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each run separately with its own exit code recordednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …over that recordpnpm check:nul-bytesplus a hand sweep of both changed files for every non-tab/newline C0 byte and DELpnpm check:dual-build-cjs-loadsreturned exit 3 —PREREQUISITE NOT METon its first run: it reads built output for every package, and only the@objectstack/clidependency closure was built at that point. ⛔ That is not a red gate and was not recorded as one — nothing was measured. The whole repo was then built and the family re-run to a real verdict: exit 0, 104 published require entry points across 67 packages.NOT MEASURED — declared, not smoothed over
packages/cli'stypecheckdoes not reach the changed file.packages/cli/tsconfig.jsondeclaresinclude: ["src"]with noallowJs, sotsc --noEmitnever compilespackages/cli/scripts/check-app-nav-i18n.mjs. Its green says nothing about this diff. What does cover the file:node --check, the script's own--self-test(which thecheck:app-nav-i18nscript runs before the real pass), and the real run itself.packages/cli'sintegrationtier was not run locally. This diff touches no spawn entry point, nobin/, notest/helpers/serve-process.tsand no driver or kernel boot path, so by the tier rule onlyunitis owed here. CI'spnpm testruns both.dispatch-gatesnames every one of them, and CI is their authority.origin/mainmoved during this run (bdb247d9e→225197cdbat the time of the derivation). This branch is not rebased onto it; the merge is CI's and the queue's to validate.Generated by Claude Code