Skip to content

Commit 28bff18

Browse files
fix(cli): os serve and seven sibling readers read a multi-package config's package-owned keys through its package bodies (#22321)
Fixes #22288 Clause-②: no ## What this changes A `composeStacks([…], { manifest: 'preserve' })` config carries every package-owned key (`requires`, `tiers`, `analyticsCubes`, `flows`, `objects`, …) once, inside the body of the package that declared it. Its top level carries none of them. `os serve` read the capability providers to mount from the top-level `requires` only (`serve.ts:2847` on `6729e107`), so a capability a package declares was not mounted. The card named two sibling readers. The enumeration this PR adds found the same top-level read in seven more readers in `packages/cli`. All of them are fixed here. Every reader now uses one rule: `resolveStackCollection` (`utils/stack-collections.ts`, the rule #22285 gave the build preflight), which takes the top-level value when the stack carries one and otherwise every package body's. Or it folds the stack with `authoringRuleUnionStack` where the stack is loaded. A stack with no `packages[]` reads exactly as before. `serve` imports only `stack-collections.ts` (core plus spec), so it stays off `artifact-packages.ts` and `@objectstack/lint`. | reader | file | change | |---|---|---| | `os serve` providers (`requires`) | `commands/serve.ts` | `stackDeclaredCapabilities(config)`, a new one-line export over `resolveStackCollection(…, 'requires')` | | `os serve` `tiers` | `commands/serve.ts` | `resolveStackCollection(config, 'tiers')` | | `os serve` analytics cubes | `commands/serve.ts` | the top level first (its array, then the legacy `cubes` spelling), then the bodies | | `os serve` declared-flow count (automation line) | `commands/serve.ts` | `resolveStackCollection(config, 'flows').length` | | `os migrate plan` / `apply` providers | `utils/schema-migration-plugins.ts` | `stackDeclaredCapabilities(config)` | | `os generate` missing-capability hint | `utils/scaffold-wiring.ts` | `declaredCapabilities` reads by the same rule; JSDoc rewritten (below) | | `os generate types` / `client` / `migration` | `commands/generate.ts` | the four readers fold the loaded config on entry | | `os doctor` | `commands/doctor.ts` | folds once where it normalizes the config | | `os diff` | `commands/diff.ts` | folds each side where it is loaded | | `os migrate meta` data-migration advice | `commands/migrate/meta.ts` | folds at the one call of `pendingDataMigrations` | ## Measured, through the doors Each door was run with bounded boots/runs of two-package fixtures and their one-package controls. BEFORE is `6729e107`, AFTER is this branch. The fixtures are the ones in the pins below. | door | two packages, before | two packages, after | one package (control, before = after) | |---|---|---|---| | `os serve` (config, no artifact), package `requires: ['automation']` | `Info: Optional service not present: automation`; banner 31 plugins, no `AutomationServicePlugin` | `Plugin loaded: com.objectstack.service-automation`; banner 32 plugins incl. `AutomationServicePlugin` | `Plugin loaded: com.objectstack.service-automation` | | `os serve`, package `requires: ['ai']` (no open-edition provider) | `✓ Server is ready` | exit 1 `✗ Capability "ai" resolves to @objectstack/service-ai, which is not available in the open edition …` | the same exit 1 line | | `os dev`, package `analyticsCubes` | `[Analytics] Service started with 0 cubes: (none)` | `… with 1 cubes: prb_note_cube` | `… with 1 cubes: prb_note_cube` | | `os dev`, package screen flow, no automation | no flow line | `⚠ Flows: 1 flow(s) declared but the automation engine is not enabled …` | the same line | | `os serve`, package `tiers` without `auth` | ignored: `com.objectstack.auth` mounted, ready | exit 1 `✗ This stack mounts no auth, …` | the same exit 1 | | `os migrate plan`, host plugin hard-depending on automation, package `requires: ['automation']` | exit 1 `✗ [Kernel] Dependency 'com.objectstack.service-automation' not found for plugin 'com.probe.connector'` | exit 0, `Composed AutomationServicePlugin for requires: ['automation'] …` | exit 0, same note | | `os generate flow done --object prb_ticket`, app package declares `['automation','triggers']` | `⚠ It will not run yet: objectstack.config.ts does not require 'automation', 'triggers'` and a `requires:` line to add | `✓ Reaches the stack`, no warning | no warning | | `os generate types --dry-run` | 0 record interfaces | 2 | 2 | | `os generate migration --format sql --dry-run` | 0 `CREATE TABLE` | 2 | 2 | | `os doctor` | no metadata check ran; `✅ Environment is healthy` | circular-dependency and unused-object checks ran (2 unused) | the same 2 | | `os diff --json`, one object added to the service package | `total: 0` | `total: 1`, `prb_extra` added | `total: 1` | | `os migrate meta --from 16 --json`, a `file` field in the service package | `dataMigrations: []` | `adr-0104-file-references` | `adr-0104-file-references` | ## The PM readings (H1–H5) - **H1, reproduced, and narrower than the card says.** Bare `os serve` on a two-package config reads `[]` (table, row 1). **`os dev` and `os start` reach the same line of code, but they do NOT reproduce the defect.** Both boot a compiled artifact: `os dev` always compiles, and `os start` auto-compiles when no artifact exists. `createStandaloneStack` resolves the artifact's `packages[]` (`resolveArtifactCollections`), and `mergeBootConfig` lays the resolved `requires` over the top level. Measured on `6729e107` with the same two-package fixture: `os dev` and `os start` each log `Plugin loaded: com.objectstack.service-automation` once and `Optional service not present` zero times. So does `os serve` with a built `dist/objectstack.json` beside the config. The `requires` defect is therefore confined to a config boot with no compiled artifact. Triage graded p1 on `os dev` / `os start` reach. On this measurement, the `requires` half does not reach them. The `tiers`, `analyticsCubes` and flow-count halves do reach `os dev` (table, rows 3–5), because the artifact path does not carry those keys. - **H2.** Serve calls the existing rule (`resolveStackCollection`) and does not copy it. The import stays off `artifact-packages.ts`. - **H3, measured, with two narrowings rather than one.** (1) A capability a package declares with no installed provider now stops an `os serve` config boot. This is table row 2, and the same exit 1 the one-package app always gave. `os dev` / `os start` already stopped there, since the artifact path made those tokens "declared". (2) A package's own `tiers` now apply on `os serve`, `os dev` and `os start` (row 5). The claim named one narrowing. Both are named in the changeset. Nothing in this repo boots a multi-package config whose packages declare `requires` or `tiers`. `examples/app-multi-package` declares neither, and no test boots a `preserve` config with either. - **H4.** Both readers read `[]` on a multi-package config, and both are fixed (rows 6 and 7). The `os generate` hint now reads the list a server mounts. Its JSDoc keeps the `defineStack` half true. On a one-package stack the list is its top-level `requires`, which is also what `defineStack`'s trigger rule reads. On a multi-package stack, `defineStack` judged each package against its own `requires` when the config loaded. A refusal there is the `load-failed` branch, reached before the hint reads anything, so the union is what decides whether the item runs. - **H5.** `test/normalized-call-sites.test.ts` gains a second table. It enumerates every member read of a package-owned key off a stack-named receiver in `packages/cli/src`. The key set comes from `packageOwnedCollectionKeys()` (now exported from `stack-collections.ts`, derived from the two schemas, 37 keys, envelope keys excluded). Every read is classified. A `resolved` row carries `evidence`: code that must be present in the file (comments and strings blanked), which proves the fold. A `top-level` row carries the reason. 53 reads in 15 files after the fix: 6 `top-level` rows (the build preflight's `config.requires` ×2, serve's `config.analyticsCubes` first leg and `config.docs`, `collect-docs`' artifact `stack.docs`, `scaffold-wiring`'s presence probe) and 12 `resolved` receivers. The grammar's bounds are written in the file: a stack held under another name, a computed key, a member chain, and a cast whose type has parentheses. None of those spellings reads a package-owned key in `src` today. ## Pins, and the tier each runs in - `test/serve-package-declared-capabilities.test.ts`: **integration tier (per-PR)**, `runServe(` boots. Covers `automation` mounted on two packages, the one-package control, the `ai` narrowing, cubes plus the flow line, and the `tiers` narrowing. 5 boots, about 60s under a shared box. Not named `*.e2e.test.ts`, so it gates PRs. - `test/package-owned-command-readers.test.ts`: **integration tier (per-PR)**, spawns `os doctor`, `os diff --json` and `os migrate meta --json`, two packages vs one. - `src/utils/schema-migrate.requires-providers.integration.test.ts`: **integration tier**, a second case where a package declares `requires`, on the real kernel boot. - `test/package-owned-readers.test.ts`: **unit tier**. `stackDeclaredCapabilities`, the `os generate` hint and the types/migration generators against real `composeStacks` output, each paired with one `defineStack`. - `test/normalized-call-sites.test.ts`: **unit tier**, the enumeration, with a scanner self-test (read forms, writes skipped, envelope keys and prose ignored) and a positive control on the real tree. ## Ablation (one-time; nothing permanent left behind) Run with `scripts/ablation-replace.mjs` (WRAP mode, the anchor must hit, restore on EXIT/INT/TERM), at `6a30bcff0`. No build leg: the scanner reads `src` and the spawned CLI runs `src` through tsx (`bin/run-dev.js`). - **serve.ts back to `Array.isArray((config as any).requires) ? … : []`**: anchor 1→0, blob `7a588141d660`→`4bf8b068950a`. Three tests red: `no read is unclassified`, naming `commands/serve.ts :: config.requires`; `two packages: the service package's automation is mounted`; and the `ai` narrowing. The one-package control and the cubes/tiers cases stayed green. Restored: blob `7a588141d660` == HEAD, `git diff HEAD` empty. - **scaffold-wiring.ts back to a top-level-only read**: two hint tests red in `package-owned-readers.test.ts`. Restored: blob `3ec828a90838` == HEAD, `git diff HEAD` empty. Direction observed: red, as expected. ## Verification Everything below ran at the merged head `a58d828b0`. That is this branch merged with `origin/main` at `dc4a5c630`, which brought #22304's `serve.ts` change. The merge was clean, and all four edits to `serve.ts` were checked present after it. The dists were rebuilt after the merge. - `pnpm --filter @objectstack/cli typecheck`: exit 0. That is `tsc --noEmit`, then `check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held`, so no new signature. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 267 files, 3943 tests passed. - `pnpm --filter @objectstack/cli exec vitest run --project integration`: 97 files, 922 passed, 2 skipped. This tier is owed locally because the diff adds integration-tier files and touches the serve boot path. - Gate union: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives the same 67 commands as the dispatch order lists. All 67 ran and exited 0. The `--ran` reconciliation reads `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. On the pre-merge tree, `check:dual-build-cjs-loads` and `check:i18n-coverage` first refused with `PREREQUISITE NOT MET` because some packages had no dist. Those dists were built and both gates re-ran green. - `pnpm lint` (full, `eslint . --no-inline-config`): exit 0. - Before the merge, at `d12d93891`: the unit tier (267/3943), the integration tier (96 files, 919 passed, 2 skipped), full lint and the gate union were all green as well. ## Acceptance notes (observations, not filed) - **Artifact-only boots.** `createStandaloneStack`'s result carries `requires`, `objects`, `manifest`, `permissions`, `positions` and `i18n`, but not `tiers`, `analyticsCubes` or `flows`. An `os start` with an artifact and no config therefore reads those three as empty even for a one-package artifact. Inferred from the code, not measured. That is a different shape from this card (not multi-package specific). Carrier: none. - **Doc embed lint.** `collectAndLintDocs`' comment says a doc already on a package body "is also in `docs` above, where `lintMetadataEmbeds` has judged it". That was true of the additive shape, but the 2026-09-22 addendum emits a body's docs only in the body, so a package body's inline docs may not reach the embed lint. Inferred, not measured. Carrier: none. - **Legacy spellings kept as they were.** Serve's `cubes` fallback, and `os generate`'s `data?.objects` fallback, are consumer-side spellings that predate this card. They are kept byte-for-byte in precedence, not endorsed. - **Wiring lines.** On a multi-package config, the `os generate` "Not wired" advice still says `inside defineStack({ … })` without naming which package. The `requires:` line it prints is the union of what the packages declare plus what is missing, so it never drops a declared token. - **#22190**, the producer-side member of this family, is not addressed here and remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe98cc6 commit 28bff18

