|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #18490 — the artifact package-id rule has ONE owner again, and importing it |
| 5 | + * DECIDED the one input the two copies disagreed on. |
| 6 | + * |
| 7 | + * ## What this file measures, and against what |
| 8 | + * |
| 9 | + * `packages/cli/src/utils/artifact-packages.ts` declares itself the sole owner |
| 10 | + * of "which package is this". `nav-contribution-groups.ts` carried a second |
| 11 | + * implementation, `artifactPackagesOf`, differing from the owner in a single |
| 12 | + * guard — a non-empty check on `manifest.name`. For a package whose |
| 13 | + * `manifest.id` AND `manifest.name` are both `''` the owner answered `''` and |
| 14 | + * the copy answered `packages[<index>]`. |
| 15 | + * |
| 16 | + * Deleting the copy chose `''`. What this file pins is not that string but the |
| 17 | + * REASON it is the right one: `''` is what the RUNTIME fold names that package, |
| 18 | + * and `packages[<index>]` is a spelling the runtime cannot produce at all |
| 19 | + * (`ObjectQL.registerApp` derives `manifest.id || manifest.name`, with no |
| 20 | + * positional fallback). `nav-contribution-groups.ts`'s own header says the id |
| 21 | + * it carries is "the string the runtime registers a contribution under, so a |
| 22 | + * command names a package the same way the fold does" — this is that sentence, |
| 23 | + * measured. |
| 24 | + * |
| 25 | + * ⛔ NEITHER side's rule is re-spelled here. The build's answer comes out of |
| 26 | + * the shipped `findNavGroupDiagnostics`, the runtime's out of a real |
| 27 | + * `ObjectQL`, and the assertion is that the two STRINGS match — so the day |
| 28 | + * either rule moves, this reds instead of agreeing with itself. |
| 29 | + * |
| 30 | + * ## Why this is a file of its own, and why it is INTEGRATION tier |
| 31 | + * |
| 32 | + * The cross-door half constructs `new ObjectQL(`, which is a KERNEL signal in |
| 33 | + * `vitest-tiers.ts`'s predicate — so a file carrying it is integration tier by |
| 34 | + * derivation, not by naming. Folding these cases into the unit-tier |
| 35 | + * `nav-contribution-groups.test.ts` would have moved that whole file, and its |
| 36 | + * nine existing #14553 pins with it, out of the tier they were written for. |
| 37 | + * Splitting keeps the tier change confined to the cases that actually boot a |
| 38 | + * registry. ⛔ Do not merge this file back into that one. |
| 39 | + */ |
| 40 | + |
| 41 | +import { describe, it, expect } from 'vitest'; |
| 42 | +import { composeStacks, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; |
| 43 | +import { ObjectQL } from '@objectstack/objectql/core'; |
| 44 | +import { findNavGroupDiagnostics } from './nav-contribution-groups.js'; |
| 45 | +import { artifactPackages } from './artifact-packages.js'; |
| 46 | + |
| 47 | +type AnyRec = Record<string, unknown>; |
| 48 | + |
| 49 | +const APP = 'multi_crm'; |
| 50 | +const GROUP = 'sales_group'; |
| 51 | +const TYPO = 'sales_grp'; |
| 52 | +const CORE_ID = 'com.example.multi.core'; |
| 53 | + |
| 54 | +/** The App package — owns the app and the group container the module aims at. */ |
| 55 | +const coreStack = () => ({ |
| 56 | + manifest: { |
| 57 | + id: CORE_ID, |
| 58 | + name: 'Multi-Package Core', |
| 59 | + namespace: 'crm', |
| 60 | + version: '1.0.0', |
| 61 | + type: 'app' as const, |
| 62 | + }, |
| 63 | + objects: [{ |
| 64 | + name: 'crm_account', |
| 65 | + label: 'Account', |
| 66 | + sharingModel: 'private' as const, |
| 67 | + fields: { name: { name: 'name', type: 'text' as const, label: 'Account Name', required: true } }, |
| 68 | + }], |
| 69 | + apps: [{ |
| 70 | + name: APP, |
| 71 | + label: 'Multi-Package CRM', |
| 72 | + navigation: [{ |
| 73 | + id: GROUP, |
| 74 | + type: 'group' as const, |
| 75 | + label: 'Sales', |
| 76 | + children: [{ id: 'nav_accounts', type: 'object' as const, objectName: 'crm_account', label: 'Accounts' }], |
| 77 | + }], |
| 78 | + }], |
| 79 | +}); |
| 80 | + |
| 81 | +/** |
| 82 | + * The contributing package, carrying the divergent identity: both id keys `''`. |
| 83 | + * |
| 84 | + * ⚠️ Its own namespace, not the app package's. The composed-artifact leg does |
| 85 | + * not care, but the runtime leg INSTALLS both packages into one registry and |
| 86 | + * ADR-0048 refuses a second package under a namespace another already owns — |
| 87 | + * a conflict that would make this file red for a reason that has nothing to do |
| 88 | + * with what it measures. |
| 89 | + */ |
| 90 | +const emptyIdOrdersStack = (group: string) => ({ |
| 91 | + manifest: { |
| 92 | + id: '', |
| 93 | + name: '', |
| 94 | + namespace: 'ord', |
| 95 | + version: '1.0.0', |
| 96 | + type: 'module' as const, |
| 97 | + navigationContributions: [{ |
| 98 | + app: APP, |
| 99 | + group, |
| 100 | + items: [{ id: 'nav_orders', type: 'object' as const, objectName: 'ord_order', label: 'Orders' }], |
| 101 | + }], |
| 102 | + }, |
| 103 | + objects: [{ |
| 104 | + name: 'ord_order', |
| 105 | + label: 'Order', |
| 106 | + sharingModel: 'private' as const, |
| 107 | + fields: { name: { name: 'name', type: 'text' as const, label: 'Order Number', required: true } }, |
| 108 | + }], |
| 109 | +}); |
| 110 | + |
| 111 | +/** The artifact as the commands actually hand it down — through their own parse. */ |
| 112 | +const parsedEmptyIdArtifact = (group: string): AnyRec => { |
| 113 | + const composed = composeStacks( |
| 114 | + [emptyIdOrdersStack(group), coreStack()], |
| 115 | + { manifest: 'preserve' }, |
| 116 | + ) as unknown as Record<string, unknown>; |
| 117 | + const normalized = normalizeStackInput(composed, { onConversionNotice: () => {} }); |
| 118 | + const result = ObjectStackDefinitionSchema.safeParse(normalized); |
| 119 | + if (!result.success) { |
| 120 | + throw new Error(`fixture does not parse: ${JSON.stringify(result.error.issues.slice(0, 3))}`); |
| 121 | + } |
| 122 | + return result.data as unknown as AnyRec; |
| 123 | +}; |
| 124 | + |
| 125 | +describe('#18490 — one package, two doors, one name', () => { |
| 126 | + it('an empty `manifest.id` AND `manifest.name` REACHES this check — the divergence is not hypothetical', () => { |
| 127 | + // The floor under everything below. `ManifestSchema` requires both keys as |
| 128 | + // strings and constrains NEITHER to be non-empty, so `''` parses — which is |
| 129 | + // the only reason the two rules could ever disagree on a stack `os build` |
| 130 | + // or `os validate` would actually accept. If a spec change starts refusing |
| 131 | + // it, this reds FIRST and says the pins under it now measure nothing. |
| 132 | + const parsed = parsedEmptyIdArtifact(GROUP); |
| 133 | + expect(artifactPackages(parsed).map((pkg) => pkg.id).sort()).toEqual(['', CORE_ID]); |
| 134 | + }); |
| 135 | + |
| 136 | + it('the build names that package EXACTLY as the runtime fold does', async () => { |
| 137 | + // The build door: the shipped derivation, reached the way both commands |
| 138 | + // reach it — `findNavGroupDiagnostics(result.data)`, one argument. |
| 139 | + const built = await findNavGroupDiagnostics(parsedEmptyIdArtifact(TYPO)); |
| 140 | + expect(built).toHaveLength(1); |
| 141 | + |
| 142 | + // The runtime door: the same two packages installed into a real registry. |
| 143 | + // `registerApp` derives the id it registers the contribution under, the |
| 144 | + // read-time fold relocates the mis-aimed items, and recording that |
| 145 | + // relocation is what produces the diagnostic. |
| 146 | + const engine = new ObjectQL(); |
| 147 | + const core = coreStack(); |
| 148 | + engine.registerApp({ ...core.manifest, apps: core.apps }); |
| 149 | + engine.registerApp({ ...emptyIdOrdersStack(TYPO).manifest }); |
| 150 | + engine.registry.getApp(APP); |
| 151 | + const folded = engine.registry.getAppNavDiagnostics(APP); |
| 152 | + expect(folded).toHaveLength(1); |
| 153 | + |
| 154 | + // The card, in two lines: the `packageId` field a consumer reads, and the |
| 155 | + // sentence an author reads. |
| 156 | + expect(built[0].packageId).toBe(folded[0].packageId); |
| 157 | + expect(built[0].message).toBe(folded[0].message); |
| 158 | + }); |
| 159 | + |
| 160 | + it('⛔ and that shared name is NOT the deleted copy\'s positional spelling', async () => { |
| 161 | + // Its own assertion, because the pin above would also pass if BOTH doors |
| 162 | + // moved to `packages[0]`. The runtime has no positional fallback, so a |
| 163 | + // build printing one is a build naming a package the runtime never will. |
| 164 | + const built = await findNavGroupDiagnostics(parsedEmptyIdArtifact(TYPO)); |
| 165 | + expect(built[0].packageId).toBe(''); |
| 166 | + expect(built[0].packageId).not.toBe('packages[0]'); |
| 167 | + expect(built[0].message).not.toContain('packages[0]'); |
| 168 | + }); |
| 169 | + |
| 170 | + it('two packages that both resolve to the empty id still produce TWO findings', async () => { |
| 171 | + // The id is CARRIED and PRINTED on this path — never a map key, a dedupe |
| 172 | + // key or a sort key. Pinned because "both collapse into one finding" is the |
| 173 | + // failure an empty id would cause if it ever became one, and it would |
| 174 | + // present as the report going QUIET rather than as an error. |
| 175 | + const second = { |
| 176 | + manifest: { |
| 177 | + id: '', |
| 178 | + name: '', |
| 179 | + navigationContributions: [{ |
| 180 | + app: APP, |
| 181 | + group: TYPO, |
| 182 | + items: [{ id: 'nav_second', type: 'object' as const, objectName: 'ord_order', label: 'Second' }], |
| 183 | + }], |
| 184 | + }, |
| 185 | + }; |
| 186 | + const parsed = parsedEmptyIdArtifact(TYPO); |
| 187 | + const widened: AnyRec = { |
| 188 | + ...parsed, |
| 189 | + packages: [...((parsed.packages ?? []) as unknown[]), second], |
| 190 | + }; |
| 191 | + const found = await findNavGroupDiagnostics(widened); |
| 192 | + expect(found).toHaveLength(2); |
| 193 | + expect(found.map((d) => d.packageId)).toEqual(['', '']); |
| 194 | + }); |
| 195 | +}); |
0 commit comments