Skip to content

Commit a9fb83e

Browse files
fix(core,runtime,plugin-dev,plugin-security): refuse packages: null as malformed, never absent (#20228)
Fixes #19926 Clause-②: no (narrowing) Executes ruling `5805260775` (letter A, class-1): `packages: null` on a release artifact is **malformed and refused**, never read as absent. `ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema and `composeStacks` (two or more inputs) already refused `null`; every runtime reader read it as absent. The readers now follow the declaration. ⛔ The schema does not change, and no error code is added: `null` gets the envelope `{}`, `0` and `'x'` already get, `INVALID_ARTIFACT_PACKAGES` / `status: 422`. ## Per reader, before and after (`packages: null`) | reader | before | after | |:--|:--|:--| | `@objectstack/core` `resolveArtifactPackageOrder` | `[artifact]` (the absent branch) | refused, `INVALID_ARTIFACT_PACKAGES` / 422 | | `@objectstack/runtime` `resolveArtifactCollections` | the argument, by identity | refused (the resolver's envelope) | | `@objectstack/plugin-dev` `stackDeclaresTranslations` (via `devI18nPluginOptions`) | `false` | refused (the resolver's envelope) | | `@objectstack/plugin-security` `appSecurityPluginOptions` | read the top level | refused (the resolver's envelope) | | `ObjectStackDefinitionSchema` | refused, `invalid_type` | unchanged | | `composeStacks`, two or more inputs | refused, `STACK_SCHEMA_INVALID` / 422 | unchanged | Controls stay put at every reader: an absent `packages` (no key, or an explicit `undefined`) is still the single-package branch, by identity; an array is still read as its entries. ## The ruling's three items, and where each landed 1. **Readers.** `resolveArtifactPackageOrder` drops its `declared === null` branch, so its `@throws` / header wording 「present but is not an array」 is now literally true. The runtime reader's guard and the plugin-dev guard are spelled exactly as the resolver's absent branch (`undefined` only), and they moved in the same commit. plugin-security has no guard of its own; it inherits the refusal, and its docblock no longer names a `null` absent branch. The core message names the value `null` instead of `typeof null` (which reads `object`). 2. **Pins.** `null` is refused at the schema (`invalid_type` at `packages`), at `composeStacks` with two inputs in both positions (`STACK_SCHEMA_INVALID`, 422, issue path `packages`), and at each reader (`code` + `status`), each beside its absent and array controls. The plugin-dev LOCKSTEP pin asks the private guard and `resolveArtifactPackageOrder` directly and asserts they return the same envelope on `{ packages: null }` and agree on an absent key. That is the pin the PR #19924 re-review asked for. 3. **Spec rule text.** The paragraph beside `AssembledPackageBodySchema` that carved `null` out ("the one value this rule does not settle") now names `null` as malformed. It is TSDoc only; the schema bytes are unchanged. ## Pin sweep `git grep` over every test for `packages` next to `null` found three pins asserting the old absent reading: `packages/runtime/src/artifact-collections.test.ts`, `packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts` and `packages/plugins/plugin-security/src/app-default-permission-set.test.ts`. All three are flipped in this PR. Each `null` row now asserts the refusal's substance (`code` and `status`); none of the flips just deletes an assertion. `packages/lint/src/validate-object-references.test.ts` also iterates `null` over `packages`. It pins the lint reader, which is sibling #20206's surface and is fenced out of this claim. It is not touched here. ## Producer census (mechanism hypothesis H3) No in-repo producer writes `packages: null`. The census covered `examples/`, fixtures, tests and generated artifacts, with `packages` followed by `:` and `null`, quoted or bare. The only hits were the three test pins above and the pending `.changeset/15293-non-array-packages-refusal.md` sentence below. `os build` / `os validate` refuse the value at the schema before any reader runs. ## Clause-② — measured, and it differs from the claim's line The claim reads `Clause-②: no`. Measured: the accept set of published exports narrows. `resolveArtifactPackageOrder` (`@objectstack/core` root export), `devI18nPluginOptions` (`@objectstack/plugin-dev`), `appSecurityPluginOptions` (`@objectstack/plugin-security`) and `carriedPackageIds` (`@objectstack/runtime`) all returned an answer for `{ packages: null }` and now throw. So the line is `no (narrowing)`: breaking, graded `minor` under the launch-window convention, in this body and in the changeset. The ADR-0087 disposition is `not-required (no-migration-prescription)`. Nothing authorable moves, because the schema already refused the value. This follows the `.changeset/18239-merge-objects-refusal.md` precedent (a runtime narrowing on inputs that bypassed the parse). `check-adr-0087-registration` reads it green. ## Deviations from the claim's file surface (declared, not silent) - **`packages/spec/src/stack.zod.ts`**: the claim fences this file ("the schema already refuses `null`"). The edit is ruling item 3's rule text, and it is **TSDoc only**; the schema is unchanged, which is what the fence protects. Without it, the one statement of the rule that all four readers cite would still say the readers treat `null` as absent, and this PR would make that false. - **`packages/spec/src/stack-artifact-packages.test.ts`**: ruling item 2's schema and `composeStacks` pins. - **`packages/core/src/artifact-packages.test.ts`** (new): core had no in-package test for the resolver. Its broader pins live in `@objectstack/objectql`'s `artifact-load-path.test.ts`. - **`.changeset/15293-non-array-packages-refusal.md`**: a DELIBERATE CORRECTION of a pending release note (next section). ## Pending release note corrected, confirmation requested `.changeset/15293-non-array-packages-refusal.md` (PR #19924, still pending) said under "What does not change": "an absent `packages`, and `packages: null`, still return the caller's own object by identity". This PR makes the `null` half false in the same release, so the sentence now reads "an absent `packages` still returns the caller's own object by identity … `packages: null` is not absent: it is malformed, and it is refused the same way (#19926)." `check-empty-changeset` refuses this by design ("DELIBERATE CORRECTION -- ... say so on the PR and get it confirmed"). **Check Changeset stays red until a person confirms the correction here**; it is not a required context. `skip-changeset` is not applied. ## Verification (every reading below is at head `ec9402ad05`) All runs were serialized behind `scripts/pm/os-verify-lock.sh`, and each read its `VERDICT command-exit` line. - **Build.** `pnpm turbo run build --filter='@objectstack/plugin-dev^...' --concurrency=2` (the closure of core, runtime, plugin-security, spec and the rest): 34/34 tasks successful, `VERDICT command-exit 0`. - **Affected packages, whole suites.** `@objectstack/core` (project `local`): 55 files, 1426 tests passed. `@objectstack/plugin-security`: 137 files, 2756 passed. `@objectstack/plugin-dev`: 8 files, 82 passed. `@objectstack/runtime` (project `local`): 279 files, 3910 passed, 1 skipped. - **Targeted.** `@objectstack/spec`: `stack-artifact-packages`, `assembled-package-body` and `compose-stacks-concat-shape-refusal`, 3 files, 160 passed. Resolver consumers: objectql `artifact-load-path` 14/14, metadata `plugin-artifact-packages-attribution` 11/11, verify `artifact-collections` 8/8, cli (`unit`) `stack-collections` 16/16. The pin sweep found no other test that feeds `packages: null`. - **Typecheck.** `typecheck` for core, plugin-security, plugin-dev and runtime: all exit 0, each echoing `tsc --noEmit` and `check:test-typecheck: OK`. `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date (its `check:test-typecheck` included), measured against the spec `dist` built above. - **Ablation** (one-time proof, no permanent file). The fix was committed first. Each mutation went through `scripts/ablation-replace.mjs`: anchor hit 1 to 0, blob moved, and after the restore the blob equals `HEAD`'s and `git diff HEAD` is empty. - A. Put `|| declared === null` back into the core resolver's absent branch (src). Red: core `null` row (1 of 6 failed), runtime `null` row (1 of 22), plugin-dev `null` row plus LOCKSTEP (2 of 20). - A through dist. Same mutation, then core rebuilt. `ablation-dist-preflight` found the marker in 2 built files. Red: plugin-security `null` row (1 of 28). Restore leg: rebuilt, `--absent` passed (marker in 0 of 14 built files, tree clean), 28 of 28 green. - B. Only the plugin-dev guard drifts back to `|| packages === null`. Red: the `null` row and LOCKSTEP (2 of 20). That is the lockstep pin catching a guard that disagrees with a fixed resolver. - C. Only the runtime guard drifts back. Red: the runtime `null` row (1 of 22). - After the battery: tree clean against `HEAD`, and a `--reporter=verbose` re-run of all five files lists every new or flipped case green. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` reconciles 87 derived, 84 run, 3 NOT MEASURED, 0 unrun. Of the 84 run, 83 exit 0 and `check-empty-changeset` exits 1: the deliberate correction above, left red on purpose. `check-adr-0087-registration` passes: one declared-breaking changeset, disposition `not-required (no-migration-prescription)`. `check-changeset-no-major`: no `major` (the level axis reads the PR, so it is judged in CI). - NOT MEASURED: `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`. Reason: each exited 3, PREREQUISITE NOT MET. They read a whole-repo build (`dist/` of packages outside this closure), which CI builds. - **Lint, narrowed.** `eslint --no-inline-config --format json` over the 10 changed `.ts` files: 10 files linted, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, stated in its own header), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. - `origin/main` is 5 commits past this branch's base, and none of them touches a path in this diff (`git diff --stat` of those paths is empty). The merge queue validates the merged generation. ## Acceptance notes - `packages/lint` (`validate-object-references`) still ignores `packages: null`, like any non-list. Sibling #20206 owns the lint readers; carrier: #20206. - The CLI's post-parse readers (`packages/cli/src/utils/stack-collections.ts` `packageBodies`, `artifact-packages.ts` `artifactPackages`) test `Array.isArray` and read every non-array as "no packages". They run after the strict schema parse has already refused `null`, `{}`, `0` and `'x'`, so `null` never reaches them through `os build` / `os validate`. Noted, not filed; carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 443b2f4 commit a9fb83e

12 files changed

Lines changed: 236 additions & 41 deletions

‎.changeset/15293-non-array-packages-refusal.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ Clause-②: no
99
`packages` is declared as an array of package entries (`ObjectStackDefinitionSchema.packages: z.array(ArtifactPackageSchema).optional()`), and the rule is now written down once, beside `AssembledPackageBodySchema` in `@objectstack/spec`: an absent `packages` means a single-package artifact, and any other non-array value is malformed and refused. `resolveArtifactPackageOrder` in `@objectstack/core` already refused it, and so did the i18n detector in `@objectstack/plugin-dev` and the default-permission-set reader in `@objectstack/plugin-security`.
1010

1111
- **What changes**: `AppPlugin` reads its collections in `start()`, and `start()` now raises the same refusal `init()` already raised through the kernel's `manifest` service. Under `os dev`, `DevPlugin`'s child-`start()` loop logs it on its `error` line, where before the app started on its top-level collections alone. `createStandaloneStack` now refuses such an artifact while it builds the stack. Before, the refusal came later, when the app registered with the `manifest` service. `loadArtifactBundle`'s runtime-module merge reports it through its existing `warn` line and skips the merge, as it already does for a malformed `packages[]` entry. `resolveProjectDatabaseUrl` no longer reads a default datasource out of such an artifact: it declines, as it already does for any artifact it cannot read, and moves on to the next rung (the unified default database). The boot that loads the artifact then refuses it.
12-
- **What does not change**: an absent `packages`, and `packages: null`, still return the caller's own object by identity. A well-formed `packages[]` resolves exactly as before.
12+
- **What does not change**: an absent `packages` still returns the caller's own object by identity. A well-formed `packages[]` resolves exactly as before. `packages: null` is not absent: it is malformed, and it is refused the same way (#19926).
1313
- **Fix**: remove the `packages` key for a single-package artifact, or make it an array of `{ manifest: … }` entries.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/plugin-dev': minor
5+
'@objectstack/plugin-security': minor
6+
---
7+
8+
fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926)
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, Zod schema, export, config field or stored metadata shape is added, removed, renamed or re-spelled. ObjectStackDefinitionSchema already refused packages null, and so did composeStacks with two or more inputs, so an authored stack that passed its schema reads exactly as before; what narrows is the runtime readers' behaviour on an artifact that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->
13+
14+
**BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level).
15+
16+
`ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added.
17+
18+
- **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry.
19+
- **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too.
20+
- **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback.
21+
- **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`.
22+
- **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity.
23+
24+
No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `resolveArtifactPackageOrder`'s ABSENT branch is `undefined` only (#19926,
5+
* ruling A).
6+
*
7+
* `ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema)
8+
* .optional()`, and `.optional()` admits `undefined`, not `null`. So `null` is
9+
* a present, non-array `packages`: malformed, and refused with the same
10+
* envelope as `{}`, `0` or `'x'`. The rule is stated once, beside
11+
* `AssembledPackageBodySchema` (`@objectstack/spec`, `stack.zod.ts`).
12+
*
13+
* The resolver's wider behaviour (ordering, the entry gate, duplicates) is
14+
* pinned where its load path is, in `@objectstack/objectql`'s
15+
* `artifact-load-path.test.ts`. This file pins the one branch every reader of
16+
* `packages` inherits from here.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { resolveArtifactPackageOrder, type ArtifactPackageError } from './artifact-packages';
21+
22+
const manifest = { id: 'com.example.a', name: 'A', version: '1.0.0', type: 'app' };
23+
24+
function refusalOf(artifact: unknown): ArtifactPackageError | undefined {
25+
try {
26+
resolveArtifactPackageOrder(artifact);
27+
return undefined;
28+
} catch (err) {
29+
return err as ArtifactPackageError;
30+
}
31+
}
32+
33+
describe('resolveArtifactPackageOrder — `packages: null` is malformed, not absent', () => {
34+
it('refuses `packages: null` with INVALID_ARTIFACT_PACKAGES / 422', () => {
35+
const refused = refusalOf({ manifest, packages: null });
36+
expect(refused).toBeInstanceOf(Error);
37+
expect(refused?.code).toBe('INVALID_ARTIFACT_PACKAGES');
38+
expect(refused?.status).toBe(422);
39+
});
40+
41+
it.each([
42+
['{}', {}],
43+
['0', 0],
44+
["'x'", 'x'],
45+
])('refuses `packages: %s` with the same envelope — `null` is one of these, not a fourth case', (_label, packages) => {
46+
const refused = refusalOf({ manifest, packages });
47+
expect(refused?.code).toBe('INVALID_ARTIFACT_PACKAGES');
48+
expect(refused?.status).toBe(422);
49+
});
50+
51+
it('control: an ABSENT `packages` (no key, or an explicit `undefined`) is the single-package branch, returned by identity', () => {
52+
const noKey = { manifest };
53+
const explicitUndefined = { manifest, packages: undefined };
54+
for (const artifact of [noKey, explicitUndefined]) {
55+
expect(refusalOf(artifact)).toBeUndefined();
56+
const resolved = resolveArtifactPackageOrder(artifact);
57+
expect(resolved).toHaveLength(1);
58+
expect(resolved[0]).toBe(artifact);
59+
}
60+
});
61+
62+
it('control: an ARRAY `packages` resolves to its bodies, by reference', () => {
63+
const body = { ...manifest };
64+
const resolved = resolveArtifactPackageOrder({ packages: [{ manifest: body }] });
65+
expect(resolved).toHaveLength(1);
66+
expect(resolved[0]).toBe(body);
67+
expect(resolveArtifactPackageOrder({ packages: [] })).toEqual([]);
68+
});
69+
});