14 files changed

Lines changed: 998 additions & 34 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
The CLI reads a multi-package app's package-owned keys (`requires`, `tiers`, `analyticsCubes`, `flows`, `objects` and the rest) out of its package bodies, as it reads a one-package app's top level.
6+
7+
Clause-②: no
8+
9+
A `composeStacks([…], { manifest: 'preserve' })` config carries each of these keys only inside the body of the package that declared it. Its top level carries none of them. These readers looked at the top level only, so they read nothing on such an app, and did so silently:
10+
11+
- **`os serve` capability providers.** A config boot with no compiled artifact did not mount the provider a package declares in `requires` (`automation`, say). The boot logged `Optional service not present` and the features that need the provider were absent. `os dev`, `os start`, and `os serve` beside a built `dist/objectstack.json` already mounted it, because they boot the artifact.
12+
- **`os serve` tiers.** A package's own `tiers` were ignored on `os serve`, `os dev` and `os start`. The preset's tiers were used instead.
13+
- **`os serve` analytics cubes.** The always-on analytics provider started with none of the cubes a package declares in `analyticsCubes`, on every boot of such an app.
14+
- **`os serve` flow line.** The ready banner's line `N flow(s) declared but the automation engine is not enabled` never counted a package's flows, so it did not appear.
15+
- **`os migrate plan` / `apply`.** A host plugin that hard-depends on a provider a package's `requires` supplies could not be ordered. Both commands exited 1 with `Dependency '…' not found for plugin '…'`.
16+
- **`os generate`.** After writing a flow, it warned that the config does not require `automation` and `triggers` even when a package declares both. `os generate types`, `client` and `migration` emitted none of a package's objects.
17+
- **`os doctor`, `os diff`, `os migrate meta`.** `os doctor` skipped every metadata check and reported a healthy environment. `os diff` reported no change for an object added to a package. `os migrate meta` did not list the data migration a package's file fields need.
18+
19+
Each now reads by one rule: the top-level value when the stack carries one, otherwise every package body's. This is the rule `os validate` and `os build` already use for `requires`. A stack with no `packages[]` reads exactly as before.
20+
21+
**Two behaviours narrow.** In each, a two-package app is now refused where the same declarations in one `defineStack` were always refused:
22+
23+
- A capability a package declares in `requires` with no installed provider (`requires: ['ai']` in the open edition, say) now stops an `os serve` boot of the config, with the same `Capability "…"` error. `os dev` and `os start` already stopped there.
24+
- A package's `tiers` now apply. A package that declares `tiers` without `auth` gets a stack with no auth, which `os serve` refuses to boot with its data API, as it always has for one package. To keep the preset's tiers, remove `tiers` from the package, or list every tier it needs.

