|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A `packages` that is present but is not an array is REFUSED by every |
| 5 | + * `@objectstack/cli` reader of it, never read as "no packages" (#19925). |
| 6 | + * |
| 7 | + * The rule is ruling A on #15293: `{}`, `0` and `'x'` are malformed, not |
| 8 | + * absent. It is stated once, beside `AssembledPackageBodySchema` |
| 9 | + * (`@objectstack/spec`), and enforced once, by `resolveArtifactPackageOrder` |
| 10 | + * (`@objectstack/core`), as `INVALID_ARTIFACT_PACKAGES`. The runtime, core and |
| 11 | + * the plugin readers already refused it. The CLI readers answered `[]` or |
| 12 | + * handed the value back unchanged, so `os info` printed `0` objects for such a |
| 13 | + * stack and `os lint` passed it with exit 0. Both were measured from a |
| 14 | + * hand-written config exported as a plain object, which reaches the command |
| 15 | + * without `defineStack`'s parse. |
| 16 | + * |
| 17 | + * Each reader is pinned through the entry its door calls: |
| 18 | + * |
| 19 | + * - `packageBodies` (private) through `resolveStackCollection` and |
| 20 | + * `authoringRuleUnionStack`, and through the two door functions that reach |
| 21 | + * it first: `collectMetadataStats` (`os info`) and `lintConfig` (`os lint`). |
| 22 | + * - `docsPackageRefs`, directly and through `collectDocsFromSrc`, the |
| 23 | + * collection step `os serve` / `os build` / `os lint` run. |
| 24 | + * - `attachPackageDocs`, directly. |
| 25 | + * - `bodyDocsOf` (private) has one caller, and that caller has already handed |
| 26 | + * the same value to `docsPackageRefs`, so a non-array cannot reach it |
| 27 | + * through any export. It goes through the same judgment and is not pinned |
| 28 | + * separately. |
| 29 | + * |
| 30 | + * Every refusal asserts the ADR-0112 envelope (`code` + `status`), never a bare |
| 31 | + * `toThrow()`, which an unnamed `Error` would also satisfy. The lit control is a |
| 32 | + * malformed ENTRY, refused as `INVALID_ARTIFACT_PACKAGE_ENTRY` through the same |
| 33 | + * entries. The other controls are a well-formed array, which reads as before, |
| 34 | + * and an absent `packages`, which still reads as no packages. |
| 35 | + * |
| 36 | + * `packages: null` is pinned in `null-packages-follows-resolver.test.ts`, not |
| 37 | + * here. Ruling A on #19926 (`5805260775`) makes `null` malformed at every |
| 38 | + * reader, and it is refused by core's resolver, never by the CLI. So the pin |
| 39 | + * there asserts that each reader answers `null` the way the resolver does. It |
| 40 | + * does not assert a fixed answer. |
| 41 | + */ |
| 42 | + |
| 43 | +import { describe, expect, it } from 'vitest'; |
| 44 | + |
| 45 | +import { lintConfig } from '../src/commands/lint'; |
| 46 | +import { attachPackageDocs, collectDocsFromSrc, docsPackageRefs, type PackageDocSet } from '../src/utils/collect-docs'; |
| 47 | +import { collectMetadataStats } from '../src/utils/format'; |
| 48 | +import { authoringRuleUnionStack, resolveStackCollection } from '../src/utils/stack-collections'; |
| 49 | + |
| 50 | +const MANIFEST = { |
| 51 | + id: 'com.example.probe', |
| 52 | + name: 'probe', |
| 53 | + version: '1.0.0', |
| 54 | + type: 'app' as const, |
| 55 | + namespace: 'probe', |
| 56 | + engines: { protocol: '^17' }, |
| 57 | +}; |
| 58 | + |
| 59 | +const PROBE_OBJECT = { |
| 60 | + name: 'probe_account', |
| 61 | + label: 'Probe Account', |
| 62 | + sharingModel: 'private' as const, |
| 63 | + fields: { name: { name: 'name', type: 'text' as const, label: 'Name' } }, |
| 64 | +}; |
| 65 | + |
| 66 | +/** The three spellings ruling A names. */ |
| 67 | +const NON_ARRAYS: ReadonlyArray<readonly [string, unknown]> = [ |
| 68 | + ['{}', {}], |
| 69 | + ['0', 0], |
| 70 | + ["'x'", 'x'], |
| 71 | +]; |
| 72 | + |
| 73 | +/** One well-formed entry, as `resolveArtifactPackageOrder` parses it whole. */ |
| 74 | +const wellFormed = () => [{ manifest: { ...MANIFEST, objects: [PROBE_OBJECT] } }]; |
| 75 | + |
| 76 | +/** The lit control: a body inlined onto the array element, not wrapped. */ |
| 77 | +const inlinedEntry = () => [{ ...MANIFEST, objects: [PROBE_OBJECT] }]; |
| 78 | + |
| 79 | +/** A stack whose ONLY answer to "which objects" has to come from `packages`. */ |
| 80 | +const stackWith = (packages: unknown) => ({ manifest: MANIFEST, packages }); |
| 81 | + |
| 82 | +const docSet = (): PackageDocSet[] => [{ |
| 83 | + index: 0, |
| 84 | + id: MANIFEST.id, |
| 85 | + namespace: MANIFEST.namespace, |
| 86 | + dir: 'src/probe/docs', |
| 87 | + docs: [{ name: 'probe_guide', label: 'Guide', content: '# Guide' }], |
| 88 | +}]; |
| 89 | + |
| 90 | +/** A config path whose `src/` does not exist: nothing on disk is read. */ |
| 91 | +const CONFIG_PATH = '/nonexistent-19925/objectstack.config.ts'; |
| 92 | + |
| 93 | +/** The ADR-0112 envelope a call raised, or `undefined` when it returned. */ |
| 94 | +function refusalOf(call: () => unknown): { code?: unknown; status?: unknown } | undefined { |
| 95 | + try { |
| 96 | + call(); |
| 97 | + } catch (error) { |
| 98 | + expect(error, 'the refusal is an Error carrying the envelope').toBeInstanceOf(Error); |
| 99 | + return error as { code?: unknown; status?: unknown }; |
| 100 | + } |
| 101 | + return undefined; |
| 102 | +} |
| 103 | + |
| 104 | +function expectRefused(call: () => unknown, code: string): void { |
| 105 | + const refusal = refusalOf(call); |
| 106 | + expect(refusal, `expected a ${code} refusal, got an answer`).toBeDefined(); |
| 107 | + expect(refusal?.code).toBe(code); |
| 108 | + expect(refusal?.status).toBe(422); |
| 109 | +} |
| 110 | + |
| 111 | +describe('#19925: a present non-array `packages` is refused by every CLI reader', () => { |
| 112 | + describe.each(NON_ARRAYS)('packages: %s', (_label, packages) => { |
| 113 | + it('packageBodies refuses through resolveStackCollection and authoringRuleUnionStack', () => { |
| 114 | + expectRefused(() => resolveStackCollection(stackWith(packages), 'objects'), 'INVALID_ARTIFACT_PACKAGES'); |
| 115 | + expectRefused(() => authoringRuleUnionStack(stackWith(packages)), 'INVALID_ARTIFACT_PACKAGES'); |
| 116 | + }); |
| 117 | + |
| 118 | + it('`os info` (collectMetadataStats) and `os lint` (lintConfig) refuse instead of answering', () => { |
| 119 | + expectRefused(() => collectMetadataStats(stackWith(packages)), 'INVALID_ARTIFACT_PACKAGES'); |
| 120 | + expectRefused(() => lintConfig(stackWith(packages)), 'INVALID_ARTIFACT_PACKAGES'); |
| 121 | + }); |
| 122 | + |
| 123 | + it('docsPackageRefs refuses, directly and through collectDocsFromSrc', () => { |
| 124 | + expectRefused(() => docsPackageRefs(packages), 'INVALID_ARTIFACT_PACKAGES'); |
| 125 | + expectRefused(() => collectDocsFromSrc(CONFIG_PATH, packages), 'INVALID_ARTIFACT_PACKAGES'); |
| 126 | + }); |
| 127 | + |
| 128 | + it('attachPackageDocs refuses rather than handing the value back', () => { |
| 129 | + expectRefused(() => attachPackageDocs(packages, docSet()), 'INVALID_ARTIFACT_PACKAGES'); |
| 130 | + }); |
| 131 | + }); |
| 132 | +}); |
| 133 | + |
| 134 | +describe('#19925 controls: the same entries still read the well-formed and absent shapes', () => { |
| 135 | + it('a well-formed array reads as before', () => { |
| 136 | + expect(resolveStackCollection(stackWith(wellFormed()), 'objects')).toEqual([PROBE_OBJECT]); |
| 137 | + expect(collectMetadataStats(stackWith(wellFormed())).objects).toBe(1); |
| 138 | + expect(docsPackageRefs(wellFormed()).map((ref) => ref.id)).toEqual([MANIFEST.id]); |
| 139 | + expect(collectDocsFromSrc(CONFIG_PATH, wellFormed()).packageDocs).toEqual([]); |
| 140 | + |
| 141 | + const attached = attachPackageDocs(wellFormed(), docSet()) as Array<{ manifest: { docs?: unknown[] } }>; |
| 142 | + expect(attached[0].manifest.docs).toEqual(docSet()[0].docs); |
| 143 | + }); |
| 144 | + |
| 145 | + it('an empty array reads as no packages, and attachPackageDocs hands it back by identity', () => { |
| 146 | + const empty: unknown[] = []; |
| 147 | + expect(resolveStackCollection(stackWith(empty), 'objects')).toEqual([]); |
| 148 | + expect(docsPackageRefs(empty)).toEqual([]); |
| 149 | + expect(attachPackageDocs(empty, docSet())).toBe(empty); |
| 150 | + }); |
| 151 | + |
| 152 | + it('an absent `packages` still reads as no packages', () => { |
| 153 | + const absent = { manifest: MANIFEST }; |
| 154 | + expect(resolveStackCollection(absent, 'objects')).toEqual([]); |
| 155 | + expect(authoringRuleUnionStack(absent)).toBe(absent); |
| 156 | + expect(collectMetadataStats(absent).objects).toBe(0); |
| 157 | + expect(docsPackageRefs(undefined)).toEqual([]); |
| 158 | + expect(attachPackageDocs(undefined, docSet())).toBeUndefined(); |
| 159 | + }); |
| 160 | + |
| 161 | + it('lit control: a malformed ENTRY is refused through the same entries, with its own code', () => { |
| 162 | + expectRefused(() => resolveStackCollection(stackWith(inlinedEntry()), 'objects'), 'INVALID_ARTIFACT_PACKAGE_ENTRY'); |
| 163 | + expectRefused(() => collectMetadataStats(stackWith(inlinedEntry())), 'INVALID_ARTIFACT_PACKAGE_ENTRY'); |
| 164 | + expectRefused(() => lintConfig(stackWith(inlinedEntry())), 'INVALID_ARTIFACT_PACKAGE_ENTRY'); |
| 165 | + }); |
| 166 | +}); |
0 commit comments