Repository navigation
Commit 6d2da32
fix(cli): read a multi-package artifact's package bodies in the capability preflight and the translation readers (#22285)
Fixes #22238
Clause-②: no
Fixes #22189
Folded into one PR by the seat's claim (comment 6058224414, posted on
both cards). #22190, the producer-side member of the same family,
remains open and is not touched here.
## What was wrong
`composeStacks([a, b], { manifest: 'preserve' })` emits each definition
once, inside the body of the package that owns it (ADR-0130 D4,
2026-09-22 addendum). The artifact's top level keeps `manifest`,
`packages` and `i18n`, and nothing a package owns. Five
`@objectstack/cli` readers looked at that top level alone, so on a
two-package app they judged nothing and answered clean.
## What changed
- **Capability preflight** (`os validate`, `os build`; #22189). New
`preflightDeclaredCapabilities` in `utils/capability-preflight.ts`. A
top-level `requires` wins whenever present, which is the
`resolveStackCollection` rule, so every single-package stack and the
additive shape are read exactly as before. When the top level declares
none, each `packages[]` body's `requires` is classified and each finding
is attributed to its package. `renderCapabilityMessage` prefixes an
attributed finding with `package 'ID' — `, the spelling the per-package
author-time pass uses. Both doors hand it the `artifactPackages` entries
they already compute; the module does not import `artifact-packages.ts`,
which would load `@objectstack/lint` into `os serve`.
- **Translation coverage** (`os lint`, `os i18n check`; #22238):
`computeI18nCoverage(authoringRuleUnionStack(normalized), …)`.
- **`os i18n extract`**:
`extractTranslations(authoringRuleUnionStack(normalized), …)`.
- **Undeclared-authoring-key walk** (`os validate`, `os build`):
`lintUnknownAuthoringKeys(authoringRuleUnionStack(normalized), …)`. The
stack-key lint beside it stays on the envelope (table below).
- **Pins**: `test/normalized-call-sites.test.ts` (unit, the
enumeration), `test/package-union-readers.test.ts` (integration, the
real commands), `test/capability-preflight.test.ts` (unit, attribution),
and the gate-parity roster row renamed to
`preflightDeclaredCapabilities`
(`test/validate-build-gate-parity.test.ts`).
- Changeset: `@objectstack/cli` patch. It says plainly that a
multi-package app that passed with an unprovidable capability or a
missing translation now fails, as the same app shipped as one package
always did.
## PM readings, measured
All readings use real fixtures: two `defineStack` packages under
`composeStacks(…, { manifest: 'preserve' })`, a service module plus an
app, and the same content in one `defineStack` as the control. Readings
were taken through the built `bin/run.js`, before on `8cbe255e` and
after on this branch.
**H1, reproduced before the fix.**
| probe | one package (control) | two packages, before | two packages,
after |
|---|---|---|---|
| `requires: ['ai']`, `os validate` | exit 1, Capability provider check
failed | **exit 0** | exit 1, `package 'com.probe.svc' — Capability "ai"
…` |
| same, `os build` | exit 1 | **exit 0** | exit 1, same line |
| missing zh-CN `pluralLabel`, `os lint --strict` | exit 1,
`i18n/missing-object` | **exit 0**, All checks passed | exit 1, the same
finding at the same path |
| same, `os i18n check` | zh-CN 7/8, 1 missing | **0/0 keys, 100%** |
zh-CN 7/8, 1 missing |
| same, `os i18n check --strict` | exit 1 | **exit 0** | exit 1 |
| `os i18n extract --json` | 6 app keys per locale | **0** | 6 per
locale |
| undeclared connector key (`strict: false` package), `os validate
--json` / `os build --json` `warnings` | 1 | **0** | 1 |
The emitted two-package artifact's top level was `manifest,packages`,
with `requires: ["ai"]` only in `packages[0]` (`com.probe.svc`).
**H2.** `authoringRuleUnionStack` measured on the fixtures. It keeps
`i18n`, an envelope key it never touches. It folds `translations` and
every collection back in, so coverage, extraction and the key walk take
it as is. It also folds `requires`, but as one concatenated list with
the package lost, which cannot name a package. The preflight therefore
reads the per-package bodies instead. A single-package stack comes back
by identity (`identity: true` on both controls).
**H3.** A refusal on a two-package artifact names its package: `package
'com.probe.svc' — Capability "ai" resolves to @objectstack/service-ai,
…`, in the text face and in `--json` `errors[].message`. The `{ token,
message }` record shape is unchanged. The single-package text is
byte-identical (H6).
**H4.** The hierarchy-security advisory is the same reader: it is the
preflight's `installable` warning for
`@objectstack/security-enterprise`. Before the fix it was printed for
one package and absent (count 0) for two. After, it appears on both
doors as `package 'com.probe.app' — Capability "hierarchy-security" is
provided by …`. It is fixed by the same change.
**H6, single-package parity.** 17 outputs (`os validate` / `os build`,
text and `--json`, over the capability, advisory and i18n controls; `os
lint --strict` text and `--json`; `os i18n check` plain, `--json` and
`--strict`). Each output was compared before and after with only the
timing tokens normalized: **17 of 17 identical, same exit codes**. The
only bytes that moved were `"duration": N`.
**Malformed `packages` (found while measuring).** A non-array `packages`
(`strict: false`) on `os validate` was refused before by the schema
parse (`invalid_type`); it is now refused one step earlier by the
resolver's `INVALID_ARTIFACT_PACKAGES`, still exit 1. `os i18n check`
and `os i18n extract` used to accept it with exit 0 and zero keys; they
now refuse it with exit 1 and `INVALID_ARTIFACT_PACKAGES`, the ruling-A
direction the other CLI readers already follow. `os build` is unchanged:
its parse runs first.
## H5: every `(normalized` call site in `packages/cli/src/commands`
There were 19 hits on `8cbe255e` (`git grep -n "(normalized"`). Two are
in test files: `dev-default-db.test.ts:43` is a string in a test title,
and `migrate/meta.report-order.test.ts:138` is a test calling
`applyMetaMigrations` on its own fixture. The 17 non-test sites, at this
branch's head:
| site | class | reads | why |
|---|---|---|---|
| `compile.ts:330` `lowerCallables(normalized` | (a) | packages | lowers
the top level and each `packages[i].manifest` body itself
(`lower-callables.ts`) |
| `compile.ts:541` `authoringRuleUnionStack(normalized` | (a) | union |
the rule table's `normalized` tier: it is the fold |
| `compile.ts:828` `lintUnknownStackKeys(normalized` | (c) | top level |
judges the envelope's own keys; a package body is closed (inherits the
manifest's strict close), so an undeclared key there is refused by the
parse, never dropped |
| `compile.ts:829` `lintUnknownAuthoringKeys(…` | **(b)** | union |
measured blind on two packages (table above); moved here |
| `validate.ts:289` `lintUnknownStackKeys(normalized` | (c) | top level
| as compile.ts |
| `validate.ts:290` `lintUnknownAuthoringKeys(…` | **(b)** | union | as
compile.ts |
| `validate.ts:346` `lowerCallables(normalized` | (a) | packages | as
compile.ts |
| `validate.ts:529` `authoringRuleUnionStack(normalized` | (a) | union |
as compile.ts |
| `lint.ts:1005` `resolveJsxGateManifest(normalized` | (a) | union |
counts pages over the fold and each body (`sdui-manifest.ts`) |
| `lint.ts:1006` `lintConfig(normalized` | (a) | union | folds
`authoringRuleUnionStack` on entry |
| `lint.ts:1031` `computeI18nCoverage(…` | **(b)** | union | the defect
of #22238 |
| `lint.ts:1044` `scoreMetadata(normalized` | (a) | union | a
whole-stack parse plus `lintConfig`, which folds |
| `i18n/check.ts:161` `computeI18nCoverage(…` | **(b)** | union | the
defect of #22238 |
| `i18n/extract.ts:259` `(normalized …).i18n` | (c) | top level | `i18n`
is an envelope key (compose disposition `single`): kept at the top
level, carried by no body |
| `i18n/extract.ts:294` `extractTranslations(…` | **(b)** | union |
measured blind on two packages; moved here |
| `migrate/meta.ts:1062` `applyMetaMigrations(normalized` | (c) | top
level | replays the chain over the AUTHORED stack; `--write` edits
source at the paths it reports, which a folded union would not hold.
Which bodies a conversion reaches is the chain's own walk (see
Acceptance notes) |
| `migrate/meta.ts:1072` `planProtocolRange(normalized` | (c) | top
level | reads the handshake range off `manifest`, where the load seam
reads it (`AppPlugin`: `bundle.manifest \|\| bundle`); a `preserve`
artifact keeps `manifest` at the top level |
(a) already read the union or the bodies; (b) moved onto the union here;
(c) top level on purpose. The capability preflight reads
`config.requires`, not `normalized`, so it is not a row; its per-package
reading is pinned separately.
`test/normalized-call-sites.test.ts` holds the table. It names each site
by file and by the callee chain wrapping `normalized` (for example
`computeI18nCoverage(authoringRuleUnionStack(normalized`), never by line
or count. It fails on an unclassified site, on a stale row, and on a row
whose verdict disagrees with its spelling. Comments and string bodies
are masked; the scanner's own cases cover a spread call, a grouping
paren, a method call and prose.
## Ablation (run from the committed fix, restored to the HEAD blob)
Each leg used `scripts/ablation-replace.mjs` in wrap mode: the anchor
had to hit, the on-disk counts went 1 → 0 and 0 → 1, the blob changed,
then the restore was proven by blob equality and an empty `git diff
HEAD`. No `dist/` leg applies: the integration pins spawn the CLI from
`src/` through `bin/run-dev.js`, and the enumeration pin reads source
text.
1. `lint.ts` coverage routed back to `computeI18nCoverage(normalized,
{`, blob `dad7d827` → `2acc4f7d`. Result: **4 red, 8 green**. Red were
the `os lint --strict` pin (`lintTwo` back to `"passed": true, "issues":
[]`) and three enumeration assertions (`lint.ts ::
computeI18nCoverage(normalized` unclassified, its row stale, the
positive control). Restored: blob `dad7d827` == HEAD.
2. `validate.ts` preflight handed `packages: []`, blob `3f2d706b` →
`7818f8b0`. Result: **1 red, 6 green**. Red was the `os validate --json`
pin (exit 0 where 1 is pinned). The `os build` pin stayed green because
its door was not ablated, so the pins are door-specific. Restored: blob
`3f2d706b` == HEAD.
## Verification (head `7bba74ec`)
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: 265 files, **3925 passed**.
- `pnpm --filter @objectstack/cli typecheck`: exit 0.
`check:test-typecheck` holds 3 files / 28 errors / 6 pinned signatures,
unchanged.
- `vitest run --project integration test/package-union-readers.test.ts`:
**7 passed** (46s). This is the integration file this PR adds. The rest
of the integration tier is CI's.
- The 65 gate commands `node scripts/pm/dispatch-gates.mjs --commands`
derives (identical to the dispatch's list): **65 of 65 exit 0**.
`check:i18n-coverage` (13 configs, 621 baselined, none new) and
`check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`,
unbuilt packages); after the prerequisite builds, both reran and exited
0. `dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
- Full `pnpm lint`: exit 0.
- `origin/main` moved 3 commits during the run (spec flow value slots,
docs, one unrelated CLI integration test). None touches a file here.
`git merge-tree` is clean, and CI's merge ref covers the rest.
## Acceptance notes
- **`os serve` / `os dev` read `requires` at the top level only**
(`serve.ts:2847`), the same family outside this card's surface. Measured
with a bounded boot: one package with `requires: ['automation']` logs
`Plugin loaded: com.objectstack.service-automation`, while the
two-package app whose service package declares it logs `Optional service
not present: automation`. The same top-level read sits in
`utils/schema-migration-plugins.ts:1453` (`os migrate` host-config
capability providers) and `utils/scaffold-wiring.ts:118` (the `os
generate` missing-capability hint); those two were not measured.
Reported to the seat for filing, not fixed here.
- **`os migrate meta` on a composed project** whose package carries a
retired spelling exits 1 with `STACK_PROVENANCE_MISSING`, naming a stack
that is wrapped in `defineStack`. The authored-source load hands
`composeStacks` an unbuilt stack. The one-package control exits 0 and
applies the conversion. So `applyMetaMigrations` is never reached there,
and whether the chain's conversions reach package bodies (the shared
walker in spec's `conversions/walk.ts` does not descend into
`packages[]`; the manifest-level entries do) could not be measured
through the door. Reported to the seat.
- The `package 'ID' — ` prefix is now spelled in two places:
`artifact-packages.ts`'s prefixer for author-time findings, and
`capability-preflight.ts` for capability messages. They are separate
surfaces, so the module stays free of `@objectstack/lint`.
- `os i18n extract` writes one bundle for all packages: the union, as
the pre-addendum flattened artifact gave. Per-package bundle emission is
not attempted.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 238222d commit 6d2da32
11 files changed
Lines changed: 778 additions & 18 deletions
File tree
- .changeset
- packages/cli
- src
- commands
- i18n
- utils
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
| 26 | + | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| |||
750 | 750 | | |
751 | 751 | | |
752 | 752 | | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
753 | 758 | | |
754 | | - | |
755 | | - | |
756 | | - | |
757 | | - | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
758 | 762 | | |
759 | 763 | | |
760 | 764 | | |
| |||
816 | 820 | | |
817 | 821 | | |
818 | 822 | | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
819 | 827 | | |
820 | 828 | | |
821 | | - | |
| 829 | + | |
822 | 830 | | |
823 | 831 | | |
824 | 832 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
60 | 61 | | |
61 | 62 | | |
62 | 63 | | |
| |||
153 | 154 | | |
154 | 155 | | |
155 | 156 | | |
156 | | - | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
157 | 162 | | |
158 | 163 | | |
159 | 164 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
285 | 286 | | |
286 | 287 | | |
287 | 288 | | |
288 | | - | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
289 | 295 | | |
290 | 296 | | |
291 | 297 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1019 | 1019 | | |
1020 | 1020 | | |
1021 | 1021 | | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
1022 | 1029 | | |
1023 | 1030 | | |
1024 | | - | |
| 1031 | + | |
1025 | 1032 | | |
1026 | 1033 | | |
1027 | 1034 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
| 26 | + | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| |||
279 | 279 | | |
280 | 280 | | |
281 | 281 | | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
282 | 288 | | |
283 | 289 | | |
284 | | - | |
| 290 | + | |
285 | 291 | | |
286 | 292 | | |
287 | 293 | | |
| |||
687 | 693 | | |
688 | 694 | | |
689 | 695 | | |
690 | | - | |
691 | | - | |
692 | | - | |
693 | | - | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
694 | 702 | | |
695 | 703 | | |
696 | 704 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
68 | 73 | | |
69 | | - | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
70 | 81 | | |
71 | 82 | | |
72 | 83 | | |
| |||
149 | 160 | | |
150 | 161 | | |
151 | 162 | | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
152 | 233 | | |
153 | 234 | | |
154 | 235 | | |
| |||
0 commit comments