Skip to content

Commit 48ee8b6

Browse files
feat(cli): os validate, os build and os lint report manifest dependency ranges at build (ADR-0141 D2 author side) (#26505)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBLnF1puMxyuL977oxW4JUtt)_ Fixes #25793 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`os validate`、`os build`、`os lint` 在构建时检查 manifest 的 `dependencies` 版本范围:写错语法的范围、以及同一个发布包内模块声明的范围与兄弟包实际版本对不上,都会当场报出来。 - **为什么改**:以前作者第一次听说这类问题,是别人的环境里安装被拒(422);包内版本对不上则根本没人报。现在在构建时就能看到,HotCRM 拆模块时最先受益。 - **风险与回滚**:只会让 `os build` 拒绝「安装时无论如何都会被拒」的范围(包外依赖写了读不懂的范围);包内的不一致只警告、不拒绝。读范围用的是安装门同一个函数,没有第二套解析。回滚即 revert 本 PR。 - **先看**:① `packages/cli/src/utils/dependency-range-check.ts`(严重度如何跟随安装判定,`diagnosticOf`);② 同目录测试里「the errors are exactly what the install door refuses」一组;③ `packages/cli/src/commands/compile.ts` 的 3b-quater 步。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project integration test/dependency-range-doors.test.ts`,预期 5 个测试全过:读不懂的包外范围让 validate/build 退出 1,包内版本不符只出现在 warnings。 - **需要你做的**:无,席位落地。 ## What changes ADR-0141 D2's author-side rule (Execution plan item 2). A new `packages/cli/src/utils/dependency-range-check.ts`, beside `checkProtocolVersionGap`, judges every `manifest.dependencies` entry of the packages one install act would deliver: each `packages[]` body of an ADR-0130 artifact, or the top-level manifest of a single package. That is the selection the artifact install door hands the same predicate (`marketplace-install-local-plugin.ts`); an artifact's wrapper manifest is not judged, there or here. **The reader is `checkDeclaredDependencies` from `@objectstack/metadata-core`** (with `DEPENDENCY_RANGE_GRAMMAR` for the message text). Nothing re-parses the grammar, and metadata-core is unchanged. Each delivered package is handed to the predicate alone, with its siblings standing in for the registry. A sibling then reads as "installed at its declared `manifest.version`", so the predicate's own `out-of-range` arm is the in-artifact mismatch and its `unreadable-range` arm is the unreadable range. Its `absent` arm, an out-of-artifact entry, is dropped: the target environment is not visible to a build. Severity follows the install's own verdict: | Finding | Install verdict | Here | |:---|:---|:---| | `dependency-range-unreadable`, entry outside the artifact | refused in every environment (fail closed) | **error**: validate and build exit 1; lint error | | `dependency-declaration-unreadable` (not a record; reachable on lint's unparsed tier) | refused | **error** | | `dependency-range-unmet-in-artifact` (sibling version outside the range) | admitted (presence only) | warning | | `dependency-range-unreadable`, co-delivered entry | admitted (presence only) | warning | | `dependency-version-unreadable-in-artifact` (sibling version not `MAJOR.MINOR.PATCH`) | admitted | warning | So `os build` refuses nothing the platform would install, which is what keeps `Clause-②: no` honest. The warnings ride `warningsSoFar()` in `validate.ts` and `compile.ts` (appended last, like the nav and permission-set lists) and are not in `--strict`'s list. `os lint` maps them by severity, so `os lint --strict` fails on them. Wiring: `validate.ts` step 3e, `compile.ts` step 3b-quater, `lint.ts` beside the protocol-range check in `lintConfig`. `validate-build-gate-parity.test.ts` gets the `checkDependencyRanges` row in `SHARED_NON_REGISTRY_GATES` (it is cross-package by construction, so it cannot be a registry rule) and `formatDependencyRangeDiagnostic` under presentation. ## Tests - `packages/cli/src/utils/dependency-range-check.test.ts` (unit, 15 tests). Arm 1: out-of-artifact error with path and grammar, single-package error, co-delivered warning, every grammar form readable. Arm 2: core 2.0.0 against `^1.0.0` is a warning naming both sides, silent when met in either package order, sibling prerelease version is a version-side warning, a readable out-of-artifact entry is not judged. **Install-door agreement** (seat 1's ask): four stacks are fed to `checkDeclaredDependencies` exactly as the artifact install door calls it against an empty environment, and the environment-independent refusals (`unreadable-range`, `unreadable-declaration`) must equal this check's errors. - `packages/cli/test/dependency-range-doors.test.ts` (integration tier, 5 tests): two-package `composeStacks(…, { manifest: 'preserve' })` projects spawned through the source CLI. Unreadable range: `os validate --json` and `os build --json` exit 1 with the entry in `errors`. Mismatch: `os validate --json` exits 0 with it in `warnings`, and `os lint --json` has a warning at `packages[1].manifest.dependencies["com.acme.core"]`. Control: no dependency finding. ## Acceptance notes - The card says both doors judge "the ADR-0130 D4 union fold". The check reads `packages[]` bodies directly, the install door's own reading. The union fold flattens collections, not manifests, so it carries no per-package `dependencies` to judge. - `os build` gained an exit-1 path. It fires only for an artifact the install refuses in every environment. - Out of scope, as the card states: whether an out-of-artifact dependency is installed or in range stays the install check's. ## Verification <details><summary>Ablation (one, on the behaviour the tests pin)</summary> `node scripts/ablation-replace.mjs --file packages/cli/src/utils/dependency-range-check.ts --anchor "severity: inArtifact ? 'warning' : 'error'," --replacement "severity: 'warning',"` over the unit file at 8f126e6. The anchor went from 1 hit to 0 and the blob from 6da446f to a6cf785ecc76. **3 failed | 12 passed**: both arm-1 error tests and "agrees on an unreadable out-of-artifact range". The tool's own check confirmed the restore: blob equals HEAD and `git diff HEAD` is empty. The CLI tests import `src/`, so no `dist/` leg applies. </details> <details><summary>Local runs</summary> - Dependency closure `pnpm --filter '@objectstack/cli^...' build`: VERDICT command-exit 0. - `pnpm --filter @objectstack/cli typecheck` (tsc plus `check:test-typecheck`): exit 0 at d93a221, after one TS2352 fix in the door test. - `vitest run --project unit` (whole cli unit tier): 336 of 338 files passed. The two red files, `published-subpath-console.pin` and `published-subpath-hook-body.pin`, failed only because `packages/cli/dist` was not built. After `pnpm --filter @objectstack/cli build` they pass (2 files, 29 tests). - Integration: `dependency-range-doors.test.ts`, 5 of 5 passed. The rest of the integration tier is left to CI. - eslint `--no-inline-config` on the 7 changed source and test files: exit 0. - Derived gates (`dispatch-gates --commands`, 62 families) run serially, reconciled with `--ran`: 57 exit 0. 5 are NOT MEASURED (exit 3, prerequisite: a full build or a pinned fixture commit): `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`, `check:dual-build-cjs-loads`, `check-plugin-teardown-shape --self-test`. CI runs them. The run began at 8f126e6. d93a221 changes only a type cast in the door test. - Full dogfood: not run. The title has no `!` and there is no `(narrowing)` arm. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint passes on the changed files (repo-wide `pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the maintainer brief, which the repo's rules ask for in Chinese. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzh15CRsSoQWL9C3Uja --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzh15CRsSoQWL9C3Uja)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 79cd9ef commit 48ee8b6

8 files changed

Lines changed: 693 additions & 0 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os validate`, `os build` and `os lint` report `manifest.dependencies` ranges at build time (ADR-0141 D2, author side)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The pre-install dependency check refuses a package whose `dependencies` range it cannot read, and judges a dependency the same artifact delivers for presence only. Until now the author first heard of either from someone else's environment: a `422 DEPENDENCY_MISSING` on install, or, for an in-artifact mismatch, never. The three authoring commands now judge every `dependencies` entry of the packages one install would deliver (each `packages[]` body of an ADR-0130 artifact, or the top-level manifest of a single package) with the install's own predicate, `checkDeclaredDependencies` from `@objectstack/metadata-core`. Nothing re-parses the range grammar.
11+
12+
- **Error**: a range outside the stated grammar on an entry the artifact does not deliver, and a `dependencies` that is not a `{ PACKAGE_ID: VERSION_RANGE }` record. The install refuses these whatever the target environment holds, so `os build` refusing them refuses nothing the platform would install.
13+
- **Warning**: what the install admits. That is an entry naming a sibling package of the same artifact whose declared `manifest.version` the range does not admit (core bumped to `2.0.0` while a module still declares `^1.0.0`), a co-delivered entry whose range cannot be read, or a sibling whose version is not `MAJOR.MINOR.PATCH`.
14+
15+
Whether an out-of-artifact dependency is installed, and at what version, is still judged only at install. No build can see the target environment.

‎packages/cli/src/commands/compile.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ import {
5151
isReportedError,
5252
} from '../utils/format.js';
5353
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
54+
import {
55+
checkDependencyRanges,
56+
formatDependencyRangeDiagnostic,
57+
type DependencyRangeDiagnostic,
58+
} from '../utils/dependency-range-check.js';
5459
// [#14553] The compile-time half of the navigation-contribution group ruling.
5560
// Reports; never refuses — the runtime still relocates, deliberately.
5661
import { findNavGroupDiagnostics } from '../utils/nav-contribution-groups.js';
@@ -190,6 +195,11 @@ export default class Compile extends Command {
190195
// and `os validate` computes the identical records. Appended LAST, in
191196
// `os validate`'s order.
192197
let picklistReferenceNotices: ReturnType<typeof judgePicklistReferences>['notices'] = [];
198+
// ADR-0141 D2 author side (step 3b-quater): the `dependencies` findings
199+
// the install ADMITS. A member of `warningsSoFar()`, ⛔ not a payload key,
200+
// for the reasons the lists above record: it reports without refusing, and
201+
// `os validate` computes the identical records. Appended LAST.
202+
let dependencyRangeWarnings: DependencyRangeDiagnostic[] = [];
193203
const warningsSoFar = () => [
194204
...ruleAdvisories,
195205
...docWarnings,
@@ -199,6 +209,7 @@ export default class Compile extends Command {
199209
...permissionSetCollisionWarnings,
200210
...jsxGateNotices,
201211
...picklistReferenceNotices,
212+
...dependencyRangeWarnings,
202213
];
203214
// [#18780] ONE rendering of the author-time advisory block, from the
204215
// COMPLETE list — hoisted here for the same reason the lists above are.
@@ -756,6 +767,43 @@ export default class Compile extends Command {
756767
);
757768
}
758769

770+
// 3b-quater. ADR-0141 D2, author side — every `manifest.dependencies`
771+
// range of the packages one install act would deliver, judged by the
772+
// install's own predicate (`utils/dependency-range-check.ts`).
773+
//
774+
// REFUSES exactly what the install refuses whatever the target
775+
// environment holds — an unreadable range on an out-of-artifact
776+
// entry, which no installed version can satisfy — so the artifact
777+
// this would emit is one no environment installs. ⛔ The in-artifact
778+
// findings (a sibling's version outside the range, or a co-delivered
779+
// range or version that cannot be read) are ADMITTED by the install,
780+
// which judges a co-delivered entry for presence only; they report,
781+
// and exiting non-zero on them would narrow what `os build` accepts.
782+
const dependencyRanges = checkDependencyRanges(result.data);
783+
const dependencyRangeErrors = dependencyRanges.filter((d) => d.severity === 'error');
784+
dependencyRangeWarnings = dependencyRanges.filter((d) => d.severity !== 'error');
785+
if (dependencyRangeErrors.length > 0) {
786+
if (flags.json) {
787+
await emitJson({ success: false, errors: dependencyRangeErrors, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
788+
this.exit(1);
789+
}
790+
console.log('');
791+
printError(
792+
`Dependency range check failed (${dependencyRangeErrors.length} issue${dependencyRangeErrors.length > 1 ? 's' : ''})`
793+
+ ' — the install refuses every one of these',
794+
);
795+
for (const d of dependencyRangeErrors) console.log(chalk.red(` ✗ ${formatDependencyRangeDiagnostic(d)}`));
796+
this.exit(1);
797+
}
798+
if (dependencyRangeWarnings.length > 0 && !flags.json) {
799+
console.log('');
800+
printWarning(
801+
`Dependency ranges inside this artifact the install does not judge (${dependencyRangeWarnings.length})`
802+
+ ' — a package the same artifact delivers is checked for presence only',
803+
);
804+
printBulletList(dependencyRangeWarnings.map(formatDependencyRangeDiagnostic), { noun: 'dependency-range diagnostic' });
805+
}
806+
759807
// 3c. [#3366] Installable-provider preflight. Every capability the app
760808
// DECLARES in `requires: [...]` must have a provider resolvable in the
761809
// active edition. A `requires` entry whose provider has NO installable

‎packages/cli/src/commands/lint.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { scoreMetadata } from '../lint/score.js';
1616
import { checkHookBodyLowering } from '../lint/hook-body-lowering.js';
1717
import { lowerCallables } from '../utils/lower-callables.js';
1818
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
19+
import { checkDependencyRanges } from '../utils/dependency-range-check.js';
1920
import {
2021
artifactPackages,
2122
packageBodyAsStack,
@@ -585,6 +586,22 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
585586
}
586587
}
587588

589+
// ── Dependency ranges (ADR-0141 D2, author side) ──
590+
// The SAME findings, at the same severity, `os build` / `os validate` report:
591+
// an `error` is an entry every install refuses (an unreadable range on an
592+
// out-of-artifact entry), a `warning` an in-artifact mismatch the install
593+
// admits. Judged on the packages one install act delivers — `packages[]`
594+
// bodies, or the top-level manifest — which the fold above keeps verbatim.
595+
for (const d of checkDependencyRanges(stack)) {
596+
issues.push({
597+
severity: d.severity,
598+
rule: `dependencies/${d.code}`,
599+
message: d.message,
600+
path: d.path,
601+
fix: d.fix,
602+
});
603+
}
604+
588605
// ── Hook/action bodies that cannot be lowered to metadata (commit ada3834ad) ──
589606
// `os build` catches every extraction refusal, warns, and bundles the closure
590607
// at exit 0 — so an app can stop being shippable as pure metadata with nothing

‎packages/cli/src/commands/validate.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ import {
5050
isReportedError,
5151
} from '../utils/format.js';
5252
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
53+
import {
54+
checkDependencyRanges,
55+
formatDependencyRangeDiagnostic,
56+
type DependencyRangeDiagnostic,
57+
} from '../utils/dependency-range-check.js';
5358
import { readSpecReleaseChanges } from '../utils/spec-release-changes.js';
5459
// [#14553] The navigation-contribution group check, shared with `os compile`.
5560
// Reports; never refuses — the runtime still relocates, deliberately.
@@ -180,6 +185,12 @@ export default class Validate extends Command {
180185
// right, and `--strict` failing on it would refuse a correct stack. `os
181186
// compile` computes the identical records, so the residue pin keeps holding.
182187
let picklistReferenceNotices: ReturnType<typeof judgePicklistReferences>['notices'] = [];
188+
// ADR-0141 D2 author side (step 3e): the `dependencies` findings the
189+
// install ADMITS — an in-artifact range mismatch, or a co-delivered entry
190+
// whose range or sibling version cannot be read. Same class as the nav and
191+
// permission-set lists: reports, never refuses, NOT in `--strict`'s list,
192+
// and `os compile` computes the identical records.
193+
let dependencyRangeWarnings: DependencyRangeDiagnostic[] = [];
183194
const warningsSoFar = () => [
184195
...ruleAdvisories,
185196
...docWarnings,
@@ -200,6 +211,8 @@ export default class Validate extends Command {
200211
...jsxGateNotices,
201212
// APPENDED for the same reason, one member later again.
202213
...picklistReferenceNotices,
214+
// APPENDED for the same reason, one member later again.
215+
...dependencyRangeWarnings,
203216
];
204217
// [commit 79cf692b0] The ADR-0087 D2 conversion notices, hoisted for the SAME reason
205218
// and under the SAME ruling as the five lists above — one field over. The
@@ -791,6 +804,44 @@ export default class Validate extends Command {
791804
this.exit(1);
792805
}
793806

807+
// 3e. ADR-0141 D2, author side — every `manifest.dependencies` range of
808+
// the packages one install act would deliver, judged by the install's
809+
// own predicate (`utils/dependency-range-check.ts`). An `error` is
810+
// an entry the install refuses whatever the environment holds (an
811+
// unreadable out-of-artifact range); the in-artifact findings the
812+
// install admits ride `warningsSoFar()`. `os build` refuses the same
813+
// errors at the same step.
814+
const dependencyRanges = checkDependencyRanges(result.data);
815+
const dependencyRangeErrors = dependencyRanges.filter((d) => d.severity === 'error');
816+
dependencyRangeWarnings = dependencyRanges.filter((d) => d.severity !== 'error');
817+
if (dependencyRangeErrors.length > 0) {
818+
if (flags.json) {
819+
await emitJson({
820+
valid: false,
821+
errors: dependencyRangeErrors,
822+
warnings: warningsSoFar(),
823+
conversions: conversionNotices,
824+
duration: timer.elapsed(),
825+
});
826+
this.exit(1);
827+
}
828+
console.log('');
829+
printError(
830+
`Dependency range check failed (${dependencyRangeErrors.length} issue${dependencyRangeErrors.length > 1 ? 's' : ''})`
831+
+ ' — the install refuses every one of these',
832+
);
833+
for (const d of dependencyRangeErrors) console.log(chalk.red(` ✗ ${formatDependencyRangeDiagnostic(d)}`));
834+
this.exit(1);
835+
}
836+
if (dependencyRangeWarnings.length > 0 && !flags.json) {
837+
console.log('');
838+
printWarning(
839+
`Dependency ranges inside this artifact the install does not judge (${dependencyRangeWarnings.length})`
840+
+ ' — a package the same artifact delivers is checked for presence only',
841+
);
842+
printBulletList(dependencyRangeWarnings.map(formatDependencyRangeDiagnostic), { noun: 'dependency-range diagnostic' });
843+
}
844+
794845
// 4. Collect and display stats
795846
const stats = collectMetadataStats(config);
796847

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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

Comments
 (0)