Skip to content

Commit 4d221c0

Browse files
committed
fix(lint,spec): packagesOf refuses packages: null too, and lists its stamp under @objectstack/lint's ledger key
Rework round 1. Ruling A on #19926 (5805260775): `null` is malformed, everywhere. `packages/lint`'s site census (#20206, ruling A on #15293) put `null` out of scope with a pointer to #19926, but #19926's own claim fenced `packages/lint` out as this card's surface -- the lint leg had no owner. `packagesOf` (object-graph.ts) now treats only `undefined` as absent; `null` falls to the same INVALID_ARTIFACT_PACKAGES refusal as `{}`/`0`/`'x'`/ a keyed object. The message names `null` as itself rather than `typeof`'s `'object'`, matching the type label PR #20228 uses in packages/core/src/artifact-packages.ts. Every `null` pin flips from a silence control to a refusal assertion, at `packagesOf` directly and at each of the three public functions its four (now five, with validate-mapping-target-fields.ts) call sites sit behind. `undefined` (absent) and a well-formed array stay green controls. CI fix: `check:error-code-provenance` (job "Lint & Repo Gates", step 120) was red on the prior head -- `packages/lint` stamps the registered code `INVALID_ARTIFACT_PACKAGES` (object-graph.ts's `err.code = ...`) without being listed under its own owner key in `ERROR_CODE_LEDGER`. Fixed the way the gate prescribes: a new `'@objectstack/lint'` row in packages/spec/src/api/error-code-ledger.zod.ts, with a comment recording the wire path (door: 'none' -- packages/lint's rules are pure `(stack) => Finding[]` functions called from `os validate`/`os lint`/ `os build`, never through an HTTP boundary). No code minted or duplicated; the existing registered code is reused, provenance is merely now recorded under a second owner (precedent: 3f9e2ea, "list plugin-security's class-field error codes under its own ledger key"). Two changesets: `@objectstack/lint: minor` (Clause-②: no (narrowing) -- unchanged from round 1) for the behavior change, and a new `@objectstack/spec: minor` (Clause-②: yes) for the ledger row -- a new per-package face on a published payload, modelled on the 3f9e2ea precedent's own spec-only changeset. The PR-level declaration becomes `Clause-②: yes (narrowing)`, carrying both facts. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
1 parent bd29ec3 commit 4d221c0

8 files changed

Lines changed: 74 additions & 29 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`ERROR_CODE_LEDGER['@objectstack/lint']` now lists `INVALID_ARTIFACT_PACKAGES`, the code `packages/lint`'s `packagesOf` reader stamps for a malformed `stack.packages` (#20206) — required by `check:error-code-provenance`, which refuses a registered code stamped by a package whose own owner key does not list it.
6+
7+
Clause-②: yes
8+
9+
Provenance, not identity: the code was already registered under `@objectstack/core` (`resolveArtifactPackageOrder`, the producer `packagesOf` deliberately mirrors rather than mints a new code for), so the `ErrorCode` union, the wire, and every other package's rows are unchanged. What widens is the per-package face a consumer reads from `ERROR_CODE_LEDGER['@objectstack/lint']`, newly present where it was absent before. Nothing to migrate.

‎.changeset/20206-lint-packages-non-array-refused.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
"@objectstack/lint": minor
33
---
44

5-
`packages/lint`'s four `stack.packages` readers (plus a fifth added by #20208 after this ruling's own site census) now refuse a PRESENT non-array `packages` — `{}`, `0`, `'x'`, or a keyed object — the same way `@objectstack/core`'s `resolveArtifactPackageOrder` already does, instead of silently reading it as "no packages" (#20206, ruling A on #15293, comment 5634034754).
5+
`packages/lint`'s five `stack.packages` readers (four named by #20206, plus one added by #20208 after that card's site census) now refuse a PRESENT non-array `packages` — `{}`, `0`, `'x'`, a keyed object, and (as of this round) `null` too — the same way `@objectstack/core`'s `resolveArtifactPackageOrder` already does, instead of silently reading it as "no packages" (#20206, ruling A on #15293 comment 5634034754; the `null` leg is ruling A on #19926, comment 5805260775: `null` is malformed, everywhere).
66

