From 464e4f183161eb0ea9385b0aa7461f5d07da6a16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 06:30:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20=E6=94=B9=E5=90=8D=20kernel=20?= =?UTF-8?q?=E4=BE=A7=20PackageDependencySchema=20=E2=86=92=20ResolvedPacka?= =?UTF-8?q?geDependencySchema=20=E2=80=94=E2=80=94=20=E5=8F=8C=E6=BA=90?= =?UTF-8?q?=E6=B8=85=E8=B4=A6=20C7,=E8=A3=B8=E5=90=8D=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=BD=92=20./cloud=20(#4741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PackageDependency` / `PackageDependencySchema` 曾由 `./cloud` 与 `./kernel` 各自 导出一个声明,键集完全不相交(0 个共享属性):cloud 侧 `{packageId, versionRange, optional}` 是作者写进包清单的**声明形**,kernel 侧 `{name, versionConstraint, type, resolvedVersion}` 是解析器在依赖图上走的**解析形**。两边都不是 `.strict()`, 把一侧文档粘到另一侧只会静默剥键而非报错(#4411 陷阱 / ADR-0104 silent-strip)。 两个概念而非两种拼写 ⇒ ADR-0112 D9(a),按维护者裁决改名 kernel 侧;cloud 侧保名。 - `RENAMED_DEFS` 承接 kernel/PackageDependency → kernel/ResolvedPackageDependency (4 keys carry:name / versionConstraint / type / resolvedVersion 全数健在) - 零 tombstone、零 ADR-0087 conversion(无 key 离开契约,已按 #4767 retiredKey() 先例逐条评估后排除);零形状变更、零词表变化、默认值原样并由 parse pin 钉住 - `PluginSecurityProtocol.PackageDependency` → `.ResolvedPackageDependency` - 同前缀近邻 PackageDependencyConflict / PackageDependencyResolutionResult 未动 - dual-source 基线 3 → 1(本簇 2 行经门禁 stale 分支点名自证删除) - 幽灵页 references/cloud/plugin-security.mdx 随 gen:docs 自愈(#4696 语境), cloud 侧 PackageDependency 归位到 package-version.mdx - compiler-API 符号身份 pin(exports map 全 16 入口枚举 + 防空转守卫 + holders 精确相等);S1/S2 sabotage 实证见 PR 三仓 import 级实测:objectstack / cloud / objectui 对 kernel 侧零外部 importer。 major(TS2305)、零元数据迁移。 Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 Co-authored-by: Claude --- .../package-dependency-dual-source-c7.md | 46 ++++ content/docs/references/cloud/meta.json | 1 - .../docs/references/cloud/package-version.mdx | 19 +- .../docs/references/cloud/plugin-security.mdx | 34 --- .../references/kernel/plugin-security.mdx | 36 +-- packages/spec/api-surface.json | 4 +- packages/spec/authorable-surface.json | 8 +- .../spec/dual-source-exports.baseline.json | 6 +- packages/spec/json-schema.manifest.json | 2 +- packages/spec/scripts/lib/renamed-defs.ts | 11 + .../spec/src/cloud/package-version.zod.ts | 7 + .../package-dependency-dual-source.test.ts | 223 ++++++++++++++++++ .../spec/src/kernel/plugin-security.zod.ts | 31 ++- 13 files changed, 353 insertions(+), 75 deletions(-) create mode 100644 .changeset/package-dependency-dual-source-c7.md delete mode 100644 content/docs/references/cloud/plugin-security.mdx create mode 100644 packages/spec/src/kernel/package-dependency-dual-source.test.ts diff --git a/.changeset/package-dependency-dual-source-c7.md b/.changeset/package-dependency-dual-source-c7.md new file mode 100644 index 0000000000..25a1749ce1 --- /dev/null +++ b/.changeset/package-dependency-dual-source-c7.md @@ -0,0 +1,46 @@ +--- +"@objectstack/spec": major +--- + +BREAKING(spec): `@objectstack/spec/kernel` 改名 `PackageDependencySchema` → `ResolvedPackageDependencySchema`;裸名 `PackageDependency(Schema)` 现在全包唯一地指 `@objectstack/spec/cloud` 的清单声明形 (#4741, #4535 C7) + +`PackageDependency` / `PackageDependencySchema` 曾由 `./cloud` 与 `./kernel` 各自导出一个声明 —— 同名、键集**完全不相交(0 个共享属性)**的**两个概念**(#4411 陷阱): + +| 入口 | 键集 | 语义 | 处置 | +|:--|:--|:--|:--| +| `./cloud`(**保名**) | `packageId` / `versionRange` / `optional` | **声明形**:作者写进包清单的依赖行,嵌在 `PackageManifestSchema.dependencies[]` → `sys_package_version.manifest_json` | 裸名唯一归属 | +| `./kernel`(**改名**) | `name` / `versionConstraint` / `type` / `resolvedVersion` | **解析形**:依赖解析器在图上走的边,嵌在 `DependencyGraphNodeSchema.dependencies[]`,并经 `PluginSecurityProtocol` 发布给 SBOM / 冲突报告 | → `ResolvedPackageDependencySchema`,字段与校验逐字不变 | + +两边都不是 `.strict()`,所以把一侧的文档粘到另一侧时**不会响亮报错,只会静默剥掉全部外来键**(ADR-0104 silent-strip 类)—— 这正是共用一个名字所掩盖的失败模式,也是本次不留任何别名的原因。 + +## FROM → TO + +```ts +// FROM —— 编译期起以 TS2305 失败 +import { PackageDependencySchema, type PackageDependency } from '@objectstack/spec/kernel'; + +// TO —— 同一声明、同一形状,名字点明它是「解析结果」而非「清单声明」 +import { + ResolvedPackageDependencySchema, + type ResolvedPackageDependency, +} from '@objectstack/spec/kernel'; +``` + +运行时命名空间对象同步改键:`PluginSecurityProtocol.PackageDependency` → `PluginSecurityProtocol.ResolvedPackageDependency`(指向同一 schema)。 + +**要的是清单里写的依赖声明?** `import { PackageDependencySchema, type PackageDependency } from '@objectstack/spec/cloud'` —— 本次未动其形状,仅补了一段互指 docblock。 + +**受影响面实测**:`objectstack` / `cloud` / `objectui` 三仓 import 级扫描,`./kernel` 侧零外部 importer(唯一读者是同文件的 `DependencyGraphNodeSchema` 与 `PluginSecurityProtocol`),预期无人受影响。零 importer 不等于有死侧可删(#4653 判则),故走改名而非删除。 + +不保留旧名别名:在 `./kernel` 上 re-export 任何一侧的 `PackageDependencySchema` 都会重开本次关闭的陷阱 —— 要么复活双源,要么把清单声明形谎报成解析器合法输入(承接表不变式 3 会在 build 阶段直接拒绝这条路线)。 + +## 零元数据迁移、零形状变更 + +本次只动 TS 导出名与内部 JSON Schema def 名(`kernel/PackageDependency` → `kernel/ResolvedPackageDependency`,走 `RENAMED_DEFS` 承接表,**4 keys carry**:`name` / `versionConstraint` / `type` / `resolvedVersion` 在新 def 名下逐个健在)。 + +- **无字段增删、无类型变更、无词表变化** —— 两个 schema 的 body 一字未改(对照 C10 的 checksum 对象→字符串、C16 的 3→5 词表拓宽:本簇均不适用)。`type` 的 `.default('required')`、cloud 侧 `optional` 的 `.default(false)` 都原样保留,并由新增的 pin 用 `parse` 实测钉住(#4666 默认值盲区的自卫)。 +- **无 tombstone**:tombstone 的前提是有 key 退役;本次 4 个 key 全数承接,一个都没离开契约,伪造 tombstone 会污染 ADR-0087 登记(`renamed-defs.ts` 头注明列的第 2 种错误处置)。已按 #4767 的 `retiredKey()` 先例逐条评估后排除,非沉默跳过。 +- **无 ADR-0087 conversion**:没有作者路径发生位移,注册迁移等于让消费者跑一次不该跑的转换。 +- 发布的 JSON Schema `$id` 随之移动:`…/kernel/PackageDependency.json` → `…/kernel/ResolvedPackageDependency.json`。 + +⚠️ 同前缀近邻 `PackageDependencyConflict(Schema)` 与 `PackageDependencyResolutionResult(Schema)` 是**不同概念**,一字未动,并由 pin 显式断言健在。 diff --git a/content/docs/references/cloud/meta.json b/content/docs/references/cloud/meta.json index baaceae978..589077dc52 100644 --- a/content/docs/references/cloud/meta.json +++ b/content/docs/references/cloud/meta.json @@ -14,7 +14,6 @@ "marketplace", "marketplace-admin", "---Tenancy & Security---", - "plugin-security", "tenant" ] } \ No newline at end of file diff --git a/content/docs/references/cloud/package-version.mdx b/content/docs/references/cloud/package-version.mdx index 08c0479841..989c8b6583 100644 --- a/content/docs/references/cloud/package-version.mdx +++ b/content/docs/references/cloud/package-version.mdx @@ -30,8 +30,8 @@ See `docs/adr/0003-package-as-first-class-citizen.md` for the full rationale. ## TypeScript Usage ```typescript -import { CreatePackageVersionRequestSchema, PackageManifestSchema, PackageVersionSchema, PackageVersionStatusSchema, PublishPackageVersionRequestSchema, UpdatePackageVersionRequestSchema } from '@objectstack/spec/cloud'; -import type { CreatePackageVersionRequest, PackageManifest, PackageVersion, PackageVersionStatus, PublishPackageVersionRequest, UpdatePackageVersionRequest } from '@objectstack/spec/cloud'; +import { CreatePackageVersionRequestSchema, PackageDependencySchema, PackageManifestSchema, PackageVersionSchema, PackageVersionStatusSchema, PublishPackageVersionRequestSchema, UpdatePackageVersionRequestSchema } from '@objectstack/spec/cloud'; +import type { CreatePackageVersionRequest, PackageDependency, PackageManifest, PackageVersion, PackageVersionStatus, PublishPackageVersionRequest, UpdatePackageVersionRequest } from '@objectstack/spec/cloud'; // Validate data const result = CreatePackageVersionRequestSchema.parse(data); @@ -55,6 +55,21 @@ Create a new draft package version | **createdBy** | `string` | ✅ | User ID creating this version | +--- + +## PackageDependency + +Package dependency declaration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **packageId** | `string` | ✅ | Manifest ID of the dependency | +| **versionRange** | `string` | ✅ | Semver version range (e.g. ^1.0.0) | +| **optional** | `boolean` | ✅ | Whether this dependency is optional | + + --- ## PackageManifest diff --git a/content/docs/references/cloud/plugin-security.mdx b/content/docs/references/cloud/plugin-security.mdx deleted file mode 100644 index 17175fb3dd..0000000000 --- a/content/docs/references/cloud/plugin-security.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Plugin Security -description: Plugin Security protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -## TypeScript Usage - -```typescript -import { PackageDependencySchema } from '@objectstack/spec/cloud'; -import type { PackageDependency } from '@objectstack/spec/cloud'; - -// Validate data -const result = PackageDependencySchema.parse(data); -``` - ---- - -## PackageDependency - -Package dependency declaration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **packageId** | `string` | ✅ | Manifest ID of the dependency | -| **versionRange** | `string` | ✅ | Semver version range (e.g. ^1.0.0) | -| **optional** | `boolean` | ✅ | Whether this dependency is optional | - - ---- - diff --git a/content/docs/references/kernel/plugin-security.mdx b/content/docs/references/kernel/plugin-security.mdx index b076ce902b..9bab36561d 100644 --- a/content/docs/references/kernel/plugin-security.mdx +++ b/content/docs/references/kernel/plugin-security.mdx @@ -32,8 +32,8 @@ Features: ## TypeScript Usage ```typescript -import { DependencyGraphSchema, DependencyGraphNodeSchema, PackageDependencySchema, PackageDependencyConflictSchema, PackageDependencyResolutionResultSchema, PluginProvenanceSchema, PluginTrustScoreSchema, SBOMSchema, SBOMEntrySchema, SecurityPolicySchema, SecurityScanResultSchema, SecurityVulnerabilitySchema, VulnerabilitySeverity } from '@objectstack/spec/kernel'; -import type { DependencyGraph, DependencyGraphNode, PackageDependency, PackageDependencyConflict, PackageDependencyResolutionResult, PluginProvenance, PluginTrustScore, SBOM, SBOMEntry, SecurityPolicy, SecurityScanResult, SecurityVulnerability, VulnerabilitySeverity } from '@objectstack/spec/kernel'; +import { DependencyGraphSchema, DependencyGraphNodeSchema, PackageDependencyConflictSchema, PackageDependencyResolutionResultSchema, PluginProvenanceSchema, PluginTrustScoreSchema, ResolvedPackageDependencySchema, SBOMSchema, SBOMEntrySchema, SecurityPolicySchema, SecurityScanResultSchema, SecurityVulnerabilitySchema, VulnerabilitySeverity } from '@objectstack/spec/kernel'; +import type { DependencyGraph, DependencyGraphNode, PackageDependencyConflict, PackageDependencyResolutionResult, PluginProvenance, PluginTrustScore, ResolvedPackageDependency, SBOM, SBOMEntry, SecurityPolicy, SecurityScanResult, SecurityVulnerability, VulnerabilitySeverity } from '@objectstack/spec/kernel'; // Validate data const result = DependencyGraphSchema.parse(data); @@ -73,22 +73,6 @@ A node in the dependency graph representing a resolved package | **metadata** | `{ name: string; description?: string; license?: string; homepage?: string }` | optional | Additional metadata about the package | ---- - -## PackageDependency - -A package dependency with its version constraint - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Package name or identifier | -| **versionConstraint** | `string` | ✅ | Semver range (e.g., `^1.0.0`, `>=2.0.0 <3.0.0`) | -| **type** | `Enum<'required' \| 'optional' \| 'peer' \| 'dev'>` | ✅ | Category of the dependency relationship | -| **resolvedVersion** | `string` | optional | Concrete version resolved during dependency resolution | - - --- ## PackageDependencyConflict @@ -159,6 +143,22 @@ Trust score and verification status for a plugin | **updatedAt** | `string` | ✅ | ISO 8601 timestamp when the trust score was last updated | +--- + +## ResolvedPackageDependency + +A resolver-side package dependency: version constraint plus its resolution outcome + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Package name or identifier | +| **versionConstraint** | `string` | ✅ | Semver range (e.g., `^1.0.0`, `>=2.0.0 <3.0.0`) | +| **type** | `Enum<'required' \| 'optional' \| 'peer' \| 'dev'>` | ✅ | Category of the dependency relationship | +| **resolvedVersion** | `string` | optional | Concrete version resolved during dependency resolution | + + --- ## SBOM diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index d8afcfc61c..285103f6f8 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1665,12 +1665,10 @@ "PackageArtifact (type)", "PackageArtifactInput (type)", "PackageArtifactSchema (const)", - "PackageDependency (type)", "PackageDependencyConflict (type)", "PackageDependencyConflictSchema (const)", "PackageDependencyResolutionResult (type)", "PackageDependencyResolutionResultSchema (const)", - "PackageDependencySchema (const)", "PackageStatus (type)", "PackageStatusEnum (const)", "PermissionAction (type)", @@ -1798,6 +1796,8 @@ "RequiredActionSchema (const)", "ResolvedDependency (type)", "ResolvedDependencySchema (const)", + "ResolvedPackageDependency (type)", + "ResolvedPackageDependencySchema (const)", "ResourceType (type)", "ResourceTypeSchema (const)", "RollbackPackageRequest (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 663c355f37..c7b8e8bd83 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -4679,10 +4679,6 @@ "kernel/PackageArtifact:signature", "kernel/PackageArtifact:size", "kernel/PackageArtifact:version", - "kernel/PackageDependency:name", - "kernel/PackageDependency:resolvedVersion", - "kernel/PackageDependency:type", - "kernel/PackageDependency:versionConstraint", "kernel/PackageDependencyConflict:conflicts", "kernel/PackageDependencyConflict:package", "kernel/PackageDependencyConflict:resolution", @@ -4998,6 +4994,10 @@ "kernel/ResolvedDependency:requiredRange", "kernel/ResolvedDependency:resolvedVersion", "kernel/ResolvedDependency:status", + "kernel/ResolvedPackageDependency:name", + "kernel/ResolvedPackageDependency:resolvedVersion", + "kernel/ResolvedPackageDependency:type", + "kernel/ResolvedPackageDependency:versionConstraint", "kernel/RollbackPackageRequest:packageId", "kernel/RollbackPackageRequest:rollbackCustomizations", "kernel/RollbackPackageRequest:snapshotId", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index f1586f91f4..fcf76b4869 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -1,8 +1,6 @@ { - "_comment": "Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more public entry points export for DIFFERENT declarations, so which type a consumer gets depends on the import path \u2014 the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of one declaration from many entries is fine and not listed). A NEW name here fails check:dual-source-exports: converge on one declaration and re-export it, or rename one side \u2014 growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that stops being dual-source fails until its line is deleted. Regenerate with: tsx scripts/check-dual-source-exports.ts --update (after pnpm build).", + "_comment": "Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more public entry points export for DIFFERENT declarations, so which type a consumer gets depends on the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of one declaration from many entries is fine and not listed). A NEW name here fails check:dual-source-exports: converge on one declaration and re-export it, or rename one side — growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that stops being dual-source fails until its line is deleted. Regenerate with: tsx scripts/check-dual-source-exports.ts --update (after pnpm build).", "entries": [ - "ActionLocationSchema \u2014 [./studio (const)] \u2260 [./ui (const)]", - "PackageDependency \u2014 [./cloud (type)] \u2260 [./kernel (type)]", - "PackageDependencySchema \u2014 [./cloud (const)] \u2260 [./kernel (const)]" + "ActionLocationSchema — [./studio (const)] ≠ [./ui (const)]" ] } diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index fa3fc74f2d..3c4266ca49 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -980,7 +980,6 @@ "kernel/OpsFilePath", "kernel/OpsPluginStructure", "kernel/PackageArtifact", - "kernel/PackageDependency", "kernel/PackageDependencyConflict", "kernel/PackageDependencyResolutionResult", "kernel/PackageStatusEnum", @@ -1039,6 +1038,7 @@ "kernel/RealTimeNotificationConfig", "kernel/RequiredAction", "kernel/ResolvedDependency", + "kernel/ResolvedPackageDependency", "kernel/ResourceType", "kernel/RollbackPackageRequest", "kernel/RollbackPackageResponse", diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index 07d61ce29f..d25a7d6be0 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -79,6 +79,17 @@ export const RENAMED_DEFS: Readonly> = { // distinct concept (client/server offline sync) and the only side with // cross-repo consumers (objectui useOffline + re-export + parity ratchet). 'integration/ConflictResolution': 'integration/ConnectorConflictResolution', + + // #4741 / ADR-0112 D9a — `PackageDependency` was published by TWO defs whose + // key sets are ENTIRELY disjoint (zero shared properties): `cloud` is the + // DECLARATION form an author writes into a package manifest + // (`packageId` / `versionRange` / `optional`), `kernel` is the RESOLVER form + // the dependency graph walks (`name` / `versionConstraint` / `type` / + // `resolvedVersion`). Two concepts, not two spellings. The resolver side + // takes the descriptive name; `cloud/PackageDependency` keeps the bare one + // and is deliberately absent from this table — it is neither source nor + // target, and it is emitted byte-for-byte unchanged by this build. + 'kernel/PackageDependency': 'kernel/ResolvedPackageDependency', // 4 keys carried }; /** diff --git a/packages/spec/src/cloud/package-version.zod.ts b/packages/spec/src/cloud/package-version.zod.ts index 6f36280657..32854243a8 100644 --- a/packages/spec/src/cloud/package-version.zod.ts +++ b/packages/spec/src/cloud/package-version.zod.ts @@ -44,6 +44,13 @@ export type PackageVersionStatus = z.infer; /** * A single dependency declared in a package manifest. * Follows npm-style `"package_id": "version_range"` semantics. + * + * This is the DECLARATION-side shape — what an author writes into + * `manifestJson.dependencies[]`. The resolver-side shape (a constraint plus + * the `resolvedVersion` it resolved to) is a different concept and lives in + * `@objectstack/spec/kernel` as `ResolvedPackageDependency`; before #4741 it + * also went by `PackageDependency`, so which type a consumer got depended on + * nothing but the import path (#4411). The bare name is now this one alone. */ export const PackageDependencySchema = lazySchema(() => z.object({ /** Manifest ID of the required package (e.g. `com.objectstack.core`). */ diff --git a/packages/spec/src/kernel/package-dependency-dual-source.test.ts b/packages/spec/src/kernel/package-dependency-dual-source.test.ts new file mode 100644 index 0000000000..5eeb66dfff --- /dev/null +++ b/packages/spec/src/kernel/package-dependency-dual-source.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +// ─── [#4741] `PackageDependency` has ONE owner per name (./kernel renamed) ─── +// +// Dual-source ledger #4535, cluster C7 — the last cluster of the second batch. +// Before this change `PackageDependency` / `PackageDependencySchema` were each +// exported by TWO entry points for TWO different declarations: +// +// ./cloud — the DECLARATION form an author writes into a package manifest: +// `{ packageId, versionRange, optional }`, embedded in +// `PackageManifestSchema.dependencies[]` → `sys_package_version.manifest_json`; +// ./kernel — the RESOLVER form the dependency resolver walks: +// `{ name, versionConstraint, type, resolvedVersion }`, embedded in +// `DependencyGraphNodeSchema.dependencies[]` and published through +// `PluginSecurityProtocol`. +// +// The two key sets are ENTIRELY DISJOINT — zero shared properties. Neither +// schema is `.strict()`, so a document copied from one side to the other +// parsed "clean" with every foreign key silently stripped and every required +// key defaulted or missing: the #4411 trap in its most dangerous form (ADR-0104 +// silent-strip class). Two concepts, not two spellings ⇒ ADR-0112 D9(a), route +// ruled by the maintainer on #4741: the KERNEL side is renamed +// `ResolvedPackageDependency(Schema)`, the cloud side keeps the bare name. +// `RENAMED_DEFS` carries `kernel/PackageDependency` → +// `kernel/ResolvedPackageDependency` (4 authorable keys, all four still +// present under the new def ⇒ zero tombstone, zero ADR-0087 conversion). +// +// #4642 established that a compile-time conditional-type pin in this package is +// a no-op (tsconfig excludes `**/*.test.ts`; vitest never enables `typecheck`), +// so the load-bearing pin is the compiler-API test below, with anti-vacuity +// guards. Sabotage-verified in the PR: S1 resurrecting the old kernel const, +// and S2 re-exporting the cloud declaration from ./kernel under the bare name +// (green to the dual-source gate — one declaration, many entries — but a lie +// to anyone reading `./kernel`, and independently rejected at BUILD time by +// RENAMED_DEFS invariant 3, "the source def is still emitted"). +describe('[#4741] PackageDependency dual-source retirement (C7)', () => { + it('resolves the export surface: one owner per name, across every public entry', async () => { + const ts = (await import('typescript')).default; + const { resolve, relative, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const { readFileSync } = await import('node:fs'); + + const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + // Every public entry point, read from package.json's exports map so a + // future entry cannot silently escape the uniqueness pins below. + const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as { + exports: Record; + }; + const entries: Record = {}; + for (const sub of Object.keys(pkg.exports)) { + if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts'); + else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`); + // './openapi.json' / './package.json' are not TypeScript entry points. + } + // Anti-vacuity: the enumeration must have found the real surface. + for (const needed of ['./cloud', './kernel']) { + expect(Object.keys(entries), `exports map must include ${needed}`).toContain(needed); + } + expect(Object.keys(entries).length).toBeGreaterThan(10); + + const program = ts.createProgram(Object.values(entries), { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + noEmit: true, + }); + const checker = program.getTypeChecker(); + const unalias = (s: import('typescript').Symbol) => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + + const exportsOf = (sub: string) => { + const sf = program.getSourceFile(entries[sub]); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + // Without this guard a resolution failure would make every assertion + // below pass vacuously — the exact way a gate goes dormant (#4642). + expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy(); + return checker.getExportsOfModule(moduleSym!); + }; + + const originOf = (sym: import('typescript').Symbol, label: string) => { + const decl = unalias(sym).declarations?.[0]; + expect(decl, `${label} must have a declaration`).toBeTruthy(); + const declFile = decl!.getSourceFile(); + return `${relative(specDir, declFile.fileName)}:${ + declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1 + }`; + }; + + /** Every entry that exports `name`, with each occurrence's declaration origin. */ + const holdersOf = (name: string) => { + const out: Array<{ sub: string; origin: string }> = []; + for (const sub of Object.keys(entries)) { + for (const sym of exportsOf(sub).filter((e) => e.getName() === name)) { + out.push({ sub, origin: originOf(sym, `${sub} ${name}`) }); + } + } + return out; + }; + + // 1. The renamed-away side: `./kernel` still has a non-trivial surface — so + // the `not.toContain` below cannot pass by resolving nothing — and no + // longer names the old export, while surviving neighbours stand. The two + // same-prefix neighbours are the trap this cluster was warned about: + // `PackageDependencyConflict` and `PackageDependencyResolutionResult` + // are DIFFERENT concepts that merely share a prefix, and must be intact. + const kernelNames = exportsOf('./kernel').map((e) => e.getName()); + expect(kernelNames.length, './kernel must export a non-trivial surface').toBeGreaterThan(40); + for (const retired of ['PackageDependencySchema', 'PackageDependency']) { + expect(kernelNames, `./kernel must not export ${retired}`).not.toContain(retired); + } + for (const neighbour of [ + 'PackageDependencyConflictSchema', + 'PackageDependencyConflict', + 'PackageDependencyResolutionResultSchema', + 'PackageDependencyResolutionResult', + 'DependencyGraphNodeSchema', + 'PluginSecurityProtocol', + ]) { + expect(kernelNames, `./kernel must still export ${neighbour}`).toContain(neighbour); + } + + // 2. The renamed side: `ResolvedPackageDependency(Schema)` originates in + // kernel/plugin-security.zod.ts and is exported by ./kernel alone — the + // rename must not fan out into a second entry. + for (const name of ['ResolvedPackageDependencySchema', 'ResolvedPackageDependency']) { + const holders = holdersOf(name); + expect(holders.length, `${name} must be exported (by ./kernel)`).toBeGreaterThan(0); + for (const h of holders) { + expect(h.sub, `${name} must only be exported by ./kernel`).toBe('./kernel'); + expect(h.origin).toMatch(/^src\/kernel\/plugin-security\.zod\.ts:\d+$/); + } + } + + // 3. The bare `PackageDependency(Schema)` now has exactly ONE owner: + // ./cloud, declared in cloud/package-version.zod.ts. Not merely "the + // same declaration everywhere" — NO other entry may export the bare name + // at all. A re-export from ./kernel would share the declaration (green + // to the dual-source gate, which judges by symbol identity) while + // telling resolver-side consumers that `versionConstraint` and + // `resolvedVersion` are fields of the manifest declaration form. That is + // the C14/C15/C17 lesson: a re-export can lie about the domain even when + // the symbol itself is honest. Exact equality, not a subset check. + for (const name of ['PackageDependencySchema', 'PackageDependency']) { + const holders = holdersOf(name); + expect(holders.map((h) => h.sub), `${name} must be owned by ./cloud alone`).toEqual(['./cloud']); + expect(holders[0].origin).toMatch(/^src\/cloud\/package-version\.zod\.ts:\d+$/); + } + }); + + it('keeps the runtime namespaces consistent with the compiler view', async () => { + const kernel = await import('./index'); + const cloud = await import('../cloud/index'); + + // Renamed-away side — the old const is gone from ./kernel at runtime too. + expect('PackageDependencySchema' in kernel, 'kernel must not export PackageDependencySchema').toBe(false); + // Anti-vacuity: the namespace we just probed is real and non-trivial, and + // the same-prefix neighbours survived. + expect('DependencyGraphNodeSchema' in kernel).toBe(true); + expect('PackageDependencyConflictSchema' in kernel).toBe(true); + expect('PackageDependencyResolutionResultSchema' in kernel).toBe(true); + + // Renamed side — the resolver vocabulary, byte-for-byte unchanged. + expect('ResolvedPackageDependencySchema' in kernel).toBe(true); + const resolved = kernel.ResolvedPackageDependencySchema.parse({ + name: 'com.objectstack.core', + versionConstraint: '^1.0.0', + }); + expect(resolved).toEqual({ + name: 'com.objectstack.core', + versionConstraint: '^1.0.0', + type: 'required', // the `.default('required')` still applies + }); + + // The namespace object published for SBOM / conflict reporting follows the + // rename — it must not keep handing out the resolver schema under the name + // this change gave back to ./cloud. + expect('PackageDependency' in kernel.PluginSecurityProtocol).toBe(false); + expect(kernel.PluginSecurityProtocol.ResolvedPackageDependency).toBe( + kernel.ResolvedPackageDependencySchema, + ); + + // cloud side — untouched shape, and still the manifest declaration form. + expect('PackageDependencySchema' in cloud).toBe(true); + const declared = cloud.PackageDependencySchema.parse({ + packageId: 'com.objectstack.core', + versionRange: '^1.0.0', + }); + expect(declared).toEqual({ + packageId: 'com.objectstack.core', + versionRange: '^1.0.0', + optional: false, // the `.default(false)` still applies + }); + }); + + it('proves the two shapes were never interchangeable — the silent-strip trap', async () => { + const { ResolvedPackageDependencySchema } = await import('./plugin-security.zod'); + const { PackageDependencySchema } = await import('../cloud/package-version.zod'); + + // Zero shared keys: this is WHY the name had to move rather than converge. + // If a future edit ever makes the key sets overlap, this assertion fails + // and the "two concepts" premise of #4741 has to be re-argued. + const keysOf = (s: { _zod: { def: { shape: Record } } }) => + new Set(Object.keys(s._zod.def.shape)); + const kernelKeys = keysOf(ResolvedPackageDependencySchema as never); + const cloudKeys = keysOf(PackageDependencySchema as never); + expect([...kernelKeys].sort()).toEqual(['name', 'resolvedVersion', 'type', 'versionConstraint']); + expect([...cloudKeys].sort()).toEqual(['optional', 'packageId', 'versionRange']); + expect([...kernelKeys].filter((k) => cloudKeys.has(k)), 'key sets must stay disjoint').toEqual([]); + + // Neither side is `.strict()`, so cross-pasting a document does not throw — + // it silently strips. That is exactly the failure mode the shared name hid, + // and it is why the pin above forbids a "helpful" re-export alias. + const manifestDoc = { packageId: 'com.objectstack.core', versionRange: '^1.0.0', optional: true }; + const strippedByResolver = ResolvedPackageDependencySchema.safeParse(manifestDoc); + expect(strippedByResolver.success, 'resolver form rejects a manifest doc only on required keys').toBe(false); + + const resolverDoc = { name: 'com.objectstack.core', versionConstraint: '^1.0.0', type: 'peer' as const }; + const strippedByManifest = PackageDependencySchema.safeParse(resolverDoc); + expect(strippedByManifest.success, 'manifest form rejects a resolver doc only on required keys').toBe(false); + }); +}); diff --git a/packages/spec/src/kernel/plugin-security.zod.ts b/packages/spec/src/kernel/plugin-security.zod.ts index 017b2e6782..2d6ccb2fa2 100644 --- a/packages/spec/src/kernel/plugin-security.zod.ts +++ b/packages/spec/src/kernel/plugin-security.zod.ts @@ -305,31 +305,44 @@ export type SecurityPolicy = z.infer; // ============================================================================ /** - * Package Dependency + * Resolved Package Dependency — one edge of the resolver's dependency graph. + * + * This is the RESOLVER-side shape: what the dependency resolver works with + * while it walks a graph (`DependencyGraphNodeSchema.dependencies`) and what + * `PluginSecurityProtocol` publishes for SBOM/conflict reporting. It carries + * the resolution outcome (`resolvedVersion`) alongside the constraint. + * + * Do NOT confuse it with `PackageDependency` from `@objectstack/spec/cloud` + * (`cloud/package-version.zod.ts`), which is the DECLARATION-side shape an + * author writes into a package manifest (`packageId` / `versionRange` / + * `optional`). Until #4741 both were named `PackageDependency` and the two + * key sets are entirely disjoint, so which type a consumer got depended on + * nothing but the import path — the #4411 trap. ADR-0112 D9(a) renamed this, + * the resolver side; the manifest side keeps the bare name. */ -export const PackageDependencySchema = lazySchema(() => z.object({ +export const ResolvedPackageDependencySchema = lazySchema(() => z.object({ /** * Package name/ID */ name: z.string().describe('Package name or identifier'), - + /** * Version constraint (semver range) */ versionConstraint: z.string().describe('Semver range (e.g., `^1.0.0`, `>=2.0.0 <3.0.0`)'), - + /** * Dependency type */ type: z.enum(['required', 'optional', 'peer', 'dev']).default('required').describe('Category of the dependency relationship'), - + /** * Resolved version (filled during resolution) */ resolvedVersion: z.string().optional().describe('Concrete version resolved during dependency resolution'), -}).describe('A package dependency with its version constraint')); +}).describe('A resolver-side package dependency: version constraint plus its resolution outcome')); -export type PackageDependency = z.infer; +export type ResolvedPackageDependency = z.infer; /** * Dependency Graph Node @@ -348,7 +361,7 @@ export const DependencyGraphNodeSchema = lazySchema(() => z.object({ /** * Dependencies of this package */ - dependencies: z.array(PackageDependencySchema).default([]).describe('Dependencies required by this package'), + dependencies: z.array(ResolvedPackageDependencySchema).default([]).describe('Dependencies required by this package'), /** * Depth in dependency tree @@ -758,7 +771,7 @@ export const PluginSecurityProtocol = { SecurityVulnerability: SecurityVulnerabilitySchema, SecurityScanResult: SecurityScanResultSchema, SecurityPolicy: SecurityPolicySchema, - PackageDependency: PackageDependencySchema, + ResolvedPackageDependency: ResolvedPackageDependencySchema, DependencyGraphNode: DependencyGraphNodeSchema, DependencyGraph: DependencyGraphSchema, DependencyConflict: PackageDependencyConflictSchema,