Repository navigation
Commit 0e06f3b
fix(spec): strict defineStack refuses a Set / Map for a map-form collection instead of accepting it as empty (#19811)
Fixes #19796
Clause-②: no (narrowing)
`normalizeMetadataCollection` now reads **only a plain object** as the
map form of a collection. Before, its map branch tested `typeof value
=== 'object'`, so a `Set`, `Map` or `Date` went through `Object.entries`
(which yields an empty list for them) before the schema parse, and
strict `defineStack` accepted the stack with the collection empty. Every
authored entry was gone, with no error and no warning. Now a non-plain
object reaches the parse unchanged, and the ordinary strict envelope
refuses it at the key.
Direction: **refuse, not convert.** Converting an iterable of entries
would widen the strict door's accept set, and the triage condition
(`5790091608`) put that out of scope. The governing sentence is ruling
`5690859601` on #18239: 「A composed artifact is complete or it is
refused」.
## Landing site
`packages/spec/src/shared/metadata-collection.zod.ts`: a module-local
`isPlainObject` guards the map branch. It treats a value as plain when
its prototype is `null`, or when its prototype's own prototype is
`null`. The second case accepts a plain object from another realm (a
`vm` context), whose prototype is that realm's `Object.prototype`. This
is the only producer, so no other package is touched.
## Measurement (tsx probe, strict `defineStack({ manifest, permissions:
VALUE })`)
| VALUE | base `0b83e01627` | this branch |
| :--- | :--- | :--- |
| `new Set([{ name: 'rep', label: 'Rep' }])` | ACCEPTED, `permissions`
is `[]` | `STACK_SCHEMA_INVALID` 422, issue `invalid_type` at
`['permissions']`, `expected: 'array'` |
| `new Map([['rep', { label: 'Rep' }]])` | ACCEPTED, `[]` | same refusal
|
| `new Date()` | ACCEPTED, `[]` | same refusal |
| a class instance with a `rep` field | read as a map of its own fields
(then refused deeper, at `permissions.0.objects`) | same refusal, at the
key |
| `Object.create(null)` map with `rep` | normalized, as a map |
unchanged (normalized) |
The refusal message is `permissions: Expected array but received
object.`, and it names the key where the value was written. No new error
code and no prescription text: see the Acceptance notes.
## Callers of `normalizeMetadataCollection`
In spec it is called only through `normalizeStackInput` and
`normalizePluginMetadata`, in the same file. `normalizePluginMetadata`
has no callers outside `packages/spec`. `normalizeStackInput` has these
callers outside the spec package:
- **Callers that parse:** `defineStack` (strict), CLI `compile`,
`validate`, `lint`, `lint/score`, `utils/scaffold-validate`, `migrate
meta`. A non-plain object used to reach the parse as `[]` and pass. Now
it is refused at the key.
- **`defineStack({ strict: false })`:** the value flows through
unparsed. `composeStacks` step 3 then refuses it (landed in #19794),
where before a `[]` composed silently.
- **Readers that do not parse:** CLI `info`, `doctor`, `i18n check`,
`i18n extract`, the dogfood `build-shaped-artifact` helper. They now see
the original `Set` / `Map` instead of `[]`. They guard with
`Array.isArray` or count through `resolveStackCollection`, so the
content is not counted either before or after. Nothing is widened.
## Tests
New
`packages/spec/src/shared/metadata-collection-non-plain-object.test.ts`:
- Unit tests: `normalizeMetadataCollection` returns a Set, Map, Date or
class instance unchanged (same reference). Controls: an object literal,
a null-prototype object and a cross-realm plain object are still
normalized.
- Envelope tests: for every key in `MAP_SUPPORTED_FIELDS` (derived from
the list, 21 keys) and each of the four non-plain shapes, strict
`defineStack` refuses with `code: 'STACK_SCHEMA_INVALID'` and `status:
422`, plus a zod issue `invalid_type` at `[key]` with `expected:
'array'`. Control: `permissions` written as a plain-object map is
accepted with its entry.
Runs at head `a4c6b1c5ad`:
- targeted: `vitest run --project local` on the new file plus
`metadata-collection.test.ts`: 2 files, 138 tests passed.
- full spec suite: `pnpm --filter @objectstack/spec exec vitest run
--project local`: 519 files, 15309 passed, 1 todo.
- `pnpm --filter @objectstack/spec typecheck`: exit 0. `build` then
`check:generated`: all 15 generated artifacts up to date.
- **Ablation** (fix committed first, through
`scripts/ablation-replace.mjs`): the anchor `if (isPlainObject(value))
{` was replaced by `if (typeof value === 'object') {`. The anchor count
went 1 to 0, the replacement count 0 to 1, and the blob `f5f0f9d131`
became `88a88c8d96`. Result: **88 failed / 50 passed**. The 88 failures
are 84 envelope cases (21 keys times 4 shapes) and 4 unit cases, and
every control stayed green. Restore: the blob equals HEAD and `git diff
HEAD` is empty. The test imports `src/` directly, so no `dist` leg is
involved.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --ran` reports 83 derived, 81 run with exit
0, 2 NOT MEASURED. Both are `check:dual-build-cjs-loads` and
`check:type-check-debt`, which exited 3 with PREREQUISITE NOT MET: they
need the whole workspace built, and CI runs them.
`check:doc-formula-expressions` and `check:lean-entry-closure` first
exited 3; they went green after building `formula`, `lint` and
`objectql`.
- eslint, narrowed to the 2 changed files (`--no-inline-config --format
json`): 2 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no
type-aware linting (no `parserOptions.project`), so this diff cannot
change the verdict for any untouched file. The full `pnpm lint` run is
CI's.
## Changeset
`.changeset/19796-map-form-non-plain-object.md`: `minor`, BREAKING,
ADR-0087 `not-required (no-migration-prescription)`. A `Set`, `Map` or
class instance is not a serializable metadata document, so there is
nothing for `migrate meta` to rewrite.
## Acceptance notes
- The refusal text is the error map's generic `Expected array but
received object.` A Set-specific prescription (for example, naming the
constructor) would change `shared/error-map.zod.ts`, which is outside
this card's file surface, so it is not done here. The key and the
envelope are already exact.
- `normalizePluginMetadata`: when the alias value (for example
`triggers`) is not an array after normalization, the alias key is
deleted without merging. This is true both before and after this change,
and for any non-collection value. The function has no callers outside
`packages/spec` (consumer: none). This is an observation only, and no
card is filed.
- A class instance used as a map is no longer read as a map of its own
fields. This is part of the same narrowing: only a plain object is the
declared map form (`MetadataCollectionInput` is `T[] | Record`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6eaa0f4 commit 0e06f3b
3 files changed
Lines changed: 160 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
114 | 114 | | |
115 | 115 | | |
116 | 116 | | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
117 | 129 | | |
118 | 130 | | |
119 | 131 | | |
120 | 132 | | |
121 | 133 | | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
122 | 137 | | |
123 | 138 | | |
124 | 139 | | |
| |||
148 | 163 | | |
149 | 164 | | |
150 | 165 | | |
151 | | - | |
152 | | - | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
153 | 174 | | |
154 | 175 | | |
155 | 176 | | |
| |||
0 commit comments