Skip to content

Commit 1282c7e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19146-tier-spellings-rekey
2 parents 8f7e56a + 0e06f3b commit 1282c7e

3 files changed

Lines changed: 160 additions & 2 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): strict `defineStack` refuses a `Set`, `Map` or other non-plain object for a map-form collection key instead of accepting it as an empty collection
6+
7+
**BREAKING** — `defineStack` (strict, the default) now refuses a class of input it used to accept with every authored entry silently missing.
8+
9+
`normalizeMetadataCollection` turns the map form of a collection (`permissions: { rep: { … } }`) into an array before the schema parse. It read any `typeof 'object'` value as that map form, so a `Set`, a `Map` or a `Date` went through `Object.entries`, which yields `[]` for them. The parse then saw a valid empty array: `defineStack({ manifest, permissions: new Set([{ name: 'rep', … }]) })` was accepted with `permissions: []` — the author's grants gone, no error, no warning. This reached every map-form key (every entry of `MAP_SUPPORTED_FIELDS`: `objects`, `apps`, `permissions`, `flows`, `agents`, …).
10+
11+
| the key's value | before | after |
12+
| :--- | :--- | :--- |
13+
| a `Set`, a `Map`, a `Date` | accepted, the collection is `[]` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, zod issue at the key (`expected: 'array'`) |
14+
| a class instance | read as a map of its own fields | refused the same way |
15+
| an object literal, `Object.create(null)`, a plain object from another realm | normalized (key → `name`) | unchanged |
16+
| an array | passed through | unchanged |
17+
18+
Only a plain object is the map form; every other value reaches the parse unchanged and is refused there, at the key where it was written. `normalizeMetadataCollection`, `normalizeStackInput` and `normalizePluginMetadata` (public `@objectstack/spec` exports) now return such a value unchanged instead of `[]`.
19+
20+
The one-line fix: author the key as an array (`[...set]`, `[...map.values()]`) or as a plain-object map (`Object.fromEntries(map)`).
21+
22+
No code is added to the ADR-0112 ledger and no export changes: the refusal is the strict parse's existing `STACK_SCHEMA_INVALID`.
23+
24+
<!-- 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. A Set, Map or class instance is not a serializable metadata document, so no stored or authored source file carries one and objectstack migrate meta has nothing to rewrite; what narrows is the normalizer's reading of in-memory values that the map form never declared. -->
25+
26+
Clause-②: no (narrowing)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `normalizeMetadataCollection` reads ONLY a plain object as the map form.
5+
*
6+
* ## What was wrong
7+
*
8+
* The map-form branch tested `typeof value === 'object'`, so a `Set`, a `Map`
9+
* or a `Date` was read as a map too. `Object.entries` of any of them is `[]`,
10+
* and normalization runs before the schema parse, so the strict `defineStack`
11+
* door saw a valid empty array and ACCEPTED the stack with every authored
12+
* entry (e.g. its permission-set grants) gone.
13+
*
14+
* ## What is pinned
15+
*
16+
* A composed artifact is complete or it is refused. For every key that accepts
17+
* the map form (derived from `MAP_SUPPORTED_FIELDS`, never transcribed), a
18+
* non-plain object reaches the strict parse unchanged and is refused with the
19+
* ordinary strict envelope: `STACK_SCHEMA_INVALID`, `status: 422`, a zod issue
20+
* rooted at the key expecting an array. The controls: the same key authored as
21+
* a plain-object map — a literal, a null-prototype object, and a plain object
22+
* from another realm — is still normalized.
23+
*/
24+
import { describe, it, expect } from 'vitest';
25+
import { runInNewContext } from 'node:vm';
26+
import { normalizeMetadataCollection, MAP_SUPPORTED_FIELDS } from './metadata-collection.zod';
27+
import { defineStack } from '../stack.zod';
28+
29+
type Envelope = Error & {
30+
code?: string;
31+
status?: number;
32+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>;
33+
};
34+
35+
/** The thrown value, or `null` when the stack is accepted. */
36+
function refusal(fn: () => unknown): Envelope | null {
37+
try {
38+
fn();
39+
return null;
40+
} catch (e) {
41+
return e as Envelope;
42+
}
43+
}
44+
45+
const manifest = { id: 'com.example.a', name: 'a', version: '1.0.0', type: 'app' as const };
46+
47+
class Holder {
48+
rep = { label: 'Rep' };
49+
}
50+
51+
const NON_PLAIN: ReadonlyArray<readonly [string, () => unknown]> = [
52+
['a Set', () => new Set([{ name: 'rep', label: 'Rep' }])],
53+
['a Map', () => new Map([['rep', { label: 'Rep' }]])],
54+
['a Date', () => new Date(0)],
55+
['a class instance', () => new Holder()],
56+
];
57+
58+
describe('normalizeMetadataCollection — only a plain object is the map form', () => {
59+
for (const [label, make] of NON_PLAIN) {
60+
it(`${label} is returned unchanged, never read as an empty map`, () => {
61+
const value = make();
62+
expect(normalizeMetadataCollection(value)).toBe(value);
63+
});
64+
}
65+
66+
it('control: an object literal is normalized with key → name', () => {
67+
expect(normalizeMetadataCollection({ rep: { label: 'Rep' } })).toEqual([{ name: 'rep', label: 'Rep' }]);
68+
});
69+
70+
it('control: a null-prototype object is normalized with key → name', () => {
71+
const map = Object.assign(Object.create(null), { rep: { label: 'Rep' } });
72+
expect(normalizeMetadataCollection(map)).toEqual([{ name: 'rep', label: 'Rep' }]);
73+
});
74+
75+
it('control: a plain object from another realm is normalized with key → name', () => {
76+
const map = runInNewContext('({ rep: { label: "Rep" } })');
77+
expect(Object.getPrototypeOf(map)).not.toBe(Object.prototype);
78+
expect(normalizeMetadataCollection(map)).toEqual([{ name: 'rep', label: 'Rep' }]);
79+
});
80+
});
81+
82+
describe('strict defineStack refuses a non-plain object for a map-form collection key', () => {
83+
it('covers every map-form key the normalizer declares (the list is the census)', () => {
84+
expect(MAP_SUPPORTED_FIELDS).toContain('permissions');
85+
expect(MAP_SUPPORTED_FIELDS.length).toBeGreaterThanOrEqual(20);
86+
});
87+
88+
for (const key of MAP_SUPPORTED_FIELDS) {
89+
for (const [label, make] of NON_PLAIN) {
90+
it(`'${key}': ${label} is refused with STACK_SCHEMA_INVALID / 422 at the key — never accepted as []`, () => {
91+
const err = refusal(() => defineStack({ manifest, [key]: make() } as never));
92+
expect(err, `'${key}' given ${label} was accepted`).not.toBeNull();
93+
expect(err!.code).toBe('STACK_SCHEMA_INVALID');
94+
expect(err!.status).toBe(422);
95+
expect(err!.issues).toEqual(
96+
expect.arrayContaining([
97+
expect.objectContaining({ code: 'invalid_type', path: [key], expected: 'array' }),
98+
]),
99+
);
100+
});
101+
}
102+
}
103+
104+
it("control: 'permissions' authored as a plain-object map is accepted with its entry", () => {
105+
const stack = defineStack({
106+
manifest,
107+
permissions: { rep: { label: 'Rep', objects: {} } },
108+
} as never);
109+
expect(stack.permissions?.map((p) => p.name)).toEqual(['rep']);
110+
});
111+
});

