From 7021420f2918686cbad49edd2935ed04791c200a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:36:05 +0800 Subject: [PATCH] fix: validate dependency ranges across every workspace --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 6 +- .../2026-08-10-npm-release-sequences.zh.md | 6 +- AGENTS.md | 2 +- docs/rescope.i18n.yaml | 4 +- docs/rescope.md | 2 +- docs/rescope.zh.md | 2 +- packages/AGENTS.md | 1 - scripts/check-workspace-constraints.spec.ts | 108 ++++++++++++------ scripts/check-workspace-constraints.ts | 60 +++++++--- 10 files changed, 130 insertions(+), 65 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index d025f4ff2d..04ee7a1af7 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: c07f5264ce4148e5d49cb8e5e20997f92ca5b596 -2026-08-10-npm-release-sequences.zh.md: 7c5d01b9d6ffb806f3babeb9d423d36fa4ecb202 +2026-08-10-npm-release-sequences.md: da80904cb8ac0f2e06ee168c130f58d39e81da05 +2026-08-10-npm-release-sequences.zh.md: 7d8040d9d9f0653914934293bca7c54300feca16 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index c07f5264ce..da80904cb8 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -78,9 +78,9 @@ Two registry behaviours shape how a publish is attempted. Writes are spaced by a ### Workspace-internal references use the `workspace:` protocol -Every reference to a workspace member uses the `workspace:` protocol. References from `packages/*/*` to DSH packages use `workspace:*`, including peers and development dependencies, so `pnpm pack` writes the target's exact release version. Vendored references, including Cordis, retain `workspace:^` and follow their independent version lines. App and native manifests retain their declared ranges; Landlock platform references remain exact. Local workspace linking is unchanged. +Every reference to a workspace member uses the `workspace:` protocol. All references to DSH packages use `workspace:*`, including peers and development dependencies, so `pnpm pack` writes the target's exact release version. This rule covers every consumer, including the repository root, apps, benchmarks, and Python runtime. Vendored references, including Cordis, use `workspace:^` and follow their independent version lines. References to native packages retain their declared ranges; Landlock platform references remain exact. Local workspace linking is unchanged. -`scripts/check-workspace-constraints.ts` requires the protocol and exact DSH-to-DSH package ranges. The invariant-companion rule requires `workspace:*` for `@deepseek-ai/dsh-invariants`; the dependency repairer preserves vendored caret ranges. Published DSH peers therefore require the matching release instead of admitting later compatible versions. +`scripts/check-workspace-constraints.ts` reads every member declared in `pnpm-workspace.yaml` plus the root manifest and enforces ranges by dependency target, not consumer directory. The invariant-companion rule requires `workspace:*` for `@deepseek-ai/dsh-invariants`; the dependency repairer preserves vendored caret ranges. Published DSH peers therefore require the matching release instead of admitting later compatible versions. ### Published dependency faces use an explicit policy @@ -133,7 +133,7 @@ The installed-consumer probe captures npm's HTTP diagnostics and includes them w |---|---| | release-set manifests | `private: true` removed; `publishConfig.access` per sequence and `repository` with each package's `directory` added | | release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*` | -| dependency protocol | workspace references use the protocol; DSH-to-DSH package references and invariant companions use `workspace:*`, while vendor ranges remain independent | +| dependency protocol | every workspace consumer uses `workspace:*` for DSH targets and `workspace:^` for vendor targets | | root `AGENTS.md` | the convention that vendored packages are `private: true` no longer holds | | `vendor/README.md` | records `src` joining `cordis`'s `files` as a local modification | | the three native packages | `publishConfig.access: public`, and their workflow passes no `--access` | diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index 7c5d01b9d6..7d8040d9d9 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -78,9 +78,9 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 ### workspace 内部引用走 `workspace:` 协议 -所有指向 workspace 成员的引用都用 `workspace:` 协议。`packages/*/*` 指向 DSH 包的引用使用 `workspace:*`,包括 peer 和开发依赖,由 `pnpm pack` 写入目标的精确发布版本。包括 Cordis 在内的 vendored 引用保留 `workspace:^`,跟随各自独立的版本线。App 和 native 清单保留已声明的范围;Landlock 平台引用仍为精确版本。本地 workspace 链接方式不变。 +所有指向 workspace 成员的引用都用 `workspace:` 协议。所有指向 DSH 包的引用使用 `workspace:*`,包括 peer 和开发依赖,由 `pnpm pack` 写入目标的精确发布版本。这条规则覆盖所有消费者,包括仓库根清单、应用、benchmarks 和 Python 运行时。包括 Cordis 在内的 vendored 引用使用 `workspace:^`,跟随各自独立的版本线。指向 native 包的引用保留已声明的范围;Landlock 平台引用仍为精确版本。本地 workspace 链接方式不变。 -`scripts/check-workspace-constraints.ts` 要求使用该协议,并精确锁定 DSH 包之间的引用。Invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 使用 `workspace:*`;依赖修复器保留 vendored caret 范围。因此,发布的 DSH peer 要求匹配的发布版本,而不接纳后续兼容版本。 +`scripts/check-workspace-constraints.ts` 读取 `pnpm-workspace.yaml` 声明的全部成员及根清单,按依赖目标而非消费者目录校验范围。Invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 使用 `workspace:*`;依赖修复器保留 vendored caret 范围。因此,发布的 DSH peer 要求匹配的发布版本,而不接纳后续兼容版本。 ### 发布依赖门面使用显式策略 @@ -133,7 +133,7 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven |---|---| | 发布集 manifest | 去掉 `private: true`;按序列补 `publishConfig.access` 与带各自 `directory` 的 `repository` | | 发布集边界 | `packages/*/*`、`apps/*`、`vendor/*` 的全部成员 | -| 依赖协议 | workspace 引用使用该协议;DSH 包之间的引用及 invariant companion 使用 `workspace:*`,vendor 范围保持独立 | +| 依赖协议 | 每个 workspace 消费者对 DSH 目标使用 `workspace:*`,对 vendor 目标使用 `workspace:^` | | 根 `AGENTS.md` | 「vendored 包是 `private: true`」这条约定不再成立 | | `vendor/README.md` | 记录「`src` 加入 `cordis` 的 `files`」这条本地修改 | | native 三包 | `publishConfig.access: public`,且其 workflow 不传 `--access` | diff --git a/AGENTS.md b/AGENTS.md index aa83ae8c08..b9083dc1cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and ## Conventions -- Every npm package is `@deepseek-ai/dsh-`; vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package. +- Packages use `@deepseek-ai/dsh-`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor `workspace:^` ([rules](.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md#workspace-internal-references-use-the-workspace-protocol)). - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Publish `./invariant` only when independent observations can diverge. Otherwise omit its source and wiring and record why in its README; empty installers and checks of service presence, plugin metadata, effects, or fixed examples are invalid ([package invariant rules](packages/AGENTS.md)). diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index e55fd71381..750cbfa26c 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/rescope.md -rescope.md: 3779001ed2dfb1acc7969a92c716dce6c389376d -rescope.zh.md: 881ef4192f6c617eefe8b56234aaae1d6acc7f5b +rescope.md: 7213077966a6e01f3f1123036645cc8de821484e +rescope.zh.md: 1b898662e8eb2fae72ae28ff1c2b1b1dd03d09e2 diff --git a/docs/rescope.md b/docs/rescope.md index 3779001ed2..7213077966 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -23,7 +23,7 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@ ## What the rename does not touch - **Directory names and upstream source versions.** `vendor/hmr/` stays `vendor/hmr/`, and the table records the upstream version of the pinned source snapshot, so the manifest reads as an upstream snapshot; the vendored `package.json`'s own `version` field is the harness's released manifest version, which `pnpm run release:vendor` bumps and a re-sync restores to the upstream version. -- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use the `workspace:` protocol; [package rules](../packages/AGENTS.md) distinguish exact DSH references from vendored caret ranges. +- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use the `workspace:` protocol; [repository rules](../AGENTS.md#conventions) distinguish exact DSH references from vendored caret ranges. - **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name. - **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`. - **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`. diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index 881ef4192f..1b898662e8 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -23,7 +23,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R ## 改名不碰什么 - **目录名与上游源码版本。** `vendor/hmr/` 仍是 `vendor/hmr/`,清单表记录的是所钉住源码快照的上游版本,因此清单读作一份上游快照;而每个 vendored 包 `package.json` 自身的 `version` 字段是 harness 发布的清单版本,`pnpm run release:vendor` 会提升它,重新 sync 时会恢复成上游版本。 -- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单使用 `workspace:` 协议;[包规则](../packages/AGENTS.md)区分精确的 DSH 引用与 vendored caret 范围。 +- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单使用 `workspace:` 协议;[仓库规则](../AGENTS.md#conventions)区分精确的 DSH 引用与 vendored caret 范围。 - **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。 - **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。 - **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 4a47b1d9d4..f0c49d55e5 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -20,7 +20,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): -- **Workspace ranges:** references from `packages/*/*` to DSH packages use `workspace:*` in every dependency section, including peers and development dependencies. Vendor and native ranges retain their existing policy; Cordis stays `workspace:^` ([publication rules](../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md#workspace-internal-references-use-the-workspace-protocol)). - **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), sets `rootDir: src` and `outDir: lib/types`, references workspace dependencies, references `runtime-diagnostics/invariants` only when the package publishes `./invariant`, and registers in one aggregate. Packages with distinct Host and Client compiler faces use `tsconfig.host.json` and `tsconfig.client.json` leaves plus a solution-only root; ordinary two-entry Client plugins do not split ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 21cb3124de..3a293b2863 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -1,7 +1,9 @@ -/** Experimental-package publication and dependency constraints. */ +/** Workspace dependency ranges and package publication constraints. */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, onTestFinished } from 'vitest' import { isPublicExperimentalPackageDirectory, PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES, @@ -13,6 +15,7 @@ import { checkExperimentalDependencyIsolation, checkExperimentalManifest, expectedDshPackageFiles, + readWorkspaceManifests, type WorkspaceManifest, } from './check-workspace-constraints.ts' @@ -26,45 +29,84 @@ const experimental = { describe('workspace dependency ranges', () => { const dependency = { dir: 'packages/core/runtime', manifest: { name: '@deepseek-ai/dsh-runtime' } } + const cli = { dir: 'apps/cli', manifest: { name: '@deepseek-ai/dsh' } } const vendor = { dir: 'vendor/cordis', manifest: { name: '@deepseek-ai/cordis' } } const native = { dir: 'native/system', manifest: { name: '@deepseek-ai/node-addon-system' } } - it.each(['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const)( - 'pins DSH %s while preserving vendor and native ranges', - (section) => { - for (const dir of ['packages/core/probe', 'packages/experimental/probe']) { - const consumer = (range: string): WorkspaceManifest => ({ - dir, - manifest: { name: '@deepseek-ai/dsh-probe', [section]: { - '@deepseek-ai/dsh-runtime': range, - '@deepseek-ai/cordis': 'workspace:^', - '@deepseek-ai/node-addon-system': 'workspace:^', - external: '^1.2.3', - } }, + describe.each([ + '.', 'packages/core/probe', 'packages/experimental/probe', 'apps/cli', 'apps/web', + 'apps/desktop', 'apps/desktop-host', 'benchmarks', 'website', 'python/sdk-runtime', 'tools/probe', + ])('consumer %s', (dir) => { + it.each(['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const)( + 'requires exact DSH and caret vendor %s independently of the consumer name', + (section) => { + const consumer = (name: string, range: string): WorkspaceManifest => ({ + dir, manifest: { name: 'consumer', [section]: { [name]: range } }, }) - expect(checkWorkspaceProtocol([dependency, vendor, native, consumer('workspace:*')])).toEqual([]) - for (const range of ['workspace:^', 'workspace:~', 'workspace:^4.0.3', '^4.0.3']) { - expect(checkWorkspaceProtocol([dependency, vendor, native, consumer(range)])).toEqual([ - `@deepseek-ai/dsh-probe: ${section}.@deepseek-ai/dsh-runtime must use workspace:*, got ${range}`, + const check = (name: string, range: string): string[] => + checkWorkspaceProtocol([dependency, cli, vendor, native, consumer(name, range)]) + for (const name of ['@deepseek-ai/dsh', '@deepseek-ai/dsh-runtime']) { + expect(check(name, 'workspace:*')).toEqual([]) + for (const range of ['workspace:^', 'workspace:~', 'workspace:^0.1.7', '^0.1.7', '*']) { + expect(check(name, range)).toEqual([ + `consumer: ${section}.${name} must use workspace:*, got ${range}`, + ]) + } + } + expect(check('@deepseek-ai/cordis', 'workspace:^')).toEqual([]) + for (const range of ['workspace:*', 'workspace:~', '^4.0.3']) { + expect(check('@deepseek-ai/cordis', range)).toEqual([ + `consumer: ${section}.@deepseek-ai/cordis must use workspace:^, got ${range}`, ]) } - } + for (const range of ['workspace:*', 'workspace:^']) { + expect(check('@deepseek-ai/node-addon-system', range)).toEqual([]) + } + expect(check('@deepseek-ai/node-addon-system', '^0.1.0')).toEqual([ + `consumer: ${section}.@deepseek-ai/node-addon-system must use the workspace: protocol, got ^0.1.0`, + ]) + expect(check('external', '^1.2.3')).toEqual([]) + }, + ) + }) +}) + +describe('workspace manifest discovery', () => { + it('checks root, app, runtime, tooling, and newly declared members while honoring exclusions', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-workspace-ranges-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + const consumers = ['.', 'apps/cli', 'apps/web', 'apps/desktop', 'apps/desktop-host', 'benchmarks', 'website', 'python/sdk-runtime', 'tools/probe'] + writeFileSync(join(root, 'pnpm-workspace.yaml'), [ + 'packages:', ' - packages/*/*', ' - apps/*', ' - apps/cli', ' - benchmarks', + ' - website', ' - python/sdk-runtime', ' - tools/*', ' - "!tools/excluded"', + ].join('\n')) + for (const dir of [...consumers, 'tools/excluded', 'unlisted/probe', 'packages/core/runtime']) { + mkdirSync(join(root, dir), { recursive: true }) + writeFileSync(join(root, dir, 'package.json'), JSON.stringify(dir === 'packages/core/runtime' + ? { name: '@deepseek-ai/dsh-runtime' } + : { dependencies: { '@deepseek-ai/dsh-runtime': 'workspace:^' } })) + } + const manifests = readWorkspaceManifests(root) + expect(manifests.map(entry => entry.dir).sort()).toEqual([...consumers, 'packages/core/runtime'].sort()) + expect(checkWorkspaceProtocol(manifests).sort()).toEqual(consumers.map(dir => + `${dir}: dependencies.@deepseek-ai/dsh-runtime must use workspace:*, got workspace:^`).sort()) + }) + + it.each(['', 'null', '{}', 'packages: []', 'packages: [false]', 'packages: [""]', 'packages: wrong'])( + 'rejects an invalid workspace declaration: %s', (contents) => { + const root = mkdtempSync(join(tmpdir(), 'dsh-workspace-ranges-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + writeFileSync(join(root, 'pnpm-workspace.yaml'), contents) + expect(() => readWorkspaceManifests(root)).toThrow('packages must be a non-empty list of workspace patterns') }, ) - it.each(['.', 'apps/cli', 'vendor/probe', 'native/system', 'python/sdk-runtime'])( - 'retains workspace range choices outside packages: %s', - (dir) => { - const consumer = (range: string): WorkspaceManifest => ({ - dir, manifest: { dependencies: { '@deepseek-ai/dsh-runtime': range } }, - }) - expect(checkWorkspaceProtocol([dependency, consumer('workspace:^')])).toEqual([]) - expect(checkWorkspaceProtocol([dependency, consumer('workspace:*')])).toEqual([]) - expect(checkWorkspaceProtocol([dependency, consumer('^4.0.3')])).toEqual([ - `${dir}: dependencies.@deepseek-ai/dsh-runtime must use the workspace: protocol, got ^4.0.3`, - ]) - }, - ) + it('rejects a declaration that matches no workspace members', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-workspace-ranges-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages: [missing/*]') + expect(() => readWorkspaceManifests(root)).toThrow('packages matched no workspace manifests') + }) }) describe('experimental workspace constraints', () => { diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 765d0f02b0..4ef941a22e 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -5,9 +5,10 @@ * Run: `tsx scripts/check-workspace-constraints.ts`. */ -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' +import { existsSync, globSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { load as loadYaml } from 'js-yaml' import { isPublicExperimentalPackageDirectory, PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES, @@ -18,8 +19,7 @@ import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/sr import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' const root = resolve(import.meta.dirname, '..') -// vendor/* is single-level; packages// nests one level deeper -// (the group dirs — core/llm/shell/… — are pure containers with no manifest). +// Publication rules cover these package trees; dependency rules read all pnpm members. const workspaceGlobs = [ { dir: 'vendor', depth: 1 }, { dir: 'packages', depth: 2 }, @@ -147,6 +147,32 @@ function workspaceManifests(): WorkspaceManifest[] { return manifests } +/** + * Read the root manifest and every member declared by pnpm-workspace.yaml. + * @param repositoryRoot - repository or fixture root containing the workspace declaration. + * @returns Manifests with normalized repository-relative directories. + * @throws When the workspace declaration is invalid or matches no member manifests. + */ +export function readWorkspaceManifests(repositoryRoot: string): WorkspaceManifest[] { + const config = loadYaml(readFileSync(join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf8')) + if (typeof config !== 'object' || config === null || !('packages' in config) + || !Array.isArray(config.packages) || config.packages.length === 0 + || !config.packages.every((member: unknown): member is string => typeof member === 'string' && member.length > 0)) { + throw new Error('pnpm-workspace.yaml: packages must be a non-empty list of workspace patterns') + } + const patterns = config.packages.filter(member => !member.startsWith('!')).map(member => `${member}/package.json`) + const exclude = config.packages.filter(member => member.startsWith('!')).map(member => `${member.slice(1)}/package.json`) + const paths = globSync(patterns, { + cwd: repositoryRoot, + exclude: ['**/node_modules/**', '**/.git/**', ...exclude], + }).map(path => path.replaceAll('\\', '/')) + if (paths.length === 0) throw new Error('pnpm-workspace.yaml: packages matched no workspace manifests') + return [...new Set(['package.json', ...paths])].sort().map(path => ({ + dir: dirname(path).replaceAll('\\', '/'), + manifest: readJson(join(repositoryRoot, path)), + })) +} + const packageFileExtras: Readonly> = { // Owned Worker bundles import this public bootstrap before their business entry. '@deepseek-ai/dsh-app-boot': ['lib/worker/profile-resolution-bootstrap.js'], @@ -548,27 +574,29 @@ export function checkExperimentalDependencyIsolation( } /** - * Require exact DSH-to-DSH package ranges and the workspace protocol elsewhere. + * Require exact DSH ranges, caret vendor ranges, and the workspace protocol elsewhere. * * A hand-written range says nothing about the version the workspace actually * carries, and `pnpm pack` leaves it alone: `^0.0.1` published from version * `0.0.2` names a version that does not exist. The protocol makes pack - * substitute the member's real version. DSH-to-DSH references under packages/ - * use `workspace:*`; references to vendor and native packages retain their ranges. + * substitute the member's real version. Dependency targets determine the range, + * regardless of the consuming manifest's name or directory. * @param manifests - every workspace manifest. * @returns One error per workspace reference with a disallowed range. */ export function checkWorkspaceProtocol(manifests: readonly WorkspaceManifest[]): string[] { const members = new Set(manifests.map(entry => entry.manifest.name).filter(name => name !== undefined)) + const vendors = new Set(manifests.filter(entry => entry.dir.startsWith('vendor/')).map(entry => entry.manifest.name)) const errors: string[] = [] for (const { dir, manifest } of manifests) { - const packageOwner = /^packages\/[^/]+\/[^/]+$/.test(dir) for (const section of dependencySections) { for (const [name, range] of Object.entries(manifest[section] ?? {})) { if (!members.has(name)) continue - const exact = packageOwner && (name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-')) - if (exact ? range === 'workspace:*' : range.startsWith('workspace:')) continue - errors.push(`${manifest.name ?? dir}: ${section}.${name} must use ${exact ? 'workspace:*' : 'the workspace: protocol'}, got ${range}`) + const expected = name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-') + ? 'workspace:*' + : vendors.has(name) ? 'workspace:^' : undefined + if (expected !== undefined ? range === expected : range.startsWith('workspace:')) continue + errors.push(`${manifest.name ?? dir}: ${section}.${name} must use ${expected ?? 'the workspace: protocol'}, got ${range}`) } } } @@ -577,16 +605,12 @@ export function checkWorkspaceProtocol(manifests: readonly WorkspaceManifest[]): /** Run the repository constraint gate. */ export function main(): void { - const manifests = workspaceManifests() - const dependencyManifests = [ - ...manifests, - { dir: 'python/sdk-runtime', manifest: readJson(join(root, 'python/sdk-runtime/package.json')) }, - ] + const manifests = readWorkspaceManifests(root) const errors = [ ...checkRepositoryVersion(), - ...manifests.flatMap(checkWorkspaceManifest), + ...workspaceManifests().flatMap(checkWorkspaceManifest), ...checkWorkspaceProtocol(manifests), - ...checkExperimentalDependencyIsolation(dependencyManifests), + ...checkExperimentalDependencyIsolation(manifests), ...checkHierarchyShape(), ...collectProjectReferenceFaceViolations(root), ]