From bd29ec386fdb4535ed9b29f7533ecf29c07688d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:53:46 +0000 Subject: [PATCH 1/6] fix(lint): refuse a present non-array stack.packages at all five recordsOf(stack.packages) readers Ruling A on #15293 (comment 5634034754): a `packages` that is present but not an array (`{}`, `0`, `'x'`, a keyed object) is malformed, not absent, and every reader refuses it. Four `packages/lint` readers fell through `recordsOf(stack.packages)` to `[]` instead of refusing: `validate-object-references.ts`'s `artifactProvidedObjectNames`, and `validate-translation-references.ts`'s `contributedNavItemsByApp`, `objectExtensionsByTarget` and `artifactProvidedRecords`. A fifth copy of the same pattern (`validate-mapping-target-fields.ts`'s `extensionFieldsByTarget`) landed via #20208 after this ruling's site census and is fixed the same way. All five now share one small reader, `packagesOf(stack)` (`object-graph.ts`): absent stays `[]`, an array is read exactly as `recordsOf` read it (junk entries dropped, unchanged), and anything else present throws `INVALID_ARTIFACT_PACKAGES` (ADR-0112, status 422) -- the same registered code `@objectstack/core`'s `resolveArtifactPackageOrder` already raises for the identical defect. `recordsOf` itself is untouched: it stays the shared map-or-array reader `objects`/`sections`/`tabs` need, where a keyed map is legitimate -- `packages` never has a map form, so this is a second, narrower reader rather than a branch on the first one. Pins: `packagesOf` is pinned exhaustively (array control, absent/null silence, refusal on `{}`/`0`/`'x'`/a keyed object, with code + status). Each of the three public functions these readers sit behind (`validateObjectReferences`, `validateTranslationReferences`, `validateMappingTargetFields`) gets one throw-pin proving the wiring reaches the shared reader. One existing pin asserted the old fall-through semantics (`validate-object-references.test.ts`, "ignores a `packages` value that is not a list of entries") and is flipped: `null` stays a silence control, `42`/`'core'` now assert the refusal. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../20206-lint-packages-non-array-refused.md | 13 +++++ packages/lint/src/object-graph.test.ts | 41 ++++++++++++++ packages/lint/src/object-graph.ts | 53 +++++++++++++++++++ .../validate-mapping-target-fields.test.ts | 15 ++++++ .../src/validate-mapping-target-fields.ts | 4 +- .../src/validate-object-references.test.ts | 19 +++++-- .../lint/src/validate-object-references.ts | 4 +- .../validate-translation-references.test.ts | 28 ++++++++++ .../src/validate-translation-references.ts | 8 +-- 9 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 .changeset/20206-lint-packages-non-array-refused.md diff --git a/.changeset/20206-lint-packages-non-array-refused.md b/.changeset/20206-lint-packages-non-array-refused.md new file mode 100644 index 00000000000..156703b2ae2 --- /dev/null +++ b/.changeset/20206-lint-packages-non-array-refused.md @@ -0,0 +1,13 @@ +--- +"@objectstack/lint": minor +--- + +`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). + +Clause-②: no (narrowing) + + + +- **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. +- **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. +- **Fix**: write `packages` as an array of `{ manifest: … }` entries, or omit the key entirely for a single-package stack. diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index 894bde68bb7..ae8319d3dad 100644 --- a/packages/lint/src/object-graph.test.ts +++ b/packages/lint/src/object-graph.test.ts @@ -14,6 +14,7 @@ import { isUnjudgeable, nearestName, listNames, + packagesOf, RELATIONSHIP_FIELD_TYPES, } from './object-graph.js'; import { walkFilterFieldKeys, type FilterFieldKey } from './filter-walk.js'; @@ -292,6 +293,46 @@ describe('object-graph — a non-record entry in `stack.objects` (#15494)', () = }); }); +describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)', () => { + // `packages` is declared `z.array(ArtifactPackageSchema).optional()` — array + // or absent, never map-or-array like `objects`/`sections`/`tabs`. A PRESENT + // non-array `packages` ({}, 0, 'x', a keyed object) is malformed, not + // absent, and every reader refuses it. This is the ONE reader the four + // `packages/lint` call sites now share, replacing four private copies of + // `recordsOf(stack.packages)`. + + it('CONTROL — an array is read exactly as `recordsOf` read it: iterated, junk dropped', () => { + const valid = { manifest: { id: 'com.example.a' } }; + expect(packagesOf({ packages: [valid] })).toEqual([valid]); + expect(packagesOf({ packages: [null, valid, undefined, 'junk', 42, []] })).toEqual([valid]); + }); + + it('an absent `packages` stays silent — `[]`, not a refusal', () => { + expect(packagesOf({})).toEqual([]); + expect(packagesOf({ packages: undefined })).toEqual([]); + }); + + it('`packages: null` also stays silent here — #19926 owns that disagreement, not this reader', () => { + expect(packagesOf({ packages: null })).toEqual([]); + }); + + it.each([ + ['an empty object', {}], + ['a keyed object (the shape `recordsOf` would have read as a map)', { a: { manifest: {} } }], + ['a number', 0], + ['a string', 'x'], + ])('refuses a PRESENT non-array `packages` — %s', (_label, shape) => { + expect(() => packagesOf({ packages: shape })).toThrow( + expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }), + ); + // The message names the actual runtime type, so an author sees what they + // wrote rather than a generic "malformed" sentence. + expect(() => packagesOf({ packages: shape })).toThrow( + new RegExp(`\`packages\` of type ${typeof shape}`), + ); + }); +}); + /** * [#19289] A `{ type: 'user' }` field with no `reference` is TRAVERSABLE — the * third defect found by the implicit-target census, and the widest-reaching of diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index bac2db6f0d9..690adf905a2 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -220,6 +220,59 @@ export function recordsOf(v: unknown): AnyRec[] { return []; } +/** The shape a thrown {@link packagesOf} refusal carries (ADR-0112 envelope). */ +export type StackPackagesError = Error & { code: string; status: number }; + +/** + * Every entry of `stack.packages` — the release artifact's package list + * (ADR-0130 D4) — read the way {@link resolveArtifactPackageOrder} + * (`@objectstack/core`) reads it, ⛔ NOT the way {@link recordsOf} reads + * `objects` / `sections` / `tabs`. + * + * `packages` is declared `z.array(ArtifactPackageSchema).optional()` + * (`ObjectStackDefinitionSchema`, `@objectstack/spec`) — array-or-absent, + * never map-or-array. Ruling A on #15293 (`5634034754`): a `packages` that is + * PRESENT but not an array (`{}`, `0`, `'x'`, or a keyed object) is + * **malformed, not absent**, and every reader refuses it — `recordsOf` cannot + * be that reader, because for its OTHER callers a plain object legitimately + * IS the map form (see the note above this function). `packages` has no map + * form at all, so this is a second, narrower reader rather than a branch on + * the first one. + * + * - **Absent** (`undefined` / `null`) → `[]`. A single-package artifact + * contributes nothing here — this answers "what does `packages[]` add", + * never "what does this stack provide". (`null` is left exactly this way + * on purpose — #19926 owns that disagreement, not this function.) + * - **An array** → iterated, non-record members dropped — unchanged from + * what every one of these four call sites did through `recordsOf` before + * this function existed. + * - **Anything else present** → refused, once, here — replacing four copies + * of the same read across `validate-object-references.ts` and + * `validate-translation-references.ts` (#20206). + * + * ⛔ Do not fold this into `recordsOf` itself (#20206's card): that reader + * stays the shared map-or-array reader its other callers need. + * + * @throws A {@link StackPackagesError} — `code: 'INVALID_ARTIFACT_PACKAGES'`, + * `status: 422`, the SAME registered code `resolveArtifactPackageOrder` + * raises for the identical defect on the assembled artifact — never a new + * one. + */ +export function packagesOf(stack: unknown): AnyRec[] { + const declared = (stack as { packages?: unknown } | null | undefined)?.packages; + if (declared === undefined || declared === null) return []; + if (Array.isArray(declared)) return declared.filter(isRec); + const err = new Error( + 'A stack\'s `packages` must be an array of package entries (ADR-0130 D4, ' + + '`ArtifactPackageSchema`), but this stack carries `packages` of type ' + + `${typeof declared}. Omit the key entirely for a single-package stack — ` + + '`manifest` is retained, not replaced.', + ) as StackPackagesError; + err.code = 'INVALID_ARTIFACT_PACKAGES'; + err.status = 422; + throw err; +} + function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } diff --git a/packages/lint/src/validate-mapping-target-fields.test.ts b/packages/lint/src/validate-mapping-target-fields.test.ts index b543b0b67ab..e75884accd2 100644 --- a/packages/lint/src/validate-mapping-target-fields.test.ts +++ b/packages/lint/src/validate-mapping-target-fields.test.ts @@ -103,6 +103,21 @@ describe('validateMappingTargetFields', () => { expect(validateMappingTargetFields(stack)).toEqual([]); }); + // [#20206, ruling A on #15293 `5634034754`] This reader was added by #20208 + // after the ruling's own site census (`origin/main` `1c8b320`) — a fifth + // copy of the same `recordsOf(stack.packages)` fall-through the ruling + // closes elsewhere in this package. A PRESENT non-array `packages` is + // malformed, not absent; only `undefined`/`null` stay silent. + it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => { + for (const packages of [{}, 0, 'x']) { + expect(() => validateMappingTargetFields({ + objects: [contact], + packages, + mappings: [mapping([{ source: 'Tier', target: 'sla_tier' }])], + })).toThrow(expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 })); + } + }); + it('stays silent on a mapping whose object this stack does not define (skip 1)', () => { expect(validateMappingTargetFields({ objects: [contact], diff --git a/packages/lint/src/validate-mapping-target-fields.ts b/packages/lint/src/validate-mapping-target-fields.ts index 8699110c675..77096e0084f 100644 --- a/packages/lint/src/validate-mapping-target-fields.ts +++ b/packages/lint/src/validate-mapping-target-fields.ts @@ -48,7 +48,7 @@ import { indexImportMappingTargets, unknownImportMappingTargets } from '@objectstack/spec/data'; -import { listNames, recordsOf, suggestName } from './object-graph.js'; +import { listNames, packagesOf, recordsOf, suggestName } from './object-graph.js'; /** A `fieldMapping[].target` that names no field of the mapping's object. */ export const MAPPING_TARGET_FIELD_UNKNOWN = 'mapping-target-field-unknown'; @@ -92,7 +92,7 @@ function extensionFieldsByTarget(stack: AnyRec): Map { } }; add(stack.objectExtensions); - for (const entry of recordsOf(stack.packages)) { + for (const entry of packagesOf(stack)) { if (isRec(entry.manifest)) add(entry.manifest.objectExtensions); } return byTarget; diff --git a/packages/lint/src/validate-object-references.test.ts b/packages/lint/src/validate-object-references.test.ts index 831c430ea0d..c353dff1e62 100644 --- a/packages/lint/src/validate-object-references.test.ts +++ b/packages/lint/src/validate-object-references.test.ts @@ -297,10 +297,21 @@ describe('validateObjectReferences — artifact packages[] as resolution context expect(findings[0].path).toBe('objects[0].fields.account.reference'); }); - it('ignores a `packages` value that is not a list of entries', () => { - for (const packages of [null, 42, 'core']) { - const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, packages)); - expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.reference']); + it('`packages: null` stays absent, unlike a present non-array value (#19926 owns `null`, not this rule)', () => { + const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, null)); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.reference']); + }); + + // [#20206, ruling A on #15293 `5634034754`] Was "ignores a `packages` value + // that is not a list of entries" — `42` and `'core'` used to fall through + // `recordsOf` to `[]` and be silently treated as "no packages", exactly the + // fall-through the ruling closes: PRESENT but not an array is malformed, not + // absent, and every reader refuses it. + it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => { + for (const packages of [42, 'core']) { + expect(() => validateObjectReferences(perPackageStack(ORDERS_BODY, packages))).toThrow( + expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }), + ); } }); }); diff --git a/packages/lint/src/validate-object-references.ts b/packages/lint/src/validate-object-references.ts index befd1c7517b..013e4df7c3f 100644 --- a/packages/lint/src/validate-object-references.ts +++ b/packages/lint/src/validate-object-references.ts @@ -81,7 +81,7 @@ import { } from '@objectstack/spec/system'; import { referenceCarrierOf, referenceTargetOf } from '@objectstack/spec/data'; -import { recordsOf, suggestName } from './object-graph.js'; +import { packagesOf, recordsOf, suggestName } from './object-graph.js'; /** Materialized once for the repeated edit-distance scans in `suggestName`. */ const PLATFORM_NAMES: readonly string[] = [...PLATFORM_PROVIDED_OBJECT_NAMES]; @@ -162,7 +162,7 @@ function isInterpolated(target: string): boolean { */ function artifactProvidedObjectNames(stack: AnyRec): string[] { const names: string[] = []; - for (const entry of recordsOf(stack.packages)) { + for (const entry of packagesOf(stack)) { const body = entry.manifest; if (!body || typeof body !== 'object' || Array.isArray(body)) continue; for (const obj of recordsOf((body as AnyRec).objects)) { diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index b03e448e85a..1a7e313370e 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -528,6 +528,34 @@ describe('validateTranslationReferences — cross-package objects (§4 ladder)', }); }); +describe('validateTranslationReferences — a PRESENT non-array `packages` (#20206, ruling A on #15293 `5634034754`)', () => { + // `packages` is declared array-or-absent (ADR-0130 D4), never map-or-array. + // This rule reads it through the three internal carriers `object-graph.ts`'s + // `packagesOf` now serves (contributed nav, object extensions, and every + // artifact-provided collection) — all three now refuse instead of silently + // reading the malformed value as "no packages". `artifactProvidedRecords` + // ('objects') runs first inside `buildUniverse`, so that is the carrier this + // pin observes throwing; the read itself is pinned exhaustively, once, in + // `object-graph.test.ts` (the shared function all three now call). + // A bundle is required — `validateTranslationReferences` returns before ever + // calling `buildUniverse` (and therefore before reading `packages` at all) + // when `stack.translations` is empty, exactly like the exemptions below. + const oneBundle = [{ 'zh-CN': { objects: {} } }]; + + it('refuses instead of silently treating it as absent', () => { + for (const packages of [{}, 0, 'x', { a: { manifest: {} } }]) { + expect(() => validateTranslationReferences({ objects: [], translations: oneBundle, packages })).toThrow( + expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }), + ); + } + }); + + it('CONTROL — an absent or `null` `packages` stays silent', () => { + expect(validateTranslationReferences({ objects: [], translations: oneBundle })).toEqual([]); + expect(validateTranslationReferences({ objects: [], translations: oneBundle, packages: null })).toEqual([]); + }); +}); + describe('validateTranslationReferences — apps, dashboards, global actions', () => { const stack = { objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index d6b2706ecbd..19a604da51a 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -167,7 +167,7 @@ import { expandViewContainer } from '@objectstack/spec'; import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from '@objectstack/spec/system'; import { walkFlowNodes } from './flow-walk.js'; -import { recordsOf, suggestName } from './object-graph.js'; +import { packagesOf, recordsOf, suggestName } from './object-graph.js'; import { walkPageComponents } from './page-walk.js'; import { SYSTEM_FIELDS } from './system-fields.js'; import { viewObjectName } from './view-walk.js'; @@ -750,7 +750,7 @@ function contributedNavItemsByApp(stack: AnyRec): Map { } }; add(isRec(stack.manifest) ? stack.manifest.navigationContributions : undefined); - for (const entry of recordsOf(stack.packages)) { + for (const entry of packagesOf(stack)) { const body = entry.manifest; if (!isRec(body)) continue; add(body.navigationContributions); @@ -845,7 +845,7 @@ function objectExtensionsByTarget(stack: AnyRec): Map { } }; add(stack.objectExtensions); - for (const entry of recordsOf(stack.packages)) { + for (const entry of packagesOf(stack)) { const body = entry.manifest; if (!isRec(body)) continue; add(body.objectExtensions); @@ -918,7 +918,7 @@ function objectExtensionsByTarget(stack: AnyRec): Map { */ function artifactProvidedRecords(stack: AnyRec, collection: string): AnyRec[] { const provided: AnyRec[] = []; - for (const entry of recordsOf(stack.packages)) { + for (const entry of packagesOf(stack)) { const body = entry.manifest; if (!isRec(body)) continue; provided.push(...recordsOf(body[collection])); From 4d221c01779318a17e7a287e44cbdc412107e3d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:02:55 +0000 Subject: [PATCH 2/6] fix(lint,spec): packagesOf refuses packages: null too, and lists its stamp under @objectstack/lint's ledger key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 3f9e2eaa1c, "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 3f9e2eaa1c precedent's own spec-only changeset. The PR-level declaration becomes `Clause-②: yes (narrowing)`, carrying both facts. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../20206-lint-error-code-provenance-row.md | 9 +++++++ .../20206-lint-packages-non-array-refused.md | 6 ++--- packages/lint/src/object-graph.test.ts | 19 ++++++++++---- packages/lint/src/object-graph.ts | 25 ++++++++++++------- .../validate-mapping-target-fields.test.ts | 6 +++-- .../src/validate-object-references.test.ts | 16 ++++++------ .../validate-translation-references.test.ts | 8 +++--- .../spec/src/api/error-code-ledger.zod.ts | 14 +++++++++++ 8 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 .changeset/20206-lint-error-code-provenance-row.md diff --git a/.changeset/20206-lint-error-code-provenance-row.md b/.changeset/20206-lint-error-code-provenance-row.md new file mode 100644 index 00000000000..4fbaaee3f79 --- /dev/null +++ b/.changeset/20206-lint-error-code-provenance-row.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": minor +--- + +`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. + +Clause-②: yes + +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. diff --git a/.changeset/20206-lint-packages-non-array-refused.md b/.changeset/20206-lint-packages-non-array-refused.md index 156703b2ae2..ae7b0d6ac61 100644 --- a/.changeset/20206-lint-packages-non-array-refused.md +++ b/.changeset/20206-lint-packages-non-array-refused.md @@ -2,12 +2,12 @@ "@objectstack/lint": minor --- -`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). +`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). Clause-②: no (narrowing) -- **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. -- **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. +- **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. +- **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. - **Fix**: write `packages` as an array of `{ manifest: … }` entries, or omit the key entirely for a single-package stack. diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index ae8319d3dad..13e776eed57 100644 --- a/packages/lint/src/object-graph.test.ts +++ b/packages/lint/src/object-graph.test.ts @@ -307,15 +307,11 @@ describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)' expect(packagesOf({ packages: [null, valid, undefined, 'junk', 42, []] })).toEqual([valid]); }); - it('an absent `packages` stays silent — `[]`, not a refusal', () => { + it('CONTROL — only an absent (`undefined`) `packages` stays silent — `[]`, not a refusal', () => { expect(packagesOf({})).toEqual([]); expect(packagesOf({ packages: undefined })).toEqual([]); }); - it('`packages: null` also stays silent here — #19926 owns that disagreement, not this reader', () => { - expect(packagesOf({ packages: null })).toEqual([]); - }); - it.each([ ['an empty object', {}], ['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`)' new RegExp(`\`packages\` of type ${typeof shape}`), ); }); + + // [ruling A on #19926, `5805260775`] `null` is malformed, everywhere — it is + // PRESENT, not absent, so it takes the same refusal as `{}`/`0`/`'x'`, not + // the silent branch above. `typeof null` is `'object'`, which would name a + // `{}` the author never wrote, so the message names `null` as itself + // (matching `resolveArtifactPackageOrder` in `@objectstack/core`, PR #20228). + it('refuses `packages: null` too — malformed, not absent (ruling `5805260775` on #19926)', () => { + expect(() => packagesOf({ packages: null })).toThrow( + expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }), + ); + expect(() => packagesOf({ packages: null })).toThrow(/`packages` of type null/); + expect(() => packagesOf({ packages: null })).not.toThrow(/of type object/); + }); }); /** diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index 690adf905a2..96757eb0d1e 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -239,16 +239,17 @@ export type StackPackagesError = Error & { code: string; status: number }; * form at all, so this is a second, narrower reader rather than a branch on * the first one. * - * - **Absent** (`undefined` / `null`) → `[]`. A single-package artifact + * - **Absent** (`undefined` ONLY) → `[]`. A single-package artifact * contributes nothing here — this answers "what does `packages[]` add", - * never "what does this stack provide". (`null` is left exactly this way - * on purpose — #19926 owns that disagreement, not this function.) + * never "what does this stack provide". `null` is malformed, per ruling + * `5805260775` on #19926. * - **An array** → iterated, non-record members dropped — unchanged from * what every one of these four call sites did through `recordsOf` before * this function existed. - * - **Anything else present** → refused, once, here — replacing four copies - * of the same read across `validate-object-references.ts` and - * `validate-translation-references.ts` (#20206). + * - **Anything else present, `null` included** → refused, once, here — + * replacing four copies of the same read across + * `validate-object-references.ts` and `validate-translation-references.ts` + * (#20206). * * ⛔ Do not fold this into `recordsOf` itself (#20206's card): that reader * stays the shared map-or-array reader its other callers need. @@ -260,13 +261,19 @@ export type StackPackagesError = Error & { code: string; status: number }; */ export function packagesOf(stack: unknown): AnyRec[] { const declared = (stack as { packages?: unknown } | null | undefined)?.packages; - if (declared === undefined || declared === null) return []; + // ⛔ `undefined` ONLY. `null` is present, not absent — ruling `5805260775` + // on #19926 — so it falls to the refusal below with every other non-array + // value. + if (declared === undefined) return []; if (Array.isArray(declared)) return declared.filter(isRec); const err = new Error( 'A stack\'s `packages` must be an array of package entries (ADR-0130 D4, ' + '`ArtifactPackageSchema`), but this stack carries `packages` of type ' - + `${typeof declared}. Omit the key entirely for a single-package stack — ` - + '`manifest` is retained, not replaced.', + // `typeof null` is `'object'`, which would name a `{}` the author never + // wrote; `null` is named as itself (matching `resolveArtifactPackageOrder` + // in `@objectstack/core`). + + `${declared === null ? 'null' : typeof declared}. Omit the key entirely for a ` + + 'single-package stack — `manifest` is retained, not replaced.', ) as StackPackagesError; err.code = 'INVALID_ARTIFACT_PACKAGES'; err.status = 422; diff --git a/packages/lint/src/validate-mapping-target-fields.test.ts b/packages/lint/src/validate-mapping-target-fields.test.ts index e75884accd2..21f146182ee 100644 --- a/packages/lint/src/validate-mapping-target-fields.test.ts +++ b/packages/lint/src/validate-mapping-target-fields.test.ts @@ -107,9 +107,11 @@ describe('validateMappingTargetFields', () => { // after the ruling's own site census (`origin/main` `1c8b320`) — a fifth // copy of the same `recordsOf(stack.packages)` fall-through the ruling // closes elsewhere in this package. A PRESENT non-array `packages` is - // malformed, not absent; only `undefined`/`null` stay silent. + // malformed, not absent; only `undefined` stays silent. `null` joins this + // set in rework round 1 (ruling A on #19926, `5805260775`): it is present, + // not absent. it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => { - for (const packages of [{}, 0, 'x']) { + for (const packages of [{}, 0, 'x', null]) { expect(() => validateMappingTargetFields({ objects: [contact], packages, diff --git a/packages/lint/src/validate-object-references.test.ts b/packages/lint/src/validate-object-references.test.ts index c353dff1e62..f70ab1b17d6 100644 --- a/packages/lint/src/validate-object-references.test.ts +++ b/packages/lint/src/validate-object-references.test.ts @@ -297,18 +297,20 @@ describe('validateObjectReferences — artifact packages[] as resolution context expect(findings[0].path).toBe('objects[0].fields.account.reference'); }); - it('`packages: null` stays absent, unlike a present non-array value (#19926 owns `null`, not this rule)', () => { - const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, null)); + it('CONTROL — `packages: undefined` (absent) stays silent — the only value this reader treats as absent', () => { + const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, undefined)); expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.reference']); }); // [#20206, ruling A on #15293 `5634034754`] Was "ignores a `packages` value - // that is not a list of entries" — `42` and `'core'` used to fall through - // `recordsOf` to `[]` and be silently treated as "no packages", exactly the - // fall-through the ruling closes: PRESENT but not an array is malformed, not - // absent, and every reader refuses it. + // that is not a list of entries" — `null`, `42` and `'core'` used to fall + // through `recordsOf` to `[]` and be silently treated as "no packages", + // exactly the fall-through the ruling closes: PRESENT but not an array is + // malformed, not absent, and every reader refuses it. `null` joins this set + // in rework round 1 (ruling A on #19926, `5805260775`): it is present, not + // absent, so it is no longer a control. it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => { - for (const packages of [42, 'core']) { + for (const packages of [null, 42, 'core']) { expect(() => validateObjectReferences(perPackageStack(ORDERS_BODY, packages))).toThrow( expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }), ); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 1a7e313370e..2cdb41262dd 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -543,16 +543,18 @@ describe('validateTranslationReferences — a PRESENT non-array `packages` (#202 const oneBundle = [{ 'zh-CN': { objects: {} } }]; it('refuses instead of silently treating it as absent', () => { - for (const packages of [{}, 0, 'x', { a: { manifest: {} } }]) { + for (const packages of [{}, 0, 'x', { a: { manifest: {} } }, null]) { expect(() => validateTranslationReferences({ objects: [], translations: oneBundle, packages })).toThrow( expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }), ); } }); - it('CONTROL — an absent or `null` `packages` stays silent', () => { + // [ruling A on #19926, `5805260775`] `null` moved from the control above + // into the refusal set in rework round 1: it is present, not absent. + it('CONTROL — only an absent (`undefined`) `packages` stays silent', () => { expect(validateTranslationReferences({ objects: [], translations: oneBundle })).toEqual([]); - expect(validateTranslationReferences({ objects: [], translations: oneBundle, packages: null })).toEqual([]); + expect(validateTranslationReferences({ objects: [], translations: oneBundle, packages: undefined })).toEqual([]); }); }); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index d7ce01ff2ec..5c062311cc5 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1356,6 +1356,20 @@ export const ERROR_CODE_LEDGER = { 'STACK_COMPOSE_KEY_CONFLICT', // a single-valued top-level key is declared with different values by two stacks 'STACK_COMPOSE_OBJECT_CONFLICT', // the same object name is defined by more than one stack under the default `objectConflict: 'error'` ], + '@objectstack/lint': [ + // [#20206] `packagesOf` (`object-graph.ts`) refuses a present non-array + // `stack.packages` ({}, 0, 'x', a keyed object, `null`) with the SAME + // registered code `@objectstack/core`'s `resolveArtifactPackageOrder` + // raises for the identical defect on an assembled artifact (ruling A on + // #15293 `5634034754`; the `null` leg is ruling A on #19926 `5805260775`) + // — deliberately reused, never minted, so an author sees one code + // regardless of which reader catches the malformed shape first. `door: + // 'none'`, the #16449 reading: `packages/lint`'s rules are pure + // `(stack) => Finding[]` functions called from `os validate` / `os lint` + // / `os build` (`packages/cli`), never through an HTTP boundary — the + // same posture `@objectstack/spec`'s own `STACK_*` rows above record. + 'INVALID_ARTIFACT_PACKAGES', + ], } as const satisfies Record; /** A code registered by at least one package (deduped union of the ledger). */ From 68398a0e7e7e75474dc75003449e2f047daa7064 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:08:15 +0000 Subject: [PATCH 3/6] fix(lint,spec): close the round-2 pin gap and correct false-at-head wording REWORK round 2 for #20206 (at-tier record 5856823202, items 1-2): 1. `validate-object-references.test.ts`'s non-array refusal test pinned only [null, 42, 'core']; add `{}` and a keyed object so all three validators pin the identical set `packagesOf` itself does. 2. Text corrected to match the head, wording only: (a) "four call sites" / "four copies" -> "five ... three files" in the `packagesOf` docblock and the `object-graph.test.ts` comment, now that `validate-mapping-target-fields.ts` is a fifth site. (b) every sentence claiming lint reads `null` "the way `resolveArtifactPackageOrder` does" or raises "the SAME code ... for the identical defect" is qualified for `null` specifically: on `main`, core still reads `null` as absent and names it via `typeof`; that only changes once #19926 (PR #20228) lands. Fixed in the docblock, the inline naming comment, the lint changeset and the ledger-row comment in `error-code-ledger.zod.ts`. (c) the lint changeset's door-reachability claim corrected: only `os lint` reaches `packagesOf`'s refusal (exit 1, message on stdout via `printError`, `code` under `--json`); `os validate` and `os build` already refuse a malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, before these rules run (re-read `validate.ts:293`, `compile.ts:356`, `lint.ts:673/1140`, `format.ts:369` directly to confirm). No `packagesOf` semantics changed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../20206-lint-packages-non-array-refused.md | 4 +-- packages/lint/src/object-graph.test.ts | 4 +-- packages/lint/src/object-graph.ts | 31 +++++++++++-------- .../src/validate-object-references.test.ts | 6 ++-- .../spec/src/api/error-code-ledger.zod.ts | 25 +++++++++------ 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/.changeset/20206-lint-packages-non-array-refused.md b/.changeset/20206-lint-packages-non-array-refused.md index ae7b0d6ac61..f757462eafc 100644 --- a/.changeset/20206-lint-packages-non-array-refused.md +++ b/.changeset/20206-lint-packages-non-array-refused.md @@ -2,12 +2,12 @@ "@objectstack/lint": minor --- -`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). +`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 — 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). For every shape other than `null`, this is the same way `@objectstack/core`'s `resolveArtifactPackageOrder` already refuses it, with the same registered code; for `null`, `resolveArtifactPackageOrder` still reads it as absent on `main`, and the two readers align only once #19926 (PR #20228) lands. Clause-②: no (narrowing) -- **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. +- **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. Only `os lint` reaches this refusal — exit 1, the message on stdout (`printError`), `code` under `--json`; `os validate` and `os build` already refuse a malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, before these rules ever run. - **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. - **Fix**: write `packages` as an array of `{ manifest: … }` entries, or omit the key entirely for a single-package stack. diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index 13e776eed57..fb8a04a20da 100644 --- a/packages/lint/src/object-graph.test.ts +++ b/packages/lint/src/object-graph.test.ts @@ -297,8 +297,8 @@ describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)' // `packages` is declared `z.array(ArtifactPackageSchema).optional()` — array // or absent, never map-or-array like `objects`/`sections`/`tabs`. A PRESENT // non-array `packages` ({}, 0, 'x', a keyed object) is malformed, not - // absent, and every reader refuses it. This is the ONE reader the four - // `packages/lint` call sites now share, replacing four private copies of + // absent, and every reader refuses it. This is the ONE reader the five + // `packages/lint` call sites now share, replacing five private copies of // `recordsOf(stack.packages)`. it('CONTROL — an array is read exactly as `recordsOf` read it: iterated, junk dropped', () => { diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index 96757eb0d1e..3452a2830d0 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -225,9 +225,8 @@ export type StackPackagesError = Error & { code: string; status: number }; /** * Every entry of `stack.packages` — the release artifact's package list - * (ADR-0130 D4) — read the way {@link resolveArtifactPackageOrder} - * (`@objectstack/core`) reads it, ⛔ NOT the way {@link recordsOf} reads - * `objects` / `sections` / `tabs`. + * (ADR-0130 D4) — read on `packages/lint`'s own terms, ⛔ NOT the way + * {@link recordsOf} reads `objects` / `sections` / `tabs`. * * `packages` is declared `z.array(ArtifactPackageSchema).optional()` * (`ObjectStackDefinitionSchema`, `@objectstack/spec`) — array-or-absent, @@ -242,22 +241,27 @@ export type StackPackagesError = Error & { code: string; status: number }; * - **Absent** (`undefined` ONLY) → `[]`. A single-package artifact * contributes nothing here — this answers "what does `packages[]` add", * never "what does this stack provide". `null` is malformed, per ruling - * `5805260775` on #19926. + * `5805260775` on #19926 — for `null` specifically, that disagrees with + * {@link resolveArtifactPackageOrder} (`@objectstack/core`), which on + * `main` still reads `null` as absent; the two readers align once #19926 + * (PR #20228) lands. * - **An array** → iterated, non-record members dropped — unchanged from - * what every one of these four call sites did through `recordsOf` before + * what every one of these five call sites did through `recordsOf` before * this function existed. * - **Anything else present, `null` included** → refused, once, here — - * replacing four copies of the same read across - * `validate-object-references.ts` and `validate-translation-references.ts` - * (#20206). + * replacing five copies of the same read across + * `validate-object-references.ts`, `validate-translation-references.ts` + * and `validate-mapping-target-fields.ts` (#20206). * * ⛔ Do not fold this into `recordsOf` itself (#20206's card): that reader * stays the shared map-or-array reader its other callers need. * * @throws A {@link StackPackagesError} — `code: 'INVALID_ARTIFACT_PACKAGES'`, - * `status: 422`, the SAME registered code `resolveArtifactPackageOrder` - * raises for the identical defect on the assembled artifact — never a new - * one. + * `status: 422` — for a present non-array `packages` OTHER than `null`, + * the SAME registered code {@link resolveArtifactPackageOrder} already + * raises for the identical defect on the assembled artifact; for `null`, + * only once #19926 (PR #20228) lands does core raise it too. Never a new + * code, either way. */ export function packagesOf(stack: unknown): AnyRec[] { const declared = (stack as { packages?: unknown } | null | undefined)?.packages; @@ -270,8 +274,9 @@ export function packagesOf(stack: unknown): AnyRec[] { 'A stack\'s `packages` must be an array of package entries (ADR-0130 D4, ' + '`ArtifactPackageSchema`), but this stack carries `packages` of type ' // `typeof null` is `'object'`, which would name a `{}` the author never - // wrote; `null` is named as itself (matching `resolveArtifactPackageOrder` - // in `@objectstack/core`). + // wrote; `null` is named as itself here — the naming #19926 (PR #20228) + // gives `resolveArtifactPackageOrder` once it lands, adopted early by + // this reader. + `${declared === null ? 'null' : typeof declared}. Omit the key entirely for a ` + 'single-package stack — `manifest` is retained, not replaced.', ) as StackPackagesError; diff --git a/packages/lint/src/validate-object-references.test.ts b/packages/lint/src/validate-object-references.test.ts index f70ab1b17d6..da03491ab8f 100644 --- a/packages/lint/src/validate-object-references.test.ts +++ b/packages/lint/src/validate-object-references.test.ts @@ -308,9 +308,11 @@ describe('validateObjectReferences — artifact packages[] as resolution context // exactly the fall-through the ruling closes: PRESENT but not an array is // malformed, not absent, and every reader refuses it. `null` joins this set // in rework round 1 (ruling A on #19926, `5805260775`): it is present, not - // absent, so it is no longer a control. + // absent, so it is no longer a control. `{}` and a keyed object join in + // rework round 2, so this validator pins the same set `packagesOf` and the + // other two validators do. it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => { - for (const packages of [null, 42, 'core']) { + for (const packages of [null, 42, 'core', {}, { a: { manifest: {} } }]) { expect(() => validateObjectReferences(perPackageStack(ORDERS_BODY, packages))).toThrow( expect.objectContaining({ code: 'INVALID_ARTIFACT_PACKAGES', status: 422 }), ); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 65e1069841a..b7721e00164 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1359,16 +1359,21 @@ export const ERROR_CODE_LEDGER = { ], '@objectstack/lint': [ // [#20206] `packagesOf` (`object-graph.ts`) refuses a present non-array - // `stack.packages` ({}, 0, 'x', a keyed object, `null`) with the SAME - // registered code `@objectstack/core`'s `resolveArtifactPackageOrder` - // raises for the identical defect on an assembled artifact (ruling A on - // #15293 `5634034754`; the `null` leg is ruling A on #19926 `5805260775`) - // — deliberately reused, never minted, so an author sees one code - // regardless of which reader catches the malformed shape first. `door: - // 'none'`, the #16449 reading: `packages/lint`'s rules are pure - // `(stack) => Finding[]` functions called from `os validate` / `os lint` - // / `os build` (`packages/cli`), never through an HTTP boundary — the - // same posture `@objectstack/spec`'s own `STACK_*` rows above record. + // `stack.packages` ({}, 0, 'x', a keyed object, `null`) — for every shape + // OTHER than `null`, with the SAME registered code `@objectstack/core`'s + // `resolveArtifactPackageOrder` already raises for the identical defect + // on an assembled artifact (ruling A on #15293 `5634034754`). For `null` + // (ruling A on #19926 `5805260775`), core still reads it as absent on + // `main`; core raises this code for `null` too only once #19926 (PR + // #20228) lands — deliberately reused either way, never minted, so an + // author sees one code regardless of which reader catches the malformed + // shape first. `door: 'none'`, the #16449 reading: `packages/lint`'s + // rules are pure `(stack) => Finding[]` functions, reachable only from + // `os lint` (`packages/cli`) — `os validate` / `os build` refuse a + // malformed `packages` earlier, at `ObjectStackDefinitionSchema.safeParse`, + // before these rules ever run — never through an HTTP boundary either + // way, the same posture `@objectstack/spec`'s own `STACK_*` rows above + // record. 'INVALID_ARTIFACT_PACKAGES', ], } as const satisfies Record; From 42b3bfbf2ee9beb4c806b051c2e9597009695e01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:09:36 +0000 Subject: [PATCH 4/6] test(lint): close the round-3 pin gap at the mapping validator REWORK round 3 for #20206 (in-seat ruling 5857515836, at-tier record 5857513507, item 1 only): 1. `validate-mapping-target-fields.test.ts`'s non-array refusal loop pinned only [{}, 0, 'x', null] -- no keyed object, and no explicit `packages: undefined` control (only the array control at :97). Add the keyed object `{ a: { manifest: {} } }` to the loop, and a dedicated `packages: undefined` control test beside it, matching what the other two validators already pin. 2. Reword the one place that over-claimed the result before this fix landed: `validate-object-references.test.ts`'s comment said "pins the same set `packagesOf` and the other two validators do" -- now "pins the same shape classes ...", since the concrete number/string representatives differ across validators (42/'core' vs 0/'x') even though the shape classes now match everywhere. The PR body carries the matching correction. Nothing else moves: packagesOf semantics, the five readers, the ledger row, the door sentences and the null-parity conditioning are all unchanged since 68398a0e7e. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../validate-mapping-target-fields.test.ts | 19 +++++++++++++++++-- .../src/validate-object-references.test.ts | 4 ++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/validate-mapping-target-fields.test.ts b/packages/lint/src/validate-mapping-target-fields.test.ts index 21f146182ee..8413a46e294 100644 --- a/packages/lint/src/validate-mapping-target-fields.test.ts +++ b/packages/lint/src/validate-mapping-target-fields.test.ts @@ -103,15 +103,30 @@ describe('validateMappingTargetFields', () => { expect(validateMappingTargetFields(stack)).toEqual([]); }); + it('CONTROL — `packages: undefined` (absent) stays silent — the only value this reader treats as absent', () => { + // `full_name`, not `sla_tier`: `sla_tier` only resolves via the + // `objectExtensions` a package supplies (the test above) — with + // `packages` genuinely absent this control needs a field `contact` + // declares on its own, so a real finding can't masquerade as the + // reader silently accepting the shape. + expect(validateMappingTargetFields({ + objects: [contact], + packages: undefined, + mappings: [mapping([{ source: 'Name', target: 'full_name' }])], + })).toEqual([]); + }); + // [#20206, ruling A on #15293 `5634034754`] This reader was added by #20208 // after the ruling's own site census (`origin/main` `1c8b320`) — a fifth // copy of the same `recordsOf(stack.packages)` fall-through the ruling // closes elsewhere in this package. A PRESENT non-array `packages` is // malformed, not absent; only `undefined` stays silent. `null` joins this // set in rework round 1 (ruling A on #19926, `5805260775`): it is present, - // not absent. + // not absent. A keyed object (the shape `recordsOf` read as a map) joins in + // rework round 3, alongside the explicit `undefined` control above, so this + // validator pins the same shape classes the other two validators do. it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => { - for (const packages of [{}, 0, 'x', null]) { + for (const packages of [{}, 0, 'x', null, { a: { manifest: {} } }]) { expect(() => validateMappingTargetFields({ objects: [contact], packages, diff --git a/packages/lint/src/validate-object-references.test.ts b/packages/lint/src/validate-object-references.test.ts index da03491ab8f..bfb494ab52b 100644 --- a/packages/lint/src/validate-object-references.test.ts +++ b/packages/lint/src/validate-object-references.test.ts @@ -309,8 +309,8 @@ describe('validateObjectReferences — artifact packages[] as resolution context // malformed, not absent, and every reader refuses it. `null` joins this set // in rework round 1 (ruling A on #19926, `5805260775`): it is present, not // absent, so it is no longer a control. `{}` and a keyed object join in - // rework round 2, so this validator pins the same set `packagesOf` and the - // other two validators do. + // rework round 2, so this validator pins the same shape classes `packagesOf` + // and the other two validators do. it('refuses a PRESENT non-array `packages` instead of silently ignoring it', () => { for (const packages of [null, 42, 'core', {}, { a: { manifest: {} } }]) { expect(() => validateObjectReferences(perPackageStack(ORDERS_BODY, packages))).toThrow( From 82dc0c9c012ce3eb1ff24a4bb9a2675fad594a4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:45:30 +0000 Subject: [PATCH 5/6] test(lint): correct which objectExtensions source the round-3 comment named Addendum on the merge-only round for #20206: the round-3 CONTROL test's comment said `sla_tier` "only resolves via the objectExtensions a package supplies" -- in the fixture it actually cites (:173-174), `sla_tier` comes from the STACK's own top-level objectExtensions, and `region` is the package-supplied one. Reword to say that. The `full_name` retarget itself was already correct and is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../lint/src/validate-mapping-target-fields.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/lint/src/validate-mapping-target-fields.test.ts b/packages/lint/src/validate-mapping-target-fields.test.ts index f903cbed304..1dc4a5295dc 100644 --- a/packages/lint/src/validate-mapping-target-fields.test.ts +++ b/packages/lint/src/validate-mapping-target-fields.test.ts @@ -181,11 +181,12 @@ describe('validateMappingTargetFields', () => { }); it('CONTROL — `packages: undefined` (absent) stays silent — the only value this reader treats as absent', () => { - // `full_name`, not `sla_tier`: `sla_tier` only resolves via the - // `objectExtensions` a package supplies (the test above) — with - // `packages` genuinely absent this control needs a field `contact` - // declares on its own, so a real finding can't masquerade as the - // reader silently accepting the shape. + // `full_name`, not `sla_tier`: `sla_tier` resolves via the STACK's own + // top-level `objectExtensions` (the test above, :173) — `region` is the + // one that needs a package (:174). Either way, with `packages` genuinely + // absent this control needs a field `contact` declares on its own, so a + // real finding can't masquerade as the reader silently accepting the + // shape. expect(validateMappingTargetFields({ objects: [contact], packages: undefined, From a38a259df60e106370f2ea9e9060e12bab8f59b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:06:01 +0000 Subject: [PATCH 6/6] docs(lint,spec): null-parity sentences state present tense, now that #20228 landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REWORK — one more wording round for #20206 (seat ruling 5857515836, extended). PR #20228 (#19926) merged as a9fb83ef06 in round 4, so every sentence still conditioning lint's null-refusal parity with resolveArtifactPackageOrder on "once #20228 lands" was false at head. Restated in the present tense, wording only, no code changed: - object-graph.ts's packagesOf docblock (the null bullet and the @throws block) and its inline naming comment. - the lint changeset's parity sentence. - the ledger row comment in error-code-ledger.zod.ts. git grep -n -E "once (it|#20228|#19926|PR #20228)[^.]*lands|still reads (it|\`null\`) as absent|adopted early" over this PR's changed files now returns 0 hits. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../20206-lint-packages-non-array-refused.md | 2 +- packages/lint/src/object-graph.ts | 17 +++++++---------- packages/spec/src/api/error-code-ledger.zod.ts | 5 ++--- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.changeset/20206-lint-packages-non-array-refused.md b/.changeset/20206-lint-packages-non-array-refused.md index f757462eafc..cd3ff2a56e4 100644 --- a/.changeset/20206-lint-packages-non-array-refused.md +++ b/.changeset/20206-lint-packages-non-array-refused.md @@ -2,7 +2,7 @@ "@objectstack/lint": minor --- -`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 — 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). For every shape other than `null`, this is the same way `@objectstack/core`'s `resolveArtifactPackageOrder` already refuses it, with the same registered code; for `null`, `resolveArtifactPackageOrder` still reads it as absent on `main`, and the two readers align only once #19926 (PR #20228) lands. +`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 — 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). For every shape other than `null`, this is the same way `@objectstack/core`'s `resolveArtifactPackageOrder` already refuses it, with the same registered code; `@objectstack/core`'s `resolveArtifactPackageOrder` refuses `null` the same way (#19926). Clause-②: no (narrowing) diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index 3452a2830d0..693c48ac130 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -241,10 +241,9 @@ export type StackPackagesError = Error & { code: string; status: number }; * - **Absent** (`undefined` ONLY) → `[]`. A single-package artifact * contributes nothing here — this answers "what does `packages[]` add", * never "what does this stack provide". `null` is malformed, per ruling - * `5805260775` on #19926 — for `null` specifically, that disagrees with - * {@link resolveArtifactPackageOrder} (`@objectstack/core`), which on - * `main` still reads `null` as absent; the two readers align once #19926 - * (PR #20228) lands. + * `5805260775` on #19926 — `null` is refused here exactly as + * {@link resolveArtifactPackageOrder} (`@objectstack/core`) refuses it, + * same code and status, since #19926 (PR #20228, `a9fb83ef06`). * - **An array** → iterated, non-record members dropped — unchanged from * what every one of these five call sites did through `recordsOf` before * this function existed. @@ -259,9 +258,8 @@ export type StackPackagesError = Error & { code: string; status: number }; * @throws A {@link StackPackagesError} — `code: 'INVALID_ARTIFACT_PACKAGES'`, * `status: 422` — for a present non-array `packages` OTHER than `null`, * the SAME registered code {@link resolveArtifactPackageOrder} already - * raises for the identical defect on the assembled artifact; for `null`, - * only once #19926 (PR #20228) lands does core raise it too. Never a new - * code, either way. + * raises for the identical defect on the assembled artifact; core raises + * the same code for `null` too (#19926). Never a new code, either way. */ export function packagesOf(stack: unknown): AnyRec[] { const declared = (stack as { packages?: unknown } | null | undefined)?.packages; @@ -274,9 +272,8 @@ export function packagesOf(stack: unknown): AnyRec[] { 'A stack\'s `packages` must be an array of package entries (ADR-0130 D4, ' + '`ArtifactPackageSchema`), but this stack carries `packages` of type ' // `typeof null` is `'object'`, which would name a `{}` the author never - // wrote; `null` is named as itself here — the naming #19926 (PR #20228) - // gives `resolveArtifactPackageOrder` once it lands, adopted early by - // this reader. + // wrote; `null` is named as itself here — the same naming + // `resolveArtifactPackageOrder` uses (`null` named as `null`). + `${declared === null ? 'null' : typeof declared}. Omit the key entirely for a ` + 'single-package stack — `manifest` is retained, not replaced.', ) as StackPackagesError; diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index b7721e00164..1dba8559b53 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1363,9 +1363,8 @@ export const ERROR_CODE_LEDGER = { // OTHER than `null`, with the SAME registered code `@objectstack/core`'s // `resolveArtifactPackageOrder` already raises for the identical defect // on an assembled artifact (ruling A on #15293 `5634034754`). For `null` - // (ruling A on #19926 `5805260775`), core still reads it as absent on - // `main`; core raises this code for `null` too only once #19926 (PR - // #20228) lands — deliberately reused either way, never minted, so an + // (ruling A on #19926 `5805260775`), core raises this code for `null` + // too (#19926) — deliberately reused either way, never minted, so an // author sees one code regardless of which reader catches the malformed // shape first. `door: 'none'`, the #16449 reading: `packages/lint`'s // rules are pure `(stack) => Finding[]` functions, reachable only from