77
Clause-②: no (narrowing)
88

99
<!-- adr-0087: not-required (no-migration-prescription) an already-malformed `packages` value is refused rather than converted; no key, export or stored value moves, and nothing in this repo emits the shape today -->
1010

11-
- **What changes**: `validateObjectReferences`, `validateTranslationReferences` and `validateMappingTargetFields` (the three public `@objectstack/lint` functions these readers sit behind) now throw an `INVALID_ARTIFACT_PACKAGES` error (ADR-0112, `status: 422`) instead of returning findings, when the stack they are handed carries a `packages` key that is present but not an array. `os validate` / `os lint` / `os build` surface it as a refusal on stderr (and in `error`/`code` under `--json`) instead of reporting the stack as clean.
12-
- **What does not change**: an absent `packages`, and `packages: null`, are still read as "no packages" — unchanged, and #19926's surface, not this one. A well-formed `packages[]` array is read exactly as before, junk entries dropped exactly as before.
11+
- **What changes**: `validateObjectReferences`, `validateTranslationReferences` and `validateMappingTargetFields` (the three public `@objectstack/lint` functions these readers sit behind) now throw an `INVALID_ARTIFACT_PACKAGES` error (ADR-0112, `status: 422`) instead of returning findings, when the stack they are handed carries a `packages` key that is present but not an array — `null` included. `os validate` / `os lint` / `os build` surface it as a refusal on stderr (and in `error`/`code` under `--json`) instead of reporting the stack as clean.
12+
- **What does not change**: an absent `packages` (the key omitted, or explicitly `undefined`) is still read as "no packages" — unchanged. A well-formed `packages[]` array is read exactly as before, junk entries dropped exactly as before.
1313
- **Fix**: write `packages` as an array of `{ manifest: … }` entries, or omit the key entirely for a single-package stack.

‎packages/lint/src/object-graph.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -307,15 +307,11 @@ describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)'
307307
expect(packagesOf({ packages: [null, valid, undefined, 'junk', 42, []] })).toEqual([valid]);
308308
});
309309

310-
it('an absent `packages` stays silent — `[]`, not a refusal', () => {
310+
it('CONTROL — only an absent (`undefined`) `packages` stays silent — `[]`, not a refusal', () => {
311311
expect(packagesOf({})).toEqual([]);
312312
expect(packagesOf({ packages: undefined })).toEqual([]);
313313
});
314314

315-
it('`packages: null` also stays silent here — #19926 owns that disagreement, not this reader', () => {
316-
expect(packagesOf({ packages: null })).toEqual([]);
317-
});
318-
319315
it.each([
320316
['an empty object', {}],
321317
['a keyed object (the shape `recordsOf` would have read as a map)', { a: { manifest: {} } }],
@@ -331,6 +327,19 @@ describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)'
331327
new RegExp(`\`packages\` of type ${typeof shape}`),
332328
);
333329
});
330+
331+
// [ruling A on #19926, `5805260775`] `null` is malformed, everywhere — it is
332+
// PRESENT, not absent, so it takes the same refusal as `{}`/`0`/`'x'`, not
333+
// the silent branch above. `typeof null` is `'object'`, which would name a
334+
// `{}` the author never wrote, so the message names `null` as itself
335+
// (matching `resolveArtifactPackageOrder` in `@objectstack/core`, PR #20228).
336+
it('refuses `packages: null` too — malformed, not absent (ruling `5805260775` on #19926)', () => {
337+
expect(() => packagesOf({ packages: null })).toThrow(
338+
expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }),
339+
);
340+
expect(() => packagesOf({ packages: null })).toThrow(/`packages` of type null/);
341+
expect(() => packagesOf({ packages: null })).not.toThrow(/of type object/);
342+
});
334343
});
335344