‎packages/cli/src/commands/diff.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Args, Command, Flags } from '@oclif/core';
44
import chalk from 'chalk';
55
import { loadConfig } from '../utils/config.js';
6+
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
67
import {
78
printHeader,
89
printSuccess,
@@ -214,8 +215,13 @@ export default class Diff extends Command {
214215
}
215216

216217
try {
217-
const { config: beforeConfig } = await loadConfig(beforePath);
218-
const { config: afterConfig } = await loadConfig(afterPath);
218+
// [#22288] Each side folded before any category is read: a
219+
// multi-package `preserve` config carries its objects, views, flows
220+
// and the rest in its package bodies, none at its top level, so a diff
221+
// of two such configs compared two empty stacks and reported nothing.
222+
// A stack with no `packages[]` comes back by identity.
223+
const beforeConfig: any = authoringRuleUnionStack((await loadConfig(beforePath)).config);
224+
const afterConfig: any = authoringRuleUnionStack((await loadConfig(afterPath)).config);
219225

220226
if (!flags.json) {
221227
printInfo(`Before: ${chalk.white(beforePath)}`);

‎packages/cli/src/commands/doctor.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { normalizeStackInput } from '@objectstack/spec';
1010
import { referenceCarrierOf } from '@objectstack/spec/data';
1111
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
1212
import { loadConfig, configExists } from '../utils/config.js';
13+
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1314
import { cwdConfigJoinsBoot, resolveArtifactBootSource } from '../utils/artifact-precedence.js';
1415
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
1516
// #5644 — "the optional package is not installed" and "it is installed and
@@ -2312,7 +2313,13 @@ export default class Doctor extends Command {
23122313
// `withDotenvOverlay` would revert the overlay while `loadConfig()`'s
23132314
// promise was still pending — applied, then removed, never read.
23142315
const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig());
2315-
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);
2316+
// [#22288] Folded once, here, before any analysis below reads a
2317+
// collection: a multi-package `preserve` config carries every object,
2318+
// view, flow, app and dashboard in its package bodies and none at its
2319+
// top level, so every check below skipped such an app and doctor
2320+
// printed a clean bill of health. Envelope keys (`manifest`) are
2321+
// untouched, and a stack with no `packages[]` comes back by identity.
2322+
const config: any = authoringRuleUnionStack(normalizeStackInput(rawConfig as Record<string, unknown>));
23162323

23172324
// Protocol drift: installed platform outside the range the app declares.
23182325
printStep('Checking platform protocol version...');

‎packages/cli/src/commands/generate.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,7 +1334,12 @@ export function generateTypesFromConfig(config: Record<string, unknown>): string
13341334

13351335
// Extract objects from config (supports both top-level and nested)
13361336
const objects: Record<string, unknown>[] = [];
1337-
const rawObjects = (config as any).objects ?? (config as any).data?.objects ?? {};
1337+
// [#22288] Read off the folded stack: a multi-package `preserve` config
1338+
// carries its objects in its package bodies and none at its top level, so
1339+
// this emitted nothing for such an app. A stack with no `packages[]` comes
1340+
// back by identity.
1341+
const stack = authoringRuleUnionStack(config) as any;
1342+
const rawObjects = stack.objects ?? stack.data?.objects ?? {};
13381343

13391344
if (Array.isArray(rawObjects)) {
13401345
objects.push(...rawObjects);
@@ -2245,7 +2250,9 @@ function generateClientFromConfig(config: Record<string, unknown>): string {
22452250
];
22462251

22472252
const objects: Record<string, unknown>[] = [];
2248-
const rawObjects = (config as any).objects ?? (config as any).data?.objects ?? {};
2253+
// [#22288] The folded stack, as in `generateTypesFromConfig`.
2254+
const stack = authoringRuleUnionStack(config) as any;
2255+
const rawObjects = stack.objects ?? stack.data?.objects ?? {};
22492256

22502257
if (Array.isArray(rawObjects)) {
22512258
objects.push(...rawObjects);
@@ -3482,7 +3489,9 @@ export function generateMigrationSql(config: Record<string, unknown>): string {
34823489
];
34833490

34843491
const objects: Record<string, unknown>[] = [];
3485-
const rawObjects = (config as any).objects ?? (config as any).data?.objects ?? {};
3492+
// [#22288] The folded stack, as in `generateTypesFromConfig`.
3493+
const stack = authoringRuleUnionStack(config) as any;
3494+
const rawObjects = stack.objects ?? stack.data?.objects ?? {};
34863495

34873496
if (Array.isArray(rawObjects)) {
34883497
objects.push(...rawObjects);
@@ -3653,7 +3662,9 @@ export function generateMigrationTs(config: Record<string, unknown>): string {
36533662
];
36543663

36553664
const objects: Record<string, unknown>[] = [];
3656-
const rawObjects = (config as any).objects ?? (config as any).data?.objects ?? {};
3665+
// [#22288] The folded stack, as in `generateTypesFromConfig`.
3666+
const stack = authoringRuleUnionStack(config) as any;
3667+
const rawObjects = stack.objects ?? stack.data?.objects ?? {};
36573668

36583669
if (Array.isArray(rawObjects)) {
36593670
objects.push(...rawObjects);

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
1919
import { FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, STRUCTURED_JSON_TYPES } from '@objectstack/spec/data';
2020
import { FILE_REFERENCES_MIGRATION_ID, VALUE_SHAPES_MIGRATION_ID } from '@objectstack/spec/system';
2121
import { loadConfig } from '../../utils/config.js';
22+
import { authoringRuleUnionStack } from '../../utils/stack-collections.js';
2223
import {
2324
printHeader,
2425
printSuccess,
@@ -1065,7 +1066,13 @@ export default class MigrateMeta extends Command {
10651066
// diff" the consumer agent reviews (ADR-0087 D3/D5).
10661067
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
10671068
const specChanges = composeSpecChanges(fromMajor, toMajor);
1068-
const dataMigrations = pendingDataMigrations(result.stack, result.fromMajor, result.toMajor);
1069+
// [#22288] Asked of the folded stack: the advice is about field classes
1070+
// the author's objects declare, and a multi-package `preserve` stack
1071+
// carries its objects in its package bodies, none at its top level, so
1072+
// the advice was never listed for such an app. A stack with no
1073+
// `packages[]` comes back by identity.
1074+
const dataMigrations = pendingDataMigrations(
1075+
authoringRuleUnionStack(result.stack as Record<string, unknown>), result.fromMajor, result.toMajor);
10691076

10701077
// The declared protocol range the load would still refuse after this
10711078
// run (#22219): `--write` rewrites it, a dry run names the edit.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ import {
105105
shouldAutoRegisterStorageDriver,
106106
stackDeclaresMetadata,
107107
bundleDeclaresTranslations,
108+
resolveStackCollection,
109+
stackDeclaredCapabilities,
108110
} from '../utils/stack-collections.js';
109111
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
110112
import { captureServedDatabaseFile, watchServedDatabaseFile } from '../utils/served-database-file.js';
@@ -2844,9 +2846,15 @@ export default class Serve extends Command {
28442846
// `aiStudio`/`aiSeat` alias canonicalization was removed in framework#3308
28452847
// — legacy spellings are now unknown tokens (warned below, rejected at
28462848
// authoring by defineStack).
2847-
const rawRequires: string[] = Array.isArray((config as any).requires)
2848-
? (config as any).requires.filter((c: unknown) => typeof c === 'string')
2849-
: [];
2849+
//
2850+
// [#22288] Read by `resolveStackCollection`'s rule: the top-level list
2851+
// when the config carries one, otherwise each package body's. A
2852+
// multi-package `preserve` config carries `requires` only in the body of
2853+
// the package that declared it, so a top-level read mounted none of its
2854+
// providers on a config boot with no compiled artifact. (An artifact boot
2855+
// already answered here: `createStandaloneStack` resolves the artifact's
2856+
// packages and `mergeBootConfig` lays its `requires` over the top level.)
2857+
const rawRequires: string[] = stackDeclaredCapabilities(config);
28502858
const requires: string[] = [...new Set(rawRequires)];
28512859
// Snapshot the app's EXPLICIT capability declarations BEFORE the platform
28522860
// appends its own convenience defaults (auth→email, mcp, pinyin-search,
@@ -2915,10 +2923,12 @@ export default class Serve extends Command {
29152923
const requiredTiers = requires
29162924
.map((c) => CAPABILITY_TO_TIER[c])
29172925
.filter((t): t is string => typeof t === 'string');
2918-
const baseTiers =
2919-
Array.isArray((config as any).tiers) && (config as any).tiers.length > 0
2920-
? (config as any).tiers
2921-
: presetTiers;
2926+
// [#22288] `tiers` is package-owned like `requires`, and no boot path
2927+
// lays it over the top level (`createStandaloneStack` does not carry it),
2928+
// so a package's own `tiers` were ignored on `os serve`, `os dev` and
2929+
// `os start` alike. Same rule as `requires` above.
2930+
const declaredTiers = resolveStackCollection(config, 'tiers') as string[];
2931+
const baseTiers = declaredTiers.length > 0 ? declaredTiers : presetTiers;
29222932
const tiers: Set<string> = new Set([...baseTiers, ...requiredTiers]);
29232933
const tierEnabled = (t: string) => tiers.has(t);
29242934
const requiresCapability = (c: string) => requires.includes(c);
@@ -4790,7 +4800,13 @@ export default class Serve extends Command {
47904800
// this root by the automation service's package file loader.
47914801
arg = { packageRoot: path.dirname(absolutePath) };
47924802
} else if (spec.configKey === 'analyticsCubes') {
4793-
const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? [];
4803+
// [#22288] The top level first, as before (its own array, then the
4804+
// legacy `cubes` spelling), and only then the package bodies: a
4805+
// multi-package config carries a package's `analyticsCubes` in that
4806+
// body alone, and `analytics` is always on, so its cubes were
4807+
// dropped on every boot of such an app (`os dev` included).
4808+
const cubes = (config as any).analyticsCubes ?? (config as any).cubes
4809+
?? resolveStackCollection(config, 'analyticsCubes');
47944810
arg = { cubes };
47954811
} else if (cap === 'email') {
47964812
// Throws on a mail configuration that cannot deliver (#5087,
@@ -5303,9 +5319,12 @@ export default class Serve extends Command {
53035319
// read off the live engine. Collect it here (after restore) and surface it
53045320
// in the banner: declared-but-engine-missing, unbound triggered flows, and
53055321
// bound-but-dead (unknown object) flows.
5322+
// [#22288] Counted by `resolveStackCollection`'s rule, so a flow a
5323+
// package declares is counted: the "declared but the automation engine is
5324+
// not enabled" line used to stay silent for every multi-package app.
53065325
const automationSummary = collectAutomationSummary(
53075326
kernel,
5308-
Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0,
5327+
resolveStackCollection(config, 'flows').length,
53095328
);
53105329

53115330
// ── Seed outcome summary (#3415/#3430) ─────────────────────────

‎packages/cli/src/utils/scaffold-wiring.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isAggregatedViewContainer } from '@objectstack/spec';
66
// The LEAF subpath: the registry-key derivation alone, not the metadata plugin.
77
import { deriveViewContainerObject } from '@objectstack/metadata/view-container';
88
import { findConfigPath, loadConfig } from './config.js';
9-
import { authoringRuleUnionStack } from './stack-collections.js';
9+
import { authoringRuleUnionStack, stackDeclaredCapabilities } from './stack-collections.js';
1010

1111
/**
1212
* Whether what `os generate` just wrote REACHES the stack the project's config
@@ -48,8 +48,9 @@ export type StackReach =
4848
/**
4949
* The config loaded. `reached` says whether its stack carries the item;
5050
* `missingRequires` lists the capability tokens the item needs to RUN that
51-
* the stack's top-level `requires` does not declare, and `declaredRequires`
52-
* is that list as declared (`null`: the stack declares no `requires`).
51+
* the stack does not declare ({@link declaredCapabilities}: its top-level
52+
* `requires`, else every package body's), and `declaredRequires` is that
53+
* list as declared (`null`: the stack declares no `requires`).
5354
*/
5455
| {
5556
kind: 'loaded';
@@ -104,19 +105,35 @@ export function stackCarries(config: unknown, stackKey: string, itemName: string
104105
}
105106

106107
/**
107-
* The tokens in `requires` that the config's top-level `requires` does not
108-
* declare. Top-level on purpose: it is the list `defineStack`'s trigger
109-
* capability rule reads and the list `os serve` mounts capabilities from.
108+
* The tokens in `requires` that the config's stack does not declare, read as
109+
* `os serve` reads them to decide which providers it mounts
110+
* ({@link declaredCapabilities}).
110111
*/
111112
export function missingCapabilities(config: unknown, requires: readonly string[]): string[] {
112113
const tokens = declaredCapabilities(config) ?? [];
113114
return requires.filter((token) => !tokens.includes(token));
114115
}
115116

116-
/** The config's top-level `requires` tokens, or `null` when it declares none. */
117+
/**
118+
* The config's declared `requires` tokens, or `null` when it declares none —
119+
* by `stackDeclaredCapabilities`'s rule, the one `os serve` mounts providers
120+
* from: the top-level `requires` when the stack carries one, otherwise every
121+
* package body's (#22288).
122+
*
123+
* - A one-package stack: its top-level `requires`. That is also the list
124+
* `defineStack`'s trigger capability rule reads for that stack.
125+
* - A multi-package `preserve` stack: its top level carries no `requires`, so
126+
* this is the union of the package bodies, which is the set a server mounts.
127+
* `defineStack`'s rule judged each package against that package's OWN
128+
* `requires` when the config loaded: a package carrying a record-change flow
129+
* without `automation` and `triggers` refuses there, and this module answers
130+
* `load-failed` before it reads this list. Reading only the top level here
131+
* told the author that a token a package already declares was missing.
132+
*/
117133
export function declaredCapabilities(config: unknown): string[] | null {
118-
const declared = (config as { requires?: unknown } | null)?.requires;
119-
return Array.isArray(declared) ? declared.filter((t): t is string => typeof t === 'string') : null;
134+
const declared = stackDeclaredCapabilities(config);
135+
const topLevel = (config as { requires?: unknown } | null)?.requires;
136+
return Array.isArray(topLevel) || declared.length > 0 ? declared : null;
120137
}
121138

122139
/** Load the project's config and ask it about `target`. Never throws. */

0 commit comments

Comments
 (0)