‎packages/core/src/artifact-packages.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
* - `packages` absent → treat `manifest` (singular) as a **single-element list**.
3636
*
3737
* A `packages` that is present but is not an array takes neither branch. It is
38-
* refused here as `INVALID_ARTIFACT_PACKAGES`. The rule is stated once,
38+
* refused here as `INVALID_ARTIFACT_PACKAGES`. `null` is one of those values:
39+
* the key is declared `.optional()`, which admits `undefined` and not `null`,
40+
* so ABSENT means `undefined` and nothing else. The rule is stated once,
3941
* beside `AssembledPackageBodySchema` (`@objectstack/spec`, `stack.zod.ts`).
4042
*
4143
* The second branch is not a convenience: it is the term ADR-0130's whole
@@ -194,7 +196,7 @@ interface ArtifactPackageNode extends OrderablePlugin {
194196
* @returns The manifest bodies to register, in the order to register them.
195197
* @throws An ADR-0112 envelope (`code` + `status: 422`):
196198
* `INVALID_ARTIFACT_PACKAGES` for a `packages` that is present but is not an
197-
* array, `INVALID_ARTIFACT_PACKAGE_ENTRY` for a malformed entry, and
199+
* array (`null` included), `INVALID_ARTIFACT_PACKAGE_ENTRY` for a malformed entry, and
198200
* `DUPLICATE_ARTIFACT_PACKAGE` for a duplicate package id. Also
199201
* `resolvePluginOrder`'s own error for a cycle.
200202
*/
@@ -205,7 +207,12 @@ export function resolveArtifactPackageOrder(artifact: unknown): unknown[] {
205207
// the caller's own object IS that package's manifest body. Returned by
206208
// reference, unvalidated and unrewritten — this is the path every artifact
207209
// built to date takes, and D7 pins that it did not move.
208-
if (declared === undefined || declared === null) return [artifact];
210+
//
211+
// ⛔ `undefined` ONLY. `null` is present, not absent: the schema's
212+
// `.optional()` refuses it, so reading it as absent here would answer for an
213+
// artifact the declaration calls malformed (#19926). It falls to the refusal
214+
// below with every other non-array value.
215+
if (declared === undefined) return [artifact];
209216

210217
// Present but not an array: malformed, never absent. The rule is stated
211218
// once, beside `AssembledPackageBodySchema`.
@@ -214,7 +221,9 @@ export function resolveArtifactPackageOrder(artifact: unknown): unknown[] {
214221
'INVALID_ARTIFACT_PACKAGES',
215222
'A release artifact\'s `packages` must be an array of package entries '
216223
+ '(ADR-0130 D4, `ArtifactPackageEntrySchema`), but this artifact carries '
217-
+ `\`packages\` of type ${typeof declared}. Omit the key entirely for a `
224+
// `typeof null` is `'object'`, which would name a `{}` the author never
225+
// wrote; `null` is named as itself.
226+
+ `\`packages\` of type ${declared === null ? 'null' : typeof declared}. Omit the key entirely for a `
218227
+ 'single-package artifact — `manifest` is retained, not replaced.',
219228
);
220229
}

‎packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
import { describe, it, expect, vi } from 'vitest';
4545
import { composeStacks, defineStack, type ObjectStackDefinition } from '@objectstack/spec';
4646

47-
import { devI18nPluginOptions } from './dev-i18n';
47+
import { resolveArtifactPackageOrder } from '@objectstack/core';
48+
import { devI18nPluginOptions, stackDeclaresTranslations } from './dev-i18n';
4849
import { DevPlugin } from './dev-plugin';
4950

5051
const absent = (name: string): Error =>
@@ -342,7 +343,9 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
342343

343344
// A `packages` that is present but is not an array is MALFORMED, not absent
344345
// (the rule beside `AssembledPackageBodySchema`). This reader's private guard
345-
// may decide only the absent branch, so these three reach the resolver.
346+
// may decide only the absent branch, so these four reach the resolver.
347+
// `null` is one of them since #19926 moved the guard and the resolver's
348+
// absent branch together.
346349
const refusalOf = (stack: unknown): (Error & { code?: string; status?: number }) | undefined => {
347350
try {
348351
devI18nPluginOptions(stack);
@@ -353,6 +356,7 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
353356
};
354357

355358
it.each([
359+
['null', null],
356360
['{}', {}],
357361
['0', 0],
358362
["'x'", 'x'],
@@ -370,16 +374,49 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
370374
expect(refusalOf(optionBProject())).toBeUndefined();
371375
expect(devI18nPluginOptions(optionBProject())).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' });
372376

373-
// Absent, explicitly `undefined`, and `null`: the guard's one decision.
377+
// Absent and explicitly `undefined`: the guard's one decision. `null` is
378+
// not here — it is a row of the refusal table above.
374379
const manifest = { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' };
375-
for (const absent of [{}, { packages: undefined }, { packages: null }]) {
380+
for (const absent of [{}, { packages: undefined }]) {
376381
expect(refusalOf({ manifest, ...absent })).toBeUndefined();
377382
expect(devI18nPluginOptions({ manifest, ...absent })).toBeUndefined();
378383
expect(devI18nPluginOptions({ manifest, ...absent, translations: [{ en: {} }] }))
379384
.toEqual({ defaultLocale: undefined, fallbackLocale: 'en' });
380385
}
381386
});
382387

388+
it('LOCKSTEP — the private guard and `resolveArtifactPackageOrder` agree on `packages: null` and on an absent key', () => {
389+
// The guard is bound to the resolver's absent branch: it may answer
390+
// "absent" for exactly the values the resolver answers `[stack]` for, and
391+
// must hand every other value to it. Asked of both directly, so a guard
392+
// that drifted back to `undefined || null` goes red here even while the
393+
// resolver refuses — the pair measured nothing before #19926.
394+
const envelopeOf = (fn: () => unknown): { code?: unknown; status?: unknown } | undefined => {
395+
try {
396+
fn();
397+
return undefined;
398+
} catch (err) {
399+
const { code, status } = err as { code?: unknown; status?: unknown };
400+
return { code, status };
401+
}
402+
};
403+
const manifest = { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' };
404+
405+
const nullStack = { manifest, packages: null };
406+
const fromResolver = envelopeOf(() => resolveArtifactPackageOrder(nullStack));
407+
const fromGuard = envelopeOf(() => stackDeclaresTranslations(nullStack));
408+
expect(fromResolver).toEqual({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 });
409+
expect(fromGuard).toEqual(fromResolver);
410+
411+
// Control: an absent key is absent to both, and neither throws.
412+
for (const absent of [{ manifest }, { manifest, packages: undefined }]) {
413+
expect(envelopeOf(() => resolveArtifactPackageOrder(absent))).toBeUndefined();
414+
expect(resolveArtifactPackageOrder(absent)).toEqual([absent]);
415+
expect(envelopeOf(() => stackDeclaresTranslations(absent))).toBeUndefined();
416+
expect(stackDeclaresTranslations(absent)).toBe(false);
417+
}
418+
});
419+
383420
// ── What the developer actually gets: the SERVICE ─────────────────────────
384421

385422
const bootWith = async (stack: Record<string, unknown> | undefined) => {

‎packages/plugins/plugin-dev/src/dev-i18n.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ const declaresTranslationArray = (body: unknown): boolean => {
109109
* reader to exactly the old path, and it goes red without the guard.
110110
*
111111
* ⛔ So the guard is allowed to decide ONE thing: the ABSENT branch, spelled
112-
* exactly as the resolver's own absent branch (`undefined` / `null`). Every
113-
* other value goes to the resolver, so a present non-array `packages` (`{}`,
114-
* `0`, `'x'`) is REFUSED as `INVALID_ARTIFACT_PACKAGES`. ⛔ Never widen it to
115-
* `Array.isArray`: that is the silent fall-through the rule above forbids. If
116-
* the resolver's absent branch ever changes, this line changes with it.
112+
* exactly as the resolver's own absent branch (`undefined`, and nothing else).
113+
* Every other value goes to the resolver, so a present non-array `packages`
114+
* (`null`, `{}`, `0`, `'x'`) is REFUSED as `INVALID_ARTIFACT_PACKAGES`. ⛔ Never
115+
* widen it to `Array.isArray`: that is the silent fall-through the rule above
116+
* forbids. If the resolver's absent branch ever changes, this line changes with
117+
* it — it did once, when `null` stopped being absent (#19926), and the two moved
118+
* in one change.
117119
*
118120
* ## A malformed `packages[]` is refused, not skipped
119121
*
@@ -163,7 +165,7 @@ const declaresTranslationArray = (body: unknown): boolean => {
163165
* array.
164166
* @throws An ADR-0112 envelope (`Error & { code, status: 422 }`) from
165167
* `resolveArtifactPackageOrder` when `packages` is present but not loadable:
166-
* `INVALID_ARTIFACT_PACKAGES` (not an array), `INVALID_ARTIFACT_PACKAGE_ENTRY`
168+
* `INVALID_ARTIFACT_PACKAGES` (not an array, `null` included), `INVALID_ARTIFACT_PACKAGE_ENTRY`
167169
* (an entry that is not `{ manifest: … }`, a body carrying authoring-time
168170
* globs where definitions belong, or a manifest with no usable id) or
169171
* `DUPLICATE_ARTIFACT_PACKAGE`.
@@ -179,7 +181,7 @@ export function stackDeclaresTranslations(stack: unknown): boolean {
179181
if (declaresTranslationArray(stack)) return true;
180182

181183
const packages = asBag(stack)?.packages;
182-
if (packages === undefined || packages === null) return false;
184+
if (packages === undefined) return false;
183185

184186
for (const body of resolveArtifactPackageOrder(stack)) {
185187
if (declaresTranslationArray(body)) return true;

‎packages/plugins/plugin-security/src/app-default-permission-set.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,10 @@ describe('appSecurityPluginOptions over `packages[]` (ADR-0130 D4, #15007)', ()
341341

342342
// A `packages` that is present but is not an array is MALFORMED, not absent
343343
// (the rule beside `AssembledPackageBodySchema`). This reader keeps no
344-
// `packages` guard of its own, so the refusal is the resolver's.
344+
// `packages` guard of its own, so the refusal is the resolver's — `null`
345+
// included, since #19926 took `null` out of the resolver's absent branch.
345346
it.each([
347+
['null', null],
346348
['{}', {}],
347349
['0', 0],
348350
["'x'", 'x'],
@@ -359,9 +361,10 @@ describe('appSecurityPluginOptions over `packages[]` (ADR-0130 D4, #15007)', ()
359361
expect(refusalOf({ packages: [wellFormed] })).toEqual({});
360362
expect(appSecurityPluginOptions({ packages: [wellFormed] })).toEqual({ fallbackPermissionSet: CORE_PROFILE });
361363

362-
// Absent, explicitly `undefined`, and `null`: all three read the top level
363-
// exactly as before the private guard was dropped.
364-
for (const absent of [{}, { packages: undefined }, { packages: null }]) {
364+
// Absent and explicitly `undefined`: both read the top level exactly as
365+
// before the private guard was dropped. `null` is a row of the refusal
366+
// table above, not an absent key.
367+
for (const absent of [{}, { packages: undefined }]) {
365368
expect(refusalOf({ ...absent, permissions: [permissionSet('top')] })).toEqual({});
366369
expect(appSecurityPluginOptions({ ...absent, permissions: [permissionSet('top')] }))
367370
.toEqual({ fallbackPermissionSet: 'top' });

0 commit comments

Comments
 (0)