Skip to content

Commit fdb2669

Browse files
fix(cli): refuse a present non-array packages in the stack-collection and docs readers (#20231)
Fixes #19925 Clause-②: no (narrowing) ## What changed Ruling A on #15293 (`5634034754`) says that a `packages` which is present but is not an array (`{}`, `0`, `'x'`) is malformed, not absent, and that every reader refuses it. The runtime, `@objectstack/core` and the plugin readers already refused it. The four `@objectstack/cli` readers answered "no packages" instead. They now refuse it with core's own `INVALID_ARTIFACT_PACKAGES` refusal (ADR-0112, `status: 422`). - `stack-collections.ts` gets ONE exported helper, `declaredPackageEntries(packages)`: - the key absent (`undefined`) answers `[]`, and that is the only value the helper answers on its own; - an array comes back by reference; - every other value, `null` included, goes to `resolveArtifactPackageOrder`. A non-array is refused there. The resolver's absent answer (`[artifact]`, the object passed in, by reference) is recognised by identity and answers `[]`. So `null` gets whatever the resolver answers for it (round 1, after ruling `5805260775` on #19926). The helper spells neither the rule nor the refusal, and no new code is minted. - `packageBodies` routes through the helper. So do `docsPackageRefs`, `bodyDocsOf` and `attachPackageDocs` in `collect-docs.ts`. None of the four keeps its own `Array.isArray` guard. - New pins in `packages/cli/test/non-array-packages-readers.test.ts` (16 tests). Each refusal asserts `code` + `status`. - New pins in `packages/cli/test/null-packages-follows-resolver.test.ts` (16 tests), added in round 1: - Leg 1 holds each reader's answer for `packages: null` to the REAL resolver's verdict on `{ packages: null }`. That is the absent answer today, and the refusal once #19926's core change lands. - Leg 2 arms a resolver double that refuses `null` with the non-array envelope, and every reader must then refuse. A private `null` branch in the CLI never asks the resolver, so today only leg 2 can see one. - The existing row `docsPackageRefs` › "is empty for anything that is not an array" (`src/utils/collect-docs.package-docs.test.ts`) pinned `[]` for `{}`, which is the retired answer. It now asserts the refusal envelope and keeps its absent control. - Changeset `.changeset/19925-cli-non-array-packages-refusal.md`: `@objectstack/cli` minor, `Clause-②: no (narrowing)`, a BREAKING paragraph, and the ADR-0087 disposition `not-required (no-migration-prescription)`. Round 1 replaced its `null` sentence with a paragraph saying that `null` follows core's resolver (ruling `5805260775`). ## Reach, measured: why the changeset narrows The built CLI (`packages/cli/bin/run.js`) was run on `ce70876e4` (before) and on this branch (after). Each fixture is a hand-written `objectstack.config.ts` whose default export is a plain object carrying a `manifest` and the `packages` value shown. | `packages` | command | before | after | |---|---|---|---| | `{}` / `0` / `'x'` | `os info --json` | exit 0, `stats.objects: 0` | exit 1, `INVALID_ARTIFACT_PACKAGES` | | `{}` / `0` / `'x'` | `os lint --json` | exit 0, `passed: true` | exit 1, `INVALID_ARTIFACT_PACKAGES` | | `{}` / `0` / `'x'` | `os validate --json` | exit 1, schema `invalid_type` at `packages` | unchanged | | `{}` | `os build --json` | exit 1, schema `invalid_type` at `packages` | unchanged | | `{}` | `os serve` | exit 1, core's `INVALID_ARTIFACT_PACKAGES` sentence raised during boot | unchanged | | well-formed array | `os info` / `os lint` | exit 0: 1 object / `passed: true` | unchanged | | absent | `os info` / `os lint` | exit 0: 1 object / `passed: true` | unchanged | | inlined entry (lit control) | `os info` / `os lint` | exit 1, `INVALID_ARTIFACT_PACKAGE_ENTRY` | unchanged | With its default strict parse, `defineStack(…)` refuses `packages: {}` at config load (`STACK_SCHEMA_INVALID`). Only two spellings reach `os info` / `os lint` with the shape: a plain-object export, and `defineStack(…, { strict: false })`. Both were measured, and both behave as the table says. So two public doors ACCEPTED the shape and answered success. The claim carried `Clause-②: no`. This PR follows the same-door precedents (`.changeset/19120-install-door-parses-manifest-version.md`, `.changeset/19417-install-door-parses-manifest-id.md`) and declares `Clause-②: no (narrowing)` with a `minor` bump instead. ## The four readers, before and after The readers were called from source with a well-formed control and an absent control. | reader | `{}` / `0` / `'x'` before | after | well-formed | absent | |---|---|---|---|---| | `packageBodies` (via `resolveStackCollection`) | `[]` | `INVALID_ARTIFACT_PACKAGES`, 422 | the body's objects, unchanged | `[]`, unchanged | | `docsPackageRefs` | `[]` | `INVALID_ARTIFACT_PACKAGES`, 422 | the package ids, unchanged | `[]`, unchanged | | `bodyDocsOf` | `[]` | refuses through the helper | the body's docs, unchanged | `[]`, unchanged | | `attachPackageDocs` | the same reference back | `INVALID_ARTIFACT_PACKAGES`, 422 | attaches, unchanged | the same reference back, unchanged | `bodyDocsOf` has one caller, `collectAndLintDocs`, which calls it only for refs that `docsPackageRefs` produced from the same value. So a non-array never reached it, before or after. It uses the helper anyway, so that no reader keeps a guard of its own. The ablation below confirms that no pin can observe it. ## Ablation The fix was committed first (`67d5b4829`). The pin file imports `src/` relatively, so no `dist/` leg is involved. Each leg went through `scripts/ablation-replace.mjs`: the anchor went 1 → 0, the injected text 0 → 1, and the blob changed. The leg then ran the pin file, and the tool restored the file (blob equal to HEAD, `git diff HEAD` empty). At the end, `git hash-object` of both files matched their HEAD blobs. | leg | mutation | pin file | |---|---|---| | A | the helper's non-array branch answers `[]` | 12 failed / 4 passed | | B | `packageBodies` gets its old `Array.isArray` guard back | 6 failed / 10 passed | | C | `docsPackageRefs` gets its old guard back | 3 failed / 13 passed | | D | `attachPackageDocs` gets its old guard back | 3 failed / 13 passed | | E | `bodyDocsOf` gets its old guard back | 16 passed: unreachable, the expected direction | Round 1: the fix was committed first (`5bac82635`). The leg restored the private `null` branch, `if (packages === undefined || packages === null) return [];`, through `scripts/ablation-replace.mjs`: the anchor went 1 → 0, the injected text 0 → 1, and the blob `c8c830d2e25b` → `78739bfea3c8`. - On the two pin files, the unmutated HEAD gave 32 passed. - The mutation gave 7 failed / 25 passed. All 7 leg-2 rows went red, and leg 1 stayed green, which is expected today. - The restore was proven: blob == HEAD (`c8c830d2e25b`), `git diff HEAD` empty, status clean. ## Verification Round 1, all on HEAD `ae8e3e83f`, after merging `origin/main` at `3cb84d084`. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 229 files, 3250 tests passed. The `integration` tier is left to CI, because the diff touches no spawn entry and no integration-tier file. - `pnpm --filter @objectstack/cli typecheck`: exit 0. `check:test-typecheck` compiles both pin files under `tsconfig.test.json`. - `pnpm lint` over the whole repo: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 62 commands. All 62 exited 0, and the `--ran` reconciliation reports 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. As in round 0, `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET, some `dist/` missing). Both were re-run on the same HEAD once the builds were present, and exited 0. - `node scripts/check-adr-0087-registration.mjs --base origin/main` and `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0 each. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0, with 6 citations resolving. - `packages: null` on the built CLI, with a plain-object config: - On this HEAD, `os info --json` exits 0 (`stats.objects: 0`) and `os lint --json` exits 0 (`passed: true`). Both are unchanged. - A throwaway worktree of this HEAD was given PR #20228's `packages/core/src/artifact-packages.ts` (`0706ffef7`), and core was rebuilt. There, both commands exit 1 with `INVALID_ARTIFACT_PACKAGES`, and all four readers refuse `null`, with no CLI edit. Both pin files pass there too (32 of 32), and leg 1 takes the refusal branch. - The worktree was removed afterwards, and nothing from it was committed. Round 0, on `a89a8e528`: 228 files / 3234 tests passed, and typecheck, lint, all 62 gates and the citation check each exited 0. ## Not in this PR - #20206 remains open. It carries the four `packages/lint` sites (`domain:spec`). - #19926 remains open, but it is not a separate decision. Ruling `5805260775` (letter A) makes `packages: null` malformed at every reader, and core's resolver refuses it. That core change is PR #20228, which touches no `packages/cli` file. - The four readers here hand `null` to the resolver and do not answer it themselves, so the refusal reaches them when #20228 lands, with no CLI edit. - Until then, `null` reads as no packages, exactly as before this PR. ## Acceptance notes - **File surface.** The claim named "their tests in `packages/cli/test/`". The new pins live there. The existing `docsPackageRefs` row sits beside its source in `packages/cli/src/utils/collect-docs.package-docs.test.ts`, and it pinned the retired answer, so it had to change in this PR. - **`artifactPackages`** (`packages/cli/src/utils/artifact-packages.ts`) keeps its own `!Array.isArray` guard. That file is outside the claimed surface, so it is untouched. Its docblock says it reads the PARSED stack. Every caller reaches it only after the schema parse, or after one of the four readers has judged the same value: - `compile.ts`, `validate.ts`, `nav-contribution-groups` and `permission-set-name-collisions` pass parsed data; - `lint.ts`, `sdui-manifest.ts` and `lintDocNavTargets` run after `authoringRuleUnionStack` or `docsPackageRefs`. So no public door reaches it with a non-array now. Its carrier is whichever PR next touches that file. - **`os serve`.** `serve.ts` wraps `collectDocsFromSrc` in a catch-all ("docs are additive — never block boot"). A `docsPackageRefs` refusal is swallowed there. The same boot then refuses the stack through `shouldAutoRegisterObjectQL` and the manifest service. Measured: `os serve` still exits 1 with the same sentence. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab82001 commit fdb2669

6 files changed

Lines changed: 509 additions & 11 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
fix(cli): `os info` and `os lint` refuse a stack whose `packages` is present but not an array, instead of reading it as no packages (#19925)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for `os info` and `os lint` on a hand-written stack.** A config whose
10+
`packages` is present but is not an array (`{}`, `0`, `'x'`) is now refused with
11+
`INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`) and exit code 1. These
12+
commands used to read it as a stack with no packages. `os info` exited 0 and
13+
reported every package-owned collection as empty. `os lint` exited 0 with
14+
`passed: true`. Only two spellings reach these commands with such a value: a
15+
config exported as a plain object, and `defineStack(…, { strict: false })`.
16+
The default `defineStack` parse, `os validate` and `os build` already refused
17+
it.
18+
19+
The accept set only shrinks back to what the declaration has always said.
20+
`ObjectStackDefinitionSchema` declares `packages` as an array of package
21+
entries. Ruling A on #15293 settled that a present non-array value is
22+
malformed, not absent. The runtime, `@objectstack/core` and the plugin readers
23+
already refused it. The CLI's stack-collection reader and its three
24+
package-docs readers still answered "no packages". They now judge the value
25+
through one helper, which hands a non-array to `resolveArtifactPackageOrder`.
26+
So the refusal's code, status and sentence are core's own, and the CLI keeps no
27+
second copy of the rule.
28+
29+
**What is not affected.** An absent `packages` reads exactly as before, and so
30+
does a `packages` array. A malformed entry inside an array is still refused as
31+
`INVALID_ARTIFACT_PACKAGE_ENTRY`. `os serve` and `os dev` refused this stack
32+
before the change, because the runtime's manifest service raises the same
33+
refusal at boot, and they still do.
34+
35+
**`packages: null` follows core's resolver.** Ruling `5805260775` on #19926
36+
makes `null` malformed at every reader, and the resolver's `null` refusal is
37+
landing separately (#19926, PR #20228). The CLI readers do not answer `null`
38+
themselves; they hand it to `resolveArtifactPackageOrder` and return what it
39+
answers. Today that is its absent answer, so `null` still reads as no
40+
packages. Once the resolver refuses `null`, these commands refuse it too, with
41+
no change to the CLI.
42+
43+
**If you are refused.** Omit `packages` for a single-package stack, or give it
44+
an array of `{ manifest: … }` entries. The refusal says the same.
45+
46+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. `objectstack migrate meta` has nothing to reach, because a non-array `packages` was never a legal spelling of anything, so no old form maps to a new one. The refusal itself carries the remedy. -->

‎packages/cli/src/utils/collect-docs.package-docs.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,9 +746,20 @@ describe('docsPackageRefs', () => {
746746
]);
747747
});
748748

