Skip to content

Commit 2b4c001

Browse files
committed
fix(cli): read package-owned keys through the package bodies on a multi-package config
A multi-package composeStacks(..., { manifest: 'preserve' }) config carries every package-owned collection inside the body of the package that owns it and none at its top level. These readers looked at the top level only: - os serve: requires (providers mounted), tiers, analyticsCubes (the always-on analytics provider's cubes) and the declared-flow count of the automation line; - os migrate plan/apply: the requires tokens that order a host plugin's hard dependency on its provider; - os generate: the missing-capability hint, and the objects that types, client and migration generation emit; - os doctor, os diff and os migrate meta's data-migration advice. Each now reads by resolveStackCollection's rule (the top level when present, otherwise every package body) or folds the stack with authoringRuleUnionStack. A stack with no packages[] reads exactly as before. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6729e10 commit 2b4c001

8 files changed

Lines changed: 124 additions & 29 deletions

File tree

‎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);
@@ -4768,7 +4778,13 @@ export default class Serve extends Command {
47684778
// this root by the automation service's package file loader.
47694779
arg = { packageRoot: path.dirname(absolutePath) };
47704780
} else if (spec.configKey === 'analyticsCubes') {
4771-
const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? [];
4781+
// [#22288] The top level first, as before (its own array, then the
4782+
// legacy `cubes` spelling), and only then the package bodies: a
4783+
// multi-package config carries a package's `analyticsCubes` in that
4784+
// body alone, and `analytics` is always on, so its cubes were
4785+
// dropped on every boot of such an app (`os dev` included).
4786+
const cubes = (config as any).analyticsCubes ?? (config as any).cubes
4787+
?? resolveStackCollection(config, 'analyticsCubes');
47724788
arg = { cubes };
47734789
} else if (cap === 'email') {
47744790
// Throws on a mail configuration that cannot deliver (#5087,
@@ -5281,9 +5297,12 @@ export default class Serve extends Command {
52815297
// read off the live engine. Collect it here (after restore) and surface it
52825298
// in the banner: declared-but-engine-missing, unbound triggered flows, and
52835299
// bound-but-dead (unknown object) flows.
5300+
// [#22288] Counted by `resolveStackCollection`'s rule, so a flow a
5301+
// package declares is counted: the "declared but the automation engine is
5302+
// not enabled" line used to stay silent for every multi-package app.
52845303
const automationSummary = collectAutomationSummary(
52855304
kernel,
5286-
Array.isArray((config as any)?.flows) ? (config as any).flows.length : 0,
5305+
resolveStackCollection(config, 'flows').length,
52875306
);
52885307

52895308
// ── 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. */

‎packages/cli/src/utils/schema-migration-plugins.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import path from 'node:path';
44
import fs from 'node:fs';
55
import { isAppPluginLike } from './graft-runtime-hooks.js';
6-
import { stackDeclaresMetadata } from './stack-collections.js';
6+
import { stackDeclaredCapabilities, stackDeclaresMetadata } from './stack-collections.js';
77

88
/**
99
* The object set a SCHEMA migration is planned against (#12938).
@@ -1450,7 +1450,12 @@ export async function buildSchemaMigrationPlugins(opts: {
14501450
const { loadConfig } = await import('./config.js');
14511451
const { config } = await loadConfig(hostConfigPath);
14521452

1453-
loadedRequires = config?.requires;
1453+
// [#22288] `serve`'s rule for the tokens, as well as its lookup: the
1454+
// top-level `requires`, otherwise each package body's. A multi-package
1455+
// config carries `requires` only in the declaring package's body, so a
1456+
// top-level read left a plugin's hard dependency on that provider
1457+
// unordered and the plan exited 1.
1458+
loadedRequires = stackDeclaredCapabilities(config);
14541459
const hostPlugins: unknown[] = Array.isArray(config?.plugins) ? config.plugins : [];
14551460
for (const plugin of hostPlugins) {
14561461
if (plugin && typeof plugin === 'object') plugins.push(composeForDeclarations(plugin, lifecycle));

‎packages/cli/src/utils/stack-collections.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,25 @@ export function resolveStackCollection(stack: unknown, key: string): unknown[] {
219219
return packageCollection(stack, key);
220220
}
221221

222+
/**
223+
* The capability tokens a stack DECLARES in `requires`, by
224+
* {@link resolveStackCollection}'s rule: the top-level list when the stack
225+
* carries one, otherwise every package body's, in package order. String
226+
* entries only, duplicates kept (each caller dedupes in its own order).
227+
*
228+
* A multi-package `composeStacks(…, { manifest: 'preserve' })` stack carries
229+
* `requires` only inside the body of the package that declared it (ADR-0130
230+
* D4, 2026-09-22 addendum). A reader of the top level alone read `[]` there:
231+
* `os serve` did not mount the provider a package declared, `os migrate plan`
232+
* could not order a plugin that hard-depends on it, and `os generate` told the
233+
* author to declare a token a package already declares (#22288). The build
234+
* doors attribute each token to its package instead
235+
* (`preflightDeclaredCapabilities`), on the same rule.
236+
*/
237+
export function stackDeclaredCapabilities(stack: unknown): string[] {
238+
return resolveStackCollection(stack, 'requires').filter((token): token is string => typeof token === 'string');
239+
}
240+
222241
/**
223242
* Does this stack declare any objects?
224243
*
@@ -372,9 +391,13 @@ export function artifactObjectNames(raw: unknown): string[] {
372391
* Computed on first use, never at module load: both schemas are lazy proxies
373392
* and touching `.shape` forces the whole graph, which the CLI pays for on every
374393
* invocation if it happens at import time.
394+
*
395+
* Exported for one reader besides the fold: the enumeration pin
396+
* (`test/normalized-call-sites.test.ts`) takes its key set from here, so a
397+
* package-owned key the schema gains is enumerated the day it is declared.
375398
*/
376399
let cachedCollectionKeys: readonly string[] | undefined;
377-
function packageOwnedCollectionKeys(): readonly string[] {
400+
export function packageOwnedCollectionKeys(): readonly string[] {
378401
if (cachedCollectionKeys) return cachedCollectionKeys;
379402
const bodyKeys = new Set(shapeKeys(AssembledPackageBodySchema, 'AssembledPackageBodySchema'));
380403
cachedCollectionKeys = shapeKeys(ObjectStackDefinitionSchema, 'ObjectStackDefinitionSchema')

0 commit comments

Comments
 (0)