336345
/**

‎packages/lint/src/object-graph.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,16 +239,17 @@ export type StackPackagesError = Error & { code: string; status: number };
239239
* form at all, so this is a second, narrower reader rather than a branch on
240240
* the first one.
241241
*
242-
* - **Absent** (`undefined` / `null`) → `[]`. A single-package artifact
242+
* - **Absent** (`undefined` ONLY) → `[]`. A single-package artifact
243243
* contributes nothing here — this answers "what does `packages[]` add",
244-
* never "what does this stack provide". (`null` is left exactly this way
245-
* on purpose — #19926 owns that disagreement, not this function.)
244+
* never "what does this stack provide". `null` is malformed, per ruling
245+
* `5805260775` on #19926.
246246
* - **An array** → iterated, non-record members dropped — unchanged from
247247
* what every one of these four call sites did through `recordsOf` before
248248
* this function existed.
249-
* - **Anything else present** → refused, once, here — replacing four copies
250-
* of the same read across `validate-object-references.ts` and
251-
* `validate-translation-references.ts` (#20206).
249+
* - **Anything else present, `null` included** → refused, once, here —
250+
* replacing four copies of the same read across
251+
* `validate-object-references.ts` and `validate-translation-references.ts`
252+
* (#20206).
252253
*
253254
* ⛔ Do not fold this into `recordsOf` itself (#20206's card): that reader
254255
* stays the shared map-or-array reader its other callers need.
@@ -260,13 +261,19 @@ export type StackPackagesError = Error & { code: string; status: number };
260261
*/
261262
export function packagesOf(stack: unknown): AnyRec[] {
262263
const declared = (stack as { packages?: unknown } | null | undefined)?.packages;
263-
if (declared === undefined || declared === null) return [];
264+
// ⛔ `undefined` ONLY. `null` is present, not absent — ruling `5805260775`
265+
// on #19926 — so it falls to the refusal below with every other non-array
266+
// value.
267+
if (declared === undefined) return [];
264268
if (Array.isArray(declared)) return declared.filter(isRec);
265269
const err = new Error(
266270
'A stack\'s `packages` must be an array of package entries (ADR-0130 D4, '
267271
+ '`ArtifactPackageSchema`), but this stack carries `packages` of type '
268-
+ `${typeof declared}. Omit the key entirely for a single-package stack — `
269-
+ '`manifest` is retained, not replaced.',
272+
// `typeof null` is `'object'`, which would name a `{}` the author never
273+
// wrote; `null` is named as itself (matching `resolveArtifactPackageOrder`
274+
// in `@objectstack/core`).
275+
+ `${declared === null ? 'null' : typeof declared}. Omit the key entirely for a `
276+
+ 'single-package stack — `manifest` is retained, not replaced.',
270277
) as StackPackagesError;
271278
err.code = 'INVALID_ARTIFACT_PACKAGES';
272279
err.status = 422;

‎packages/lint/src/validate-mapping-target-fields.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,11 @@ describe('validateMappingTargetFields', () => {
107107
// after the ruling's own site census (`origin/main` `1c8b320`) — a fifth
108108
// copy of the same `recordsOf(stack.packages)` fall-through the ruling
109109
// closes elsewhere in this package. A PRESENT non-array `packages` is
110-
// malformed, not absent; only `undefined`/`null` stay silent.
110+
// malformed, not absent; only `undefined` stays silent. `null` joins this
111+
// set in rework round 1 (ruling A on #19926, `5805260775`): it is present,
112+
// not absent.
111113
it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => {
112-
for (const packages of [{}, 0, 'x']) {
114+
for (const packages of [{}, 0, 'x', null]) {
113115
expect(() => validateMappingTargetFields({
114116
objects: [contact],
115117
packages,

‎packages/lint/src/validate-object-references.test.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -297,18 +297,20 @@ describe('validateObjectReferences — artifact packages[] as resolution context
297297
expect(findings[0].path).toBe('objects[0].fields.account.reference');
298298
});
299299

300-
it('`packages: null` stays absent, unlike a present non-array value (#19926 owns `null`, not this rule)', () => {
301-
const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, null));
300+
it('CONTROL — `packages: undefined` (absent) stays silent — the only value this reader treats as absent', () => {
301+
const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, undefined));
302302
expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.reference']);
303303
});
304304

305305
// [#20206, ruling A on #15293 `5634034754`] Was "ignores a `packages` value
306-
// that is not a list of entries" — `42` and `'core'` used to fall through
307-
// `recordsOf` to `[]` and be silently treated as "no packages", exactly the
308-
// fall-through the ruling closes: PRESENT but not an array is malformed, not
309-
// absent, and every reader refuses it.
306+
// that is not a list of entries" — `null`, `42` and `'core'` used to fall
307+
// through `recordsOf` to `[]` and be silently treated as "no packages",
308+
// exactly the fall-through the ruling closes: PRESENT but not an array is
309+
// malformed, not absent, and every reader refuses it. `null` joins this set
310+
// in rework round 1 (ruling A on #19926, `5805260775`): it is present, not
311+
// absent, so it is no longer a control.
310312
it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => {
311-
for (const packages of [42, 'core']) {
313+
for (const packages of [null, 42, 'core']) {
312314
expect(() => validateObjectReferences(perPackageStack(ORDERS_BODY, packages))).toThrow(
313315
expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }),
314316
);

‎packages/lint/src/validate-translation-references.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,16 +543,18 @@ describe('validateTranslationReferences — a PRESENT non-array `packages` (#202
543543
const oneBundle = [{ 'zh-CN': { objects: {} } }];
544544

545545
it('refuses instead of silently treating it as absent', () => {
546-
for (const packages of [{}, 0, 'x', { a: { manifest: {} } }]) {
546+
for (const packages of [{}, 0, 'x', { a: { manifest: {} } }, null]) {
547547
expect(() => validateTranslationReferences({ objects: [], translations: oneBundle, packages })).toThrow(
548548
expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }),
549549
);
550550
}
551551
});
552552

553-
it('CONTROL — an absent or `null` `packages` stays silent', () => {
553+
// [ruling A on #19926, `5805260775`] `null` moved from the control above
554+
// into the refusal set in rework round 1: it is present, not absent.
555+
it('CONTROL — only an absent (`undefined`) `packages` stays silent', () => {
554556
expect(validateTranslationReferences({ objects: [], translations: oneBundle })).toEqual([]);
555-
expect(validateTranslationReferences({ objects: [], translations: oneBundle, packages: null })).toEqual([]);
557+
expect(validateTranslationReferences({ objects: [], translations: oneBundle, packages: undefined })).toEqual([]);
556558
});
557559
});
558560

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,20 @@ export const ERROR_CODE_LEDGER = {
13561356
'STACK_COMPOSE_KEY_CONFLICT', // a single-valued top-level key is declared with different values by two stacks
13571357
'STACK_COMPOSE_OBJECT_CONFLICT', // the same object name is defined by more than one stack under the default `objectConflict: 'error'`
13581358
],
1359+
'@objectstack/lint': [
1360+
// [#20206] `packagesOf` (`object-graph.ts`) refuses a present non-array
1361+
// `stack.packages` ({}, 0, 'x', a keyed object, `null`) with the SAME
1362+
// registered code `@objectstack/core`'s `resolveArtifactPackageOrder`
1363+
// raises for the identical defect on an assembled artifact (ruling A on
1364+
// #15293 `5634034754`; the `null` leg is ruling A on #19926 `5805260775`)
1365+
// — deliberately reused, never minted, so an author sees one code
1366+
// regardless of which reader catches the malformed shape first. `door:
1367+
// 'none'`, the #16449 reading: `packages/lint`'s rules are pure
1368+
// `(stack) => Finding[]` functions called from `os validate` / `os lint`
1369+
// / `os build` (`packages/cli`), never through an HTTP boundary — the
1370+
// same posture `@objectstack/spec`'s own `STACK_*` rows above record.
1371+
'INVALID_ARTIFACT_PACKAGES',
1372+
],
13591373
} as const satisfies Record<string, readonly string[]>;
13601374

13611375
/** A code registered by at least one package (deduped union of the ledger). */

0 commit comments

Comments
 (0)