749-
it('is empty for anything that is not an array', () => {
749+
it('is empty for an absent `packages`, and refuses a present non-array one', () => {
750750
expect(docsPackageRefs(undefined)).toEqual([]);
751-
expect(docsPackageRefs({})).toEqual([]);
751+
// #19925: `{}` is malformed, not absent (ruling A on #15293). This row used
752+
// to pin `[]` for it, which was the silent answer that card removes. The
753+
// full `{}` / `0` / `'x'` set is pinned in
754+
// `test/non-array-packages-readers.test.ts`.
755+
let refusal: { code?: unknown; status?: unknown } | undefined;
756+
try {
757+
docsPackageRefs({});
758+
} catch (error) {
759+
refusal = error as { code?: unknown; status?: unknown };
760+
}
761+
expect(refusal?.code).toBe('INVALID_ARTIFACT_PACKAGES');
762+
expect(refusal?.status).toBe(422);
752763
});
753764
});
754765

‎packages/cli/src/utils/collect-docs.ts‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import fs from 'fs';
6464
import path from 'path';
6565

6666
import { artifactPackages } from './artifact-packages.js';
67+
import { declaredPackageEntries } from './stack-collections.js';
6768

6869
export interface DocTranslationItem {
6970
label?: string;
@@ -380,10 +381,14 @@ export interface PackageDocSet {
380381
*
381382
* A directory that matches none, or more than one, is not attributed — it is
382383
* reported, by {@link sweepPackageDocsDirectories}.
384+
*
385+
* An absent `packages` answers `[]`. A present non-array one (`{}`, `0`, `'x'`)
386+
* is refused with the resolver's `INVALID_ARTIFACT_PACKAGES` through
387+
* {@link declaredPackageEntries}; it is never read as "no packages" (#19925).
388+
* `null` gets whatever the resolver answers for it; see that function.
383389
*/
384390
export function docsPackageRefs(packages: unknown): DocsPackageRef[] {
385-
if (!Array.isArray(packages)) return [];
386-
return artifactPackages({ packages }).map(({ index, id, body }) => {
391+
return artifactPackages({ packages: declaredPackageEntries(packages) }).map(({ index, id, body }) => {
387392
const directoryNames = new Set<string>();
388393
if (typeof body.id === 'string' && body.id !== '') {
389394
directoryNames.add(body.id);
@@ -1158,10 +1163,16 @@ function claimedDocs(items: readonly DocItem[]): { has: (doc: DocItem) => boolea
11581163
};
11591164
}
11601165

1161-
/** The `docs` a `packages[]` entry already carries in its own assembled body. */
1166+
/**
1167+
* The `docs` a `packages[]` entry already carries in its own assembled body.
1168+
*
1169+
* `packages` is judged by {@link declaredPackageEntries}, so a present
1170+
* non-array value goes to the resolver rather than being read as an entry with
1171+
* no docs.
1172+
*/
11621173
function bodyDocsOf(packages: unknown, index: number): DocItem[] {
1163-
if (!Array.isArray(packages)) return [];
1164-
const body = (packages[index] as { manifest?: Record<string, unknown> } | null | undefined)?.manifest;
1174+
const entry = declaredPackageEntries(packages)[index];
1175+
const body = (entry as { manifest?: Record<string, unknown> } | null | undefined)?.manifest;
11651176
const docs = body?.docs;
11661177
return Array.isArray(docs) ? (docs as DocItem[]) : [];
11671178
}
@@ -1358,12 +1369,20 @@ export function collectAndLintDocs(
13581369
* Returns the ARGUMENT ITSELF when nothing is added, so an artifact with no
13591370
* per-package docs is not merely equal to the one built before this landed —
13601371
* it is the same object, serialized from the same references.
1372+
*
1373+
* `packages` is judged FIRST, by {@link declaredPackageEntries}. An absent one
1374+
* comes back as it came in. A present non-array one (`{}`, `0`, `'x'`) is
1375+
* refused, because handing it back unchanged would carry it into the artifact
1376+
* as if it held no packages (#19925). `null` gets whatever the resolver
1377+
* answers for it: today that is the absent answer, so `null` comes back as it
1378+
* came in.
13611379
*/
13621380
export function attachPackageDocs(packages: unknown, sets: readonly PackageDocSet[]): unknown {
1363-
if (!Array.isArray(packages) || sets.length === 0) return packages;
1381+
const entries = declaredPackageEntries(packages);
1382+
if (entries.length === 0 || sets.length === 0) return packages;
13641383
const byIndex = new Map(sets.map((set) => [set.index, set]));
13651384
let changed = false;
1366-
const out = packages.map((entry, index) => {
1385+
const out = entries.map((entry, index) => {
13671386
const set = byIndex.get(index);
13681387
if (!set || set.docs.length === 0) return entry;
13691388
const body = (entry as { manifest?: Record<string, unknown> } | null | undefined)?.manifest;

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

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,78 @@ type Bag = Record<string, unknown>;
8484
const asBag = (value: unknown): Bag | undefined =>
8585
value && typeof value === 'object' ? (value as Bag) : undefined;
8686

87+
/**
88+
* A stack's `packages` value, judged ONCE for every reader in this package that
89+
* walks it: the entries, by position, or `[]` when the key is absent.
90+
*
91+
* ## A present non-array `packages` is refused, never read as "no packages"
92+
*
93+
* A `packages` that is present but is not an array (`{}`, `0`, `'x'`) is
94+
* MALFORMED, not absent (ruling A on #15293). The rule is stated once, beside
95+
* `AssembledPackageBodySchema` (`@objectstack/spec`, `stack.zod.ts`), and it is
96+
* enforced by `resolveArtifactPackageOrder` (`@objectstack/core`), which
97+
* refuses the value as `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`).
98+
* The runtime, `@objectstack/core` and the plugin readers already refuse it.
99+
* This package's readers used to answer "no packages" instead (#19925): `os
100+
* info` printed `0` objects for such a stack and `os lint` passed it. So this
101+
* function spells neither the rule nor the refusal. It hands a non-array value
102+
* to the resolver, and the refusal the author sees is the resolver's own.
103+
*
104+
* - The key ABSENT (`undefined`) answers `[]`. This is the only value the
105+
* function answers on its own.
106+
* - An array is returned BY REFERENCE and unparsed. The docs readers need
107+
* entries by POSITION (`packages[i]` is where collected docs attach), and the
108+
* resolver answers bodies in LOAD order, so they cannot read its result.
109+
* Parsing each entry is the resolver's job on the path that registers
110+
* packages ({@link packageBodies} reaches it). Adding that parse to the docs
111+
* readers would widen what they refuse, and that is a separate change.
112+
* - Every other value goes to the resolver, `null` included. A non-array is
113+
* refused there.
114+
*
115+
* ## `null` follows the resolver, and is never judged here
116+
*
117+
* Ruling A on #19926 (`5805260775`) settles `null`: it is malformed at every
118+
* reader, and `resolveArtifactPackageOrder` drops its `null` branch. That core
119+
* change lands separately (#19926), and until it does, the resolver still
120+
* answers `null` through its ABSENT branch. So this function does not answer
121+
* `null` itself. It asks the resolver and reads the answer:
122+
*
123+
* - The resolver's absent answer is `[artifact]`, holding the caller's own
124+
* object BY REFERENCE (ADR-0130 D4, second branch). This function recognises
125+
* that answer by IDENTITY against the object it passed in, and returns `[]`.
126+
* That is today's answer for `null`, byte for byte.
127+
* - Once the resolver refuses `null`, its `INVALID_ARTIFACT_PACKAGES` reaches
128+
* every reader here, with no edit to this package.
129+
*
130+
* ⛔ Never add a private `null` branch here, in either direction. Answering
131+
* `null` as absent here would keep the CLI reading it as absent after the
132+
* resolver starts refusing it. Refusing it here would be a second copy of a
133+
* rule the resolver owns.
134+
*
135+
* ⛔ Never put an `Array.isArray` in front of this function as a fall-through
136+
* to "no packages". That silent answer is exactly what this function removes.
137+
*
138+
* @throws Whatever the resolver raises for a present non-array `packages`:
139+
* today `INVALID_ARTIFACT_PACKAGES` for every non-array except `null`.
140+
*/
141+
export function declaredPackageEntries(packages: unknown): readonly unknown[] {
142+
// The key is absent: there is nothing to judge.
143+
if (packages === undefined) return [];
144+
if (Array.isArray(packages)) return packages;
145+
// Present and not an array, `null` included: the resolver decides.
146+
const probe = { packages };
147+
const answer = resolveArtifactPackageOrder(probe);
148+
// The resolver's ABSENT answer holds the probe itself, by reference.
149+
if (answer.length === 1 && answer[0] === probe) return [];
150+
// Reached only if the resolver answers a non-array with anything but a
151+
// refusal or its absent answer. An empty answer here would bring back the
152+
// silent fall-through, so fail loudly.
153+
throw new Error(
154+
`resolveArtifactPackageOrder accepted a \`packages\` of type ${packages === null ? 'null' : typeof packages}; `
155+
+ 'the CLI package readers cannot walk it by position.',
156+
);
157+
}
158+
87159
/**
88160
* The assembled package bodies this stack carries, in dependency-topological
89161
* order — or `[]` when it carries no `packages` list of its own.
@@ -94,10 +166,13 @@ const asBag = (value: unknown): Bag | undefined =>
94166
* a stack's own top level back onto itself resolves nothing — so this returns
95167
* an empty list for that case, and every caller below reads the top level
96168
* first anyway.
169+
*
170+
* A `packages` that is present but is not an array goes through
171+
* {@link declaredPackageEntries}, which hands it to the resolver: it is refused,
172+
* or, for `null` while the resolver still reads it as absent, answered `[]`.
97173
*/
98174
function packageBodies(stack: unknown): Bag[] {
99-
const declared = asBag(stack)?.packages;
100-
if (!Array.isArray(declared) || declared.length === 0) return [];
175+
if (declaredPackageEntries(asBag(stack)?.packages).length === 0) return [];
101176
return (resolveArtifactPackageOrder(stack) as unknown[])
102177
.map(asBag)
103178
.filter((b): b is Bag => b !== undefined);
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A `packages` that is present but is not an array is REFUSED by every
5+
* `@objectstack/cli` reader of it, never read as "no packages" (#19925).
6+
*
7+
* The rule is ruling A on #15293: `{}`, `0` and `'x'` are malformed, not
8+
* absent. It is stated once, beside `AssembledPackageBodySchema`
9+
* (`@objectstack/spec`), and enforced once, by `resolveArtifactPackageOrder`
10+
* (`@objectstack/core`), as `INVALID_ARTIFACT_PACKAGES`. The runtime, core and
11+
* the plugin readers already refused it. The CLI readers answered `[]` or
12+
* handed the value back unchanged, so `os info` printed `0` objects for such a
13+
* stack and `os lint` passed it with exit 0. Both were measured from a
14+
* hand-written config exported as a plain object, which reaches the command
15+
* without `defineStack`'s parse.
16+
*
17+
* Each reader is pinned through the entry its door calls:
18+
*
19+
* - `packageBodies` (private) through `resolveStackCollection` and
20+
* `authoringRuleUnionStack`, and through the two door functions that reach
21+
* it first: `collectMetadataStats` (`os info`) and `lintConfig` (`os lint`).
22+
* - `docsPackageRefs`, directly and through `collectDocsFromSrc`, the
23+
* collection step `os serve` / `os build` / `os lint` run.
24+
* - `attachPackageDocs`, directly.
25+
* - `bodyDocsOf` (private) has one caller, and that caller has already handed
26+
* the same value to `docsPackageRefs`, so a non-array cannot reach it
27+
* through any export. It goes through the same judgment and is not pinned
28+
* separately.
29+
*
30+
* Every refusal asserts the ADR-0112 envelope (`code` + `status`), never a bare
31+
* `toThrow()`, which an unnamed `Error` would also satisfy. The lit control is a
32+
* malformed ENTRY, refused as `INVALID_ARTIFACT_PACKAGE_ENTRY` through the same
33+
* entries. The other controls are a well-formed array, which reads as before,
34+
* and an absent `packages`, which still reads as no packages.
35+
*
36+
* `packages: null` is pinned in `null-packages-follows-resolver.test.ts`, not
37+
* here. Ruling A on #19926 (`5805260775`) makes `null` malformed at every
38+
* reader, and it is refused by core's resolver, never by the CLI. So the pin
39+
* there asserts that each reader answers `null` the way the resolver does. It
40+
* does not assert a fixed answer.
41+
*/
42+
43+
import { describe, expect, it } from 'vitest';
44+
45+
import { lintConfig } from '../src/commands/lint';
46+
import { attachPackageDocs, collectDocsFromSrc, docsPackageRefs, type PackageDocSet } from '../src/utils/collect-docs';
47+
import { collectMetadataStats } from '../src/utils/format';
48+
import { authoringRuleUnionStack, resolveStackCollection } from '../src/utils/stack-collections';
49+
50+
const MANIFEST = {
51+
id: 'com.example.probe',
52+
name: 'probe',
53+
version: '1.0.0',
54+
type: 'app' as const,
55+
namespace: 'probe',
56+
engines: { protocol: '^17' },
57+
};
58+
59+
const PROBE_OBJECT = {
60+
name: 'probe_account',
61+
label: 'Probe Account',
62+
sharingModel: 'private' as const,
63+
fields: { name: { name: 'name', type: 'text' as const, label: 'Name' } },
64+
};
65+
66+
/** The three spellings ruling A names. */
67+
const NON_ARRAYS: ReadonlyArray<readonly [string, unknown]> = [
68+
['{}', {}],
69+
['0', 0],
70+
["'x'", 'x'],
71+
];
72+
73+
/** One well-formed entry, as `resolveArtifactPackageOrder` parses it whole. */
74+
const wellFormed = () => [{ manifest: { ...MANIFEST, objects: [PROBE_OBJECT] } }];
75+
76+
/** The lit control: a body inlined onto the array element, not wrapped. */
77+
const inlinedEntry = () => [{ ...MANIFEST, objects: [PROBE_OBJECT] }];
78+
79+
/** A stack whose ONLY answer to "which objects" has to come from `packages`. */
80+
const stackWith = (packages: unknown) => ({ manifest: MANIFEST, packages });
81+
82+
const docSet = (): PackageDocSet[] => [{
83+
index: 0,
84+
id: MANIFEST.id,
85+
namespace: MANIFEST.namespace,
86+
dir: 'src/probe/docs',
87+
docs: [{ name: 'probe_guide', label: 'Guide', content: '# Guide' }],
88+
}];
89+
90+
/** A config path whose `src/` does not exist: nothing on disk is read. */
91+
const CONFIG_PATH = '/nonexistent-19925/objectstack.config.ts';
92+
93+
/** The ADR-0112 envelope a call raised, or `undefined` when it returned. */
94+
function refusalOf(call: () => unknown): { code?: unknown; status?: unknown } | undefined {
95+
try {
96+
call();
97+
} catch (error) {
98+
expect(error, 'the refusal is an Error carrying the envelope').toBeInstanceOf(Error);
99+
return error as { code?: unknown; status?: unknown };
100+
}
101+
return undefined;
102+
}
103+
104+
function expectRefused(call: () => unknown, code: string): void {
105+
const refusal = refusalOf(call);
106+
expect(refusal, `expected a ${code} refusal, got an answer`).toBeDefined();
107+
expect(refusal?.code).toBe(code);
108+
expect(refusal?.status).toBe(422);
109+
}
110+
111+
describe('#19925: a present non-array `packages` is refused by every CLI reader', () => {
112+
describe.each(NON_ARRAYS)('packages: %s', (_label, packages) => {
113+
it('packageBodies refuses through resolveStackCollection and authoringRuleUnionStack', () => {
114+
expectRefused(() => resolveStackCollection(stackWith(packages), 'objects'), 'INVALID_ARTIFACT_PACKAGES');
115+
expectRefused(() => authoringRuleUnionStack(stackWith(packages)), 'INVALID_ARTIFACT_PACKAGES');
116+
});
117+
118+
it('`os info` (collectMetadataStats) and `os lint` (lintConfig) refuse instead of answering', () => {
119+
expectRefused(() => collectMetadataStats(stackWith(packages)), 'INVALID_ARTIFACT_PACKAGES');
120+
expectRefused(() => lintConfig(stackWith(packages)), 'INVALID_ARTIFACT_PACKAGES');
121+
});
122+
123+
it('docsPackageRefs refuses, directly and through collectDocsFromSrc', () => {
124+
expectRefused(() => docsPackageRefs(packages), 'INVALID_ARTIFACT_PACKAGES');
125+
expectRefused(() => collectDocsFromSrc(CONFIG_PATH, packages), 'INVALID_ARTIFACT_PACKAGES');
126+
});
127+
128+
it('attachPackageDocs refuses rather than handing the value back', () => {
129+
expectRefused(() => attachPackageDocs(packages, docSet()), 'INVALID_ARTIFACT_PACKAGES');
130+
});
131+
});
132+
});
133+
134+
describe('#19925 controls: the same entries still read the well-formed and absent shapes', () => {
135+
it('a well-formed array reads as before', () => {
136+
expect(resolveStackCollection(stackWith(wellFormed()), 'objects')).toEqual([PROBE_OBJECT]);
137+
expect(collectMetadataStats(stackWith(wellFormed())).objects).toBe(1);
138+
expect(docsPackageRefs(wellFormed()).map((ref) => ref.id)).toEqual([MANIFEST.id]);
139+
expect(collectDocsFromSrc(CONFIG_PATH, wellFormed()).packageDocs).toEqual([]);
140+
141+
const attached = attachPackageDocs(wellFormed(), docSet()) as Array<{ manifest: { docs?: unknown[] } }>;
142+
expect(attached[0].manifest.docs).toEqual(docSet()[0].docs);
143+
});
144+
145+
it('an empty array reads as no packages, and attachPackageDocs hands it back by identity', () => {
146+
const empty: unknown[] = [];
147+
expect(resolveStackCollection(stackWith(empty), 'objects')).toEqual([]);
148+
expect(docsPackageRefs(empty)).toEqual([]);
149+
expect(attachPackageDocs(empty, docSet())).toBe(empty);
150+
});
151+
152+
it('an absent `packages` still reads as no packages', () => {
153+
const absent = { manifest: MANIFEST };
154+
expect(resolveStackCollection(absent, 'objects')).toEqual([]);
155+
expect(authoringRuleUnionStack(absent)).toBe(absent);
156+
expect(collectMetadataStats(absent).objects).toBe(0);
157+
expect(docsPackageRefs(undefined)).toEqual([]);
158+
expect(attachPackageDocs(undefined, docSet())).toBeUndefined();
159+
});
160+
161+
it('lit control: a malformed ENTRY is refused through the same entries, with its own code', () => {
162+
expectRefused(() => resolveStackCollection(stackWith(inlinedEntry()), 'objects'), 'INVALID_ARTIFACT_PACKAGE_ENTRY');
163+
expectRefused(() => collectMetadataStats(stackWith(inlinedEntry())), 'INVALID_ARTIFACT_PACKAGE_ENTRY');
164+
expectRefused(() => lintConfig(stackWith(inlinedEntry())), 'INVALID_ARTIFACT_PACKAGE_ENTRY');
165+
});
166+
});

0 commit comments

Comments
 (0)