|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { composeStacks, defineStack, ObjectStackDefinitionSchema } from '@objectstack/spec'; |
| 5 | +import { checkDeclaredDependencies } from '@objectstack/metadata-core'; |
| 6 | +import { checkDependencyRanges, deliveredPackages } from './dependency-range-check.js'; |
| 7 | + |
| 8 | +/** One package of an artifact, built the way an author writes it. */ |
| 9 | +function pkg(id: string, version: string, dependencies?: Record<string, string>) { |
| 10 | + return defineStack({ |
| 11 | + manifest: { |
| 12 | + id, |
| 13 | + name: id, |
| 14 | + namespace: 'crm', |
| 15 | + version, |
| 16 | + type: id.endsWith('.core') ? 'app' : 'module', |
| 17 | + engines: { protocol: '^18' }, |
| 18 | + ...(dependencies ? { dependencies } : {}), |
| 19 | + }, |
| 20 | + }); |
| 21 | +} |
| 22 | + |
| 23 | +/** An ADR-0130 artifact of `packages`, through the parse every door runs. */ |
| 24 | +function artifact(...packages: ReturnType<typeof pkg>[]): Record<string, unknown> { |
| 25 | + return ObjectStackDefinitionSchema.parse(composeStacks(packages, { manifest: 'preserve' })) as Record<string, unknown>; |
| 26 | +} |
| 27 | + |
| 28 | +const codes = (stack: unknown) => checkDependencyRanges(stack).map((d) => [d.code, d.severity]); |
| 29 | + |
| 30 | +describe('checkDependencyRanges — arm 1: an unreadable range', () => { |
| 31 | + it('is an ERROR on an out-of-artifact entry, naming the entry, the range and the grammar', () => { |
| 32 | + const stack = artifact( |
| 33 | + pkg('com.acme.core', '1.0.0'), |
| 34 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': '^1.0.0', 'com.vendor.billing': 'latest' }), |
| 35 | + ); |
| 36 | + const found = checkDependencyRanges(stack); |
| 37 | + expect(found).toHaveLength(1); |
| 38 | + expect(found[0]).toMatchObject({ |
| 39 | + code: 'dependency-range-unreadable', |
| 40 | + severity: 'error', |
| 41 | + packageId: 'com.acme.orders', |
| 42 | + dependency: 'com.vendor.billing', |
| 43 | + range: 'latest', |
| 44 | + inArtifact: false, |
| 45 | + path: 'packages[1].manifest.dependencies["com.vendor.billing"]', |
| 46 | + }); |
| 47 | + expect(found[0]!.fix).toContain('`||` between alternatives'); |
| 48 | + }); |
| 49 | + |
| 50 | + it('is an ERROR on a single-package stack, at `manifest.dependencies`', () => { |
| 51 | + const stack = ObjectStackDefinitionSchema.parse( |
| 52 | + pkg('com.acme.orders', '1.0.0', { 'com.vendor.billing': 'one point two' }), |
| 53 | + ); |
| 54 | + const found = checkDependencyRanges(stack); |
| 55 | + expect(found.map((d) => [d.code, d.severity, d.path])).toEqual([ |
| 56 | + ['dependency-range-unreadable', 'error', 'manifest.dependencies["com.vendor.billing"]'], |
| 57 | + ]); |
| 58 | + }); |
| 59 | + |
| 60 | + it('is a WARNING on a co-delivered entry — the install judges it for presence only', () => { |
| 61 | + const stack = artifact( |
| 62 | + pkg('com.acme.core', '1.0.0'), |
| 63 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': 'v-one' }), |
| 64 | + ); |
| 65 | + expect(codes(stack)).toEqual([['dependency-range-unreadable', 'warning']]); |
| 66 | + expect(checkDependencyRanges(stack)[0]!.inArtifact).toBe(true); |
| 67 | + }); |
| 68 | + |
| 69 | + it('reads every form of the stated grammar as readable', () => { |
| 70 | + for (const range of ['^1.2.0', '~1.2', '>=1.0.0', '1.x', '*', '1.0.0 - 1.4', '>=1.0.0 <3', '^0.9 || ^1']) { |
| 71 | + const stack = ObjectStackDefinitionSchema.parse(pkg('com.acme.orders', '1.0.0', { 'com.vendor.billing': range })); |
| 72 | + expect(checkDependencyRanges(stack), range).toEqual([]); |
| 73 | + } |
| 74 | + }); |
| 75 | +}); |
| 76 | + |
| 77 | +describe('checkDependencyRanges — arm 2: an in-artifact range the sibling version does not meet', () => { |
| 78 | + it('reports core bumped to 2.0.0 while a module still declares ^1.0.0, as a WARNING naming both sides', () => { |
| 79 | + const stack = artifact( |
| 80 | + pkg('com.acme.core', '2.0.0'), |
| 81 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': '^1.0.0' }), |
| 82 | + ); |
| 83 | + const found = checkDependencyRanges(stack); |
| 84 | + expect(found).toHaveLength(1); |
| 85 | + expect(found[0]).toMatchObject({ |
| 86 | + code: 'dependency-range-unmet-in-artifact', |
| 87 | + severity: 'warning', |
| 88 | + packageId: 'com.acme.orders', |
| 89 | + dependency: 'com.acme.core', |
| 90 | + range: '^1.0.0', |
| 91 | + siblingVersion: '2.0.0', |
| 92 | + inArtifact: true, |
| 93 | + path: 'packages[1].manifest.dependencies["com.acme.core"]', |
| 94 | + }); |
| 95 | + }); |
| 96 | + |
| 97 | + it('is silent when the sibling version is inside the range, whatever the package order', () => { |
| 98 | + expect(codes(artifact( |
| 99 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': '^1.0.0' }), |
| 100 | + pkg('com.acme.core', '1.4.2'), |
| 101 | + ))).toEqual([]); |
| 102 | + }); |
| 103 | + |
| 104 | + it('reports a sibling whose version is outside the version grammar, as a WARNING on the version side', () => { |
| 105 | + const stack = artifact( |
| 106 | + pkg('com.acme.core', '2.0.0-beta.1'), |
| 107 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': '^2.0.0' }), |
| 108 | + ); |
| 109 | + expect(codes(stack)).toEqual([['dependency-version-unreadable-in-artifact', 'warning']]); |
| 110 | + }); |
| 111 | + |
| 112 | + it('does not judge an out-of-artifact entry it can read — the target environment decides that', () => { |
| 113 | + expect(codes(artifact( |
| 114 | + pkg('com.acme.core', '1.0.0'), |
| 115 | + pkg('com.acme.orders', '1.0.0', { 'com.vendor.billing': '^3.0.0' }), |
| 116 | + ))).toEqual([]); |
| 117 | + }); |
| 118 | +}); |
| 119 | + |
| 120 | +describe('checkDependencyRanges — the errors are exactly what the install door refuses', () => { |
| 121 | + // The install door's own call, spelled as `marketplace-install-local-plugin.ts` |
| 122 | + // makes it, against an EMPTY environment: its environment-independent |
| 123 | + // refusals (an unreadable range, an unreadable declaration) are the set this |
| 124 | + // check may refuse at build. `absent` depends on the environment and is the |
| 125 | + // install's alone. If the two sets ever differ, `os build` refuses what the |
| 126 | + // platform installs, or emits what it refuses. |
| 127 | + const installDoorRefusals = (stack: Record<string, unknown>) => |
| 128 | + checkDeclaredDependencies({ |
| 129 | + delivered: Array.isArray(stack.packages) |
| 130 | + ? (stack.packages as Array<{ manifest: Record<string, unknown> }>).map((entry) => entry.manifest) |
| 131 | + : [stack.manifest as Record<string, unknown>], |
| 132 | + getInstalled: () => undefined, |
| 133 | + }) |
| 134 | + .filter((u) => u.found === 'unreadable-range' || u.found === 'unreadable-declaration') |
| 135 | + .map((u) => [u.packageId, 'dependency' in u ? u.dependency : null]); |
| 136 | + |
| 137 | + const cases: Array<[string, Record<string, unknown>]> = [ |
| 138 | + ['an unreadable out-of-artifact range', artifact( |
| 139 | + pkg('com.acme.core', '1.0.0'), |
| 140 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': '^1.0.0', 'com.vendor.billing': 'latest' }), |
| 141 | + )], |
| 142 | + ['an unreadable co-delivered range', artifact( |
| 143 | + pkg('com.acme.core', '1.0.0'), |
| 144 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': 'v-one' }), |
| 145 | + )], |
| 146 | + ['an in-artifact mismatch', artifact( |
| 147 | + pkg('com.acme.core', '2.0.0'), |
| 148 | + pkg('com.acme.orders', '1.0.0', { 'com.acme.core': '^1.0.0' }), |
| 149 | + )], |
| 150 | + ['a single package', ObjectStackDefinitionSchema.parse( |
| 151 | + pkg('com.acme.orders', '1.0.0', { 'com.vendor.billing': '>=x', 'com.vendor.crm': '^2' }), |
| 152 | + ) as Record<string, unknown>], |
| 153 | + ]; |
| 154 | + |
| 155 | + for (const [name, stack] of cases) { |
| 156 | + it(`agrees on ${name}`, () => { |
| 157 | + const errors = checkDependencyRanges(stack) |
| 158 | + .filter((d) => d.severity === 'error') |
| 159 | + .map((d) => [d.packageId, d.dependency ?? null]); |
| 160 | + expect(errors).toEqual(installDoorRefusals(stack)); |
| 161 | + }); |
| 162 | + } |
| 163 | +}); |
| 164 | + |
| 165 | +describe('deliveredPackages', () => { |
| 166 | + it('reads `packages[]` bodies and never the artifact wrapper manifest', () => { |
| 167 | + const stack = artifact(pkg('com.acme.core', '1.0.0'), pkg('com.acme.orders', '1.0.0')); |
| 168 | + expect(deliveredPackages(stack).map((p) => [p.manifest.id, p.at])).toEqual([ |
| 169 | + ['com.acme.core', 'packages[0].manifest'], |
| 170 | + ['com.acme.orders', 'packages[1].manifest'], |
| 171 | + ]); |
| 172 | + }); |
| 173 | + |
| 174 | + it('reads the top-level manifest when there is no `packages[]`, and nothing from a non-stack', () => { |
| 175 | + expect(deliveredPackages({ manifest: { id: 'a' } }).map((p) => p.at)).toEqual(['manifest']); |
| 176 | + expect(deliveredPackages(null)).toEqual([]); |
| 177 | + expect(deliveredPackages({})).toEqual([]); |
| 178 | + }); |
| 179 | + |
| 180 | + it('reports a `dependencies` that is not a record as an ERROR (the normalized tier `os lint` reads)', () => { |
| 181 | + expect(codes({ manifest: { id: 'com.acme.orders', dependencies: ['com.acme.core'] } })) |
| 182 | + .toEqual([['dependency-declaration-unreadable', 'error']]); |
| 183 | + }); |
| 184 | +}); |
0 commit comments