Skip to content

Commit 6d2da32

Browse files
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
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os validate`, `os build`, `os lint` and `os i18n check` now judge a multi-package app by its package bodies. A multi-package app that passed with a required capability that has no installable provider in this edition, or with a missing translation, now fails, as the same app shipped as one package always did.
6+
7+
Clause-②: no
8+
9+
An app composed with `composeStacks([a, b], { manifest: 'preserve' })` carries its metadata, its `translations` and its `requires` only inside each package's body, and nothing a package owns at its top level. These readers looked at the top level alone, so on such an app they found nothing to judge and answered clean:
10+
11+
- **The capability preflight** (`os validate`, `os build`) now reads each package body's `requires` when the top level declares none. A capability with no installable provider in this edition fails the run with exit 1, and each finding names its package: `package 'com.acme.service' — Capability "ai" resolves to …`. The advisory for an installable but absent provider (for example `hierarchy-security`) comes back the same way. A top-level `requires` is read exactly as before, so a single-package app prints the same text it always did.
12+
- **Translation coverage** (`os lint`, `os i18n check`) now expects the keys of every package, so a missing translation in any package is reported. Under `os lint --strict` it fails the run again.
13+
- **`os i18n extract`** now extracts every package's keys, where it extracted none.
14+
- **The undeclared-authoring-key warnings** that `os validate --json` and `os build --json` carry in `warnings` now cover items inside package bodies too.
15+
16+
A `packages` value that is present but is not an array is now refused by `os validate`, `os i18n check` and `os i18n extract` with `INVALID_ARTIFACT_PACKAGES`, as the other readers already refuse it. `os i18n check` and `os i18n extract` used to accept such a config with exit 0 and zero keys; `os validate` refused it from the schema parse instead.
17+
18+
Apart from that refusal, no accepted input, exported symbol or `--json` field changes. A single-package app's output is unchanged.

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { stackFilterJudge } from '../utils/authoring-filter-judge.js';
2323
import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
2424
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
2525
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
26-
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
26+
import { preflightDeclaredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2727
import { attachPackageDocs, collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
2828
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
2929
import {
@@ -750,11 +750,15 @@ export default class Compile extends Command {
750750
// `os start` crash. Absent-but-installable is a `pnpm add` hint.
751751
//
752752
// Not a registry rule: it reads `node_modules`, not the stack.
753+
//
754+
// [#22189] Read wherever the stack declares `requires`: its top
755+
// level, or each `packages[]` body, naming the package. A
756+
// multi-package `preserve` artifact carries `requires` only in its
757+
// bodies, so a top-level read passed it with exit 0.
753758
if (!flags.json) printStep('Checking that every required capability has a provider installable in this edition...');
754-
const capPreflight = preflightRequiredCapabilities({
755-
requires: Array.isArray((config as { requires?: unknown[] }).requires)
756-
? ((config as { requires?: unknown[] }).requires as unknown[])
757-
: [],
759+
const capPreflight = preflightDeclaredCapabilities({
760+
requires: (config as { requires?: unknown }).requires,
761+
packages: packageEntries,
758762
projectDir: path.dirname(absolutePath),
759763
});
760764
// [#11727] MAPPED HERE, once, and consumed by BOTH faces — the text block
@@ -816,9 +820,13 @@ export default class Compile extends Command {
816820
// its own `normalized` — so hoisting the formatting rather than
817821
// restating it at the payload is what keeps the two faces from
818822
// reporting different sets. One list cannot drift from itself.
823+
//
824+
// [#22238] The item walk reads the package-union stack, as
825+
// `os validate`'s does; the stack-key lint stays on the envelope.
826+
// See `validate.ts` step 2 for why each.
819827
unknownKeyWarnings = [
820828
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
821-
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
829+
...lintUnknownAuthoringKeys(authoringRuleUnionStack(normalized as Record<string, unknown>), ObjectStackDefinitionSchema),
822830
].map(formatUnknownAuthoringKey);
823831
if (unknownKeyWarnings.length > 0 && !flags.json) {
824832
printWarning(`Undeclared authoring keys (${unknownKeyWarnings.length}) — dropped at load; reported here, never refused`);

‎packages/cli/src/commands/i18n/check.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
isReportedError,
5858
} from '../../utils/format.js';
5959
import { computeI18nCoverage, COVERAGE_SURFACE_PHRASE } from '../../utils/i18n-coverage.js';
60+
import { authoringRuleUnionStack } from '../../utils/stack-collections.js';
6061

6162
export default class I18nCheck extends Command {
6263
// ⛔ The surface list is DERIVED (`COVERAGE_SURFACE_PHRASE`), never a
@@ -153,7 +154,11 @@ export default class I18nCheck extends Command {
153154
if (!flags.json) printInfo(`Config: ${chalk.white(absolutePath)}`);
154155

155156
const normalized = normalizeStackInput(config as Record<string, unknown>);
156-
const report = computeI18nCoverage(normalized, {
157+
// [#22238] Over the package-union stack, as `os lint` reads it: a
158+
// multi-package `preserve` artifact carries its collections and
159+
// `translations` only in `packages[]`, so the top level alone expected
160+
// zero keys and reported 100%.
161+
const report = computeI18nCoverage(authoringRuleUnionStack(normalized as Record<string, unknown>), {
157162
defaultLocale: flags['default-locale'],
158163
locales: flags.locales ? flags.locales.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
159164
strict: flags.strict,

‎packages/cli/src/commands/i18n/extract.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
type FillStrategy,
3131
type TranslationModuleKind,
3232
} from '../../utils/i18n-extract.js';
33+
import { authoringRuleUnionStack } from '../../utils/stack-collections.js';
3334

3435
const FILL_STRATEGIES: FillStrategy[] = ['empty', 'default', 'todo'];
3536

@@ -285,7 +286,12 @@ export default class I18nExtract extends Command {
285286
}
286287
}
287288

288-
const result = extractTranslations(normalized, {
289+
// [#22238] Over the package-union stack, as `os i18n check` reads it: a
290+
// multi-package `preserve` artifact carries its collections and
291+
// `translations` only in `packages[]`, so the top level alone yielded no
292+
// key to extract. The `i18n` read above stays on the top level, where
293+
// that artifact keeps it.
294+
const result = extractTranslations(authoringRuleUnionStack(normalized as Record<string, unknown>), {
289295
defaultLocale,
290296
locales,
291297
previousSourceHashes,

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1019,9 +1019,16 @@ export default class Lint extends Command {
10191019
// already cover. A project that ships neither is checked against its
10201020
// default locale alone, which its inline labels already satisfy — so this
10211021
// stays silent for projects that do not translate.
1022+
//
1023+
// [#22238] Judged over the package-union stack. A multi-package
1024+
// `preserve` artifact carries its collections and `translations` only in
1025+
// `packages[]` and keeps `i18n` at the top level, so the top level alone
1026+
// had no key to expect and every `i18n/missing-*` went silent. The union
1027+
// keeps `i18n` and folds the rest back in; a stack that already carries
1028+
// its collections comes back by identity.
10221029
let hiddenPlatform = 0;
10231030
if (!flags['skip-i18n']) {
1024-
const coverage = computeI18nCoverage(normalized, {
1031+
const coverage = computeI18nCoverage(authoringRuleUnionStack(normalized as Record<string, unknown>), {
10251032
defaultLocale: flags['default-locale'],
10261033
strict: flags['i18n-strict'],
10271034
});

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact
2323
import { stackFilterJudge } from '../utils/authoring-filter-judge.js';
2424
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
2525
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
26-
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
26+
import { preflightDeclaredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2727
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
2828
import {
2929
printHeader,
@@ -279,9 +279,15 @@ export default class Validate extends Command {
279279
// carries the key the author actually wrote. Computed here rather than
280280
// down in the warnings section so the `--json` path reports it too — the
281281
// "computed, then discarded" shape this file already had to fix once.
282+
//
283+
// The item walk reads the package-union stack (#22238): a multi-package
284+
// `preserve` artifact carries its collections only in `packages[]`, so
285+
// the top level alone held no item to walk. The stack-key lint stays on
286+
// the top level on purpose: it judges the envelope's own keys, and a
287+
// package body is a closed shape the parse refuses an unknown key on.
282288
unknownKeyWarnings = [
283289
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
284-
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
290+
...lintUnknownAuthoringKeys(authoringRuleUnionStack(normalized as Record<string, unknown>), ObjectStackDefinitionSchema),
285291
].map(formatUnknownAuthoringKey);
286292
// 2b. [#16544] Lower inline `function` handlers (Hook.handler, action
287293
// `target`, top-level `functions`) to a metadata `body` + string ref
@@ -687,10 +693,12 @@ export default class Validate extends Command {
687693
}
688694

689695
if (!flags.json) printStep('Checking that every required capability has a provider installable in this edition...');
690-
const capProviderPreflight = preflightRequiredCapabilities({
691-
requires: Array.isArray((config as { requires?: unknown[] }).requires)
692-
? ((config as { requires?: unknown[] }).requires as unknown[])
693-
: [],
696+
// [#22189] Every place the stack declares `requires`: its top level, or
697+
// each `packages[]` body, naming the package. A multi-package
698+
// `preserve` artifact carries `requires` only in its bodies.
699+
const capProviderPreflight = preflightDeclaredCapabilities({
700+
requires: (config as { requires?: unknown }).requires,
701+
packages: packageEntries,
694702
projectDir: dirname(absolutePath),
695703
});
696704
const capProviderErrors = capProviderPreflight.errors;

‎packages/cli/src/utils/capability-preflight.ts‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,19 @@ export function makeProviderResolver(projectDir: string): (pkg: string) => boole
6565
* build/validate preflight and the serve boot error so both read identically.
6666
* Only `installable` / `unavailable` / `unknown` produce a message; `ok` is
6767
* satisfied and never surfaced.
68+
*
69+
* A finding a PACKAGE declared (see {@link preflightDeclaredCapabilities}) is
70+
* prefixed with that package, in the `package '<id>' — ` spelling the
71+
* per-package author-time rule pass puts on its findings. Without a package the
72+
* text is exactly what it was.
6873
*/
69-
export function renderCapabilityMessage(c: CapabilityClassification): string {
74+
export function renderCapabilityMessage(c: DeclaredCapabilityClassification): string {
75+
const text = renderTokenMessage(c);
76+
return c.package === undefined ? text : `package '${c.package}' — ${text}`;
77+
}
78+
79+
/** {@link renderCapabilityMessage} without the package prefix. */
80+
function renderTokenMessage(c: CapabilityClassification): string {
7081
const p = c.provider;
7182
switch (c.status) {
7283
case 'installable': {
@@ -149,6 +160,76 @@ export function preflightRequiredCapabilities(opts: {
149160
return { errors, warnings };
150161
}
151162

163+
/**
164+
* A classified capability, and the package whose `requires` declared it.
165+
* `package` is absent when the stack's own top level declared it.
166+
*/
167+
export interface DeclaredCapabilityClassification extends CapabilityClassification {
168+
readonly package?: string;
169+
}
170+
171+
/** {@link CapabilityPreflightResult}, each finding attributed to where it was declared. */
172+
export interface DeclaredCapabilityPreflightResult {
173+
readonly errors: DeclaredCapabilityClassification[];
174+
readonly warnings: DeclaredCapabilityClassification[];
175+
}
176+
177+
/**
178+
* The `os validate` / `os build` preflight over every place a stack DECLARES
179+
* capabilities: its own top level, or each `packages[]` body.
180+
*
181+
* A multi-package `composeStacks(…, { manifest: 'preserve' })` artifact carries
182+
* `requires` once, inside the body of the package that declared it (ADR-0130
183+
* D4, 2026-09-22 addendum), and nothing at its top level. A preflight that read
184+
* only the top level read `[]` there, so a capability with no installable
185+
* provider passed both doors with exit 0, while the same token in one
186+
* `defineStack` exits 1.
187+
*
188+
* The resolution rule is `resolveStackCollection`'s (`stack-collections.ts`):
189+
* a top-level `requires` WINS whenever it is present. In the additive shape
190+
* that array already is the union of the bodies, and a single-package stack has
191+
* nothing else, so every stack that declares `requires` at its top level is
192+
* judged exactly as before, unattributed. The bodies are read only when the top
193+
* level does not carry the key, and each finding there names its package,
194+
* because a token is removed from the package that declared it. The same token
195+
* in two packages is reported once per package for that reason.
196+
*
197+
* @param opts.requires - The stack's top-level `requires`, as declared.
198+
* @param opts.packages - The stack's `packages[]` entries, as `artifactPackages`
199+
* (`artifact-packages.ts`) names them. Passed in, never derived here: that
200+
* module carries the one package-id rule, and importing it would load
201+
* `@objectstack/lint` into `os serve`, which imports this module.
202+
*/
203+
export function preflightDeclaredCapabilities(opts: {
204+
requires: unknown;
205+
packages: ReadonlyArray<{ readonly id: string; readonly body: Record<string, unknown> }>;
206+
projectDir: string;
207+
/** Injectable for tests; defaults to on-disk `require.resolve` resolution. */
208+
isInstalled?: (pkg: string) => boolean;
209+
}): DeclaredCapabilityPreflightResult {
210+
const isInstalled = opts.isInstalled ?? makeProviderResolver(opts.projectDir);
211+
if (Array.isArray(opts.requires) || opts.packages.length === 0) {
212+
return preflightRequiredCapabilities({
213+
requires: Array.isArray(opts.requires) ? opts.requires : [],
214+
projectDir: opts.projectDir,
215+
isInstalled,
216+
});
217+
}
218+
const errors: DeclaredCapabilityClassification[] = [];
219+
const warnings: DeclaredCapabilityClassification[] = [];
220+
for (const pkg of opts.packages) {
221+
const declared = pkg.body.requires;
222+
const result = preflightRequiredCapabilities({
223+
requires: Array.isArray(declared) ? declared : [],
224+
projectDir: opts.projectDir,
225+
isInstalled,
226+
});
227+
errors.push(...result.errors.map((c) => ({ ...c, package: pkg.id })));
228+
warnings.push(...result.warnings.map((c) => ({ ...c, package: pkg.id })));
229+
}
230+
return { errors, warnings };
231+
}
232+
152233
/**
153234
* The fatal one-line message `os serve` throws when a DECLARED capability's
154235
* provider import fails as module-not-found. The package is already confirmed

0 commit comments

Comments
 (0)