‎packages/spec/src/shared/metadata-collection.zod.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,26 @@ export {
114114
singularToPlural,
115115
} from '../meta-spelling/manifest-collection-spelling.js';
116116

117+
/**
118+
* Whether `value` is a plain object — a `{ … }` literal, `Object.create(null)`,
119+
* or a plain object from another realm (a `vm` context): its prototype is
120+
* `null`, or a prototype whose own prototype is `null` (that realm's
121+
* `Object.prototype`). A `Set`, `Map`, `Date`, array or class instance is not.
122+
*/
123+
function isPlainObject(value: unknown): value is Record<string, unknown> {
124+
if (value === null || typeof value !== 'object') return false;
125+
const proto: unknown = Object.getPrototypeOf(value);
126+
return proto === null || Object.getPrototypeOf(proto) === null;
127+
}
128+
117129
/**
118130
* Normalize a single metadata collection value from map format to array format.
119131
* If the input is already an array (or nullish), it is returned unchanged.
120132
* If the input is a plain object (map), it is converted to an array where
121133
* each key is injected as the `name` field of the corresponding item.
134+
* Any other value — including a non-plain object such as a `Set` or `Map` — is
135+
* returned unchanged, so schema validation refuses it rather than this
136+
* function reading it as an empty map.
122137
*
123138
* **Precedence:** If an item already has a `name` property, it is preserved
124139
* (the map key is only used as a fallback).
@@ -148,8 +163,14 @@ export function normalizeMetadataCollection(value: unknown, keyField = 'name'):
148163
// Nullish or already an array — pass through
149164
if (value == null || Array.isArray(value)) return value;
150165

151-
// Plain object — treat as map and convert to array
152-
if (typeof value === 'object') {
166+
// Plain object — treat as map and convert to array. ONLY a plain object: a
167+
// `Set`, `Map`, `Date` or class instance is `typeof 'object'` too, and
168+
// `Object.entries` reads its own enumerable string keys — `[]` for a `Set` or
169+
// a `Map` — so treating it as the map form would hand the parse a valid empty
170+
// array and drop every authored entry before any schema saw it. A non-plain
171+
// object falls through unchanged so the strict parse refuses it as a
172+
// non-array at the key, where it was written.
173+
if (isPlainObject(value)) {
153174
return Object.entries(value as Record<string, unknown>).map(([key, item]) => {
154175
if (item && typeof item === 'object' && !Array.isArray(item)) {
155176
const obj = item as Record<string, unknown>;

0 commit comments

Comments
 (0)