From 283ac7f09723f25496df90ecedf8b0ac7d7e71e4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 16:34:09 +0800 Subject: [PATCH] python: close runtime packaging and platform wheels --- package.json | 3 +- pnpm-lock.yaml | 18 ++++ python/sdk-runtime/hatch_build.py | 60 +++++++++++ python/sdk-runtime/package.json | 6 ++ python/sdk-runtime/pyproject.toml | 6 +- python/sdk/pyproject.toml | 4 +- scripts/build-exe-for-python-sdk.ts | 13 +++ scripts/build-python-release.py | 160 ++++++++++++++++++++++++++++ scripts/run-gates.ts | 3 + scripts/verify-runtime-closure.ts | 116 ++++++++++++++++++++ 10 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 python/sdk-runtime/hatch_build.py create mode 100644 scripts/build-python-release.py create mode 100644 scripts/verify-runtime-closure.ts diff --git a/package.json b/package.json index ed0b603feb..cdc78f1828 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", + "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -64,7 +65,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 164bcbb17f..c2e74d552b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1706,6 +1706,12 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../packages/core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -1718,6 +1724,12 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../packages/skill/skill-local '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent @@ -1757,6 +1769,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../packages/skill/tool-skill '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent @@ -1772,6 +1787,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py new file mode 100644 index 0000000000..1c5b22e11a --- /dev/null +++ b/python/sdk-runtime/hatch_build.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import os +import platform +import stat +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +_PLATFORMS = { + "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), + "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), + "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), +} + + +def _host_platform_tag() -> str: + machine = platform.machine().lower() + arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine + system = platform.system().lower() + key = f"macos-{arch}" if system == "darwin" else f"linux-{arch}" if system == "linux" else system + try: + return _PLATFORMS[key][0] + except KeyError as exc: + raise RuntimeError(f"unsupported deepseek-harness-runtime-bin build platform: {key}") from exc + + +class RuntimeBuildHook(BuildHookInterface): + """Assign the native wheel tag and reject incomplete or mixed-platform payloads.""" + + def initialize(self, version: str, build_data: dict[str, object]) -> None: + if version == "editable": + return + if self.target_name == "sdist": + raise RuntimeError( + "deepseek-harness-runtime-bin is wheel-only; build and publish platform wheels only." + ) + + platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() + matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] + if len(matches) != 1: + supported = ", ".join(value[0] for value in _PLATFORMS.values()) + raise RuntimeError( + f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" + ) + expected_executable = matches[0][1] + runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" + executables = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + if [path.name for path in executables] != [expected_executable]: + found = ", ".join(path.name for path in executables) or "none" + raise RuntimeError( + f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" + ) + if executables[0].stat().st_mode & stat.S_IXUSR == 0: + raise RuntimeError(f"runtime executable is not executable: {executables[0]}") + + build_data["pure_python"] = False + build_data["infer_tag"] = False + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 7f179f3941..76e6bf157f 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -33,10 +33,14 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", @@ -50,11 +54,13 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index ed1e147494..d0f1d97bf5 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "deepseek-harness-runtime-bin" -version = "0.0.0-dev" +version = "0.0.0.dev0" description = "Pinned DeepSeek Harness runtime for the Python SDK" readme = "README.md" requires-python = ">=3.10" @@ -19,3 +19,7 @@ exclude = ["src/deepseek_harness_runtime/runtime/node"] [tool.hatch.build.targets.wheel] packages = ["src/deepseek_harness_runtime"] + +[tool.hatch.build.targets.wheel.hooks.custom] + +[tool.hatch.build.targets.sdist.hooks.custom] diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index 68760f7c61..859a2d6282 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "hatchling.build" [project] name = "deepseek-harness" -version = "0.0.0-dev" +version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" requires-python = ">=3.10" license = { text = "BSD-3-Clause" } dependencies = [ "pydantic>=2.12", - "deepseek-harness-runtime-bin==0.0.0-dev", + "deepseek-harness-runtime-bin==0.0.0.dev0", ] [dependency-groups] diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 0cc7b0c731..6f29438d42 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -81,6 +81,8 @@ const OUT_DIR = 'dist-exe' const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' /** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */ const PYTHON_NODE_SUBDIR = 'node' +/** Deploy-root documentation is not runtime input and violates the generated-directory i18n exclusion if retained. */ +const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] /** * Whole-tree asset globs. The cordis Loader dynamic-imports bare package names @@ -295,6 +297,11 @@ class SingleExeBuild { constructor(private readonly cli: BuildCli) {} + /** Gate the manifest before spending time compiling or packaging it. */ + async verifyClosure(): Promise { + await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure']) + } + /** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */ async build(): Promise { if (this.cli.skipBuild) { @@ -322,6 +329,11 @@ class SingleExeBuild { '--config.link-workspace-packages=true', this.staging, ]) + if (this.cli.dryRun) { + for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) + } else { + await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true }))) + } } /** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */ @@ -445,6 +457,7 @@ async function main(): Promise { const pipeline = new SingleExeBuild(cli) console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`) console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`) + await pipeline.verifyClosure() await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py new file mode 100644 index 0000000000..a196973a53 --- /dev/null +++ b/scripts/build-python-release.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Stage and build one Python release wheel from a stable ``python-vX.Y.Z`` tag.""" + +from __future__ import annotations + +import argparse +import email +import os +import re +import shutil +import stat +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PLATFORMS = { + "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), + "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), + "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), +} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", choices=("sdk", "runtime"), required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--platform", choices=tuple(PLATFORMS)) + parser.add_argument("--runtime-exe", type=Path) + args = parser.parse_args() + version = version_from_tag(args.tag) + if args.package == "runtime" and (args.platform is None or args.runtime_exe is None): + parser.error("runtime builds require --platform and --runtime-exe") + if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None): + parser.error("SDK builds do not accept --platform or --runtime-exe") + + output_dir = args.output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary: + staging = Path(temporary) / args.package + if args.package == "sdk": + stage_sdk(staging, version) + environment = None + expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl" + else: + platform_tag, executable_name = PLATFORMS[args.platform] + stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name) + environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag} + expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl" + command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)] + subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True) + if not expected.is_file(): + raise RuntimeError(f"build did not produce expected wheel: {expected}") + verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform]) + print(expected) + + +def version_from_tag(tag: str) -> str: + match = re.fullmatch(r"python-v(\d+\.\d+\.\d+)", tag) + if match is None: + raise ValueError(f"release tag must match python-vX.Y.Z, got {tag!r}") + return match.group(1) + + +def copy_package(source: Path, destination: Path) -> None: + shutil.copytree( + source, + destination, + ignore=shutil.ignore_patterns( + ".venv", + ".pytest_cache", + "__pycache__", + "*.pyc", + "dist", + "node_modules", + "dsh-jsonrpc-agent-pkg-*", + ), + ) + + +def rewrite_version(pyproject: Path, version: str) -> None: + text, count = re.subn( + r'^version = "[^"]+"$', + f'version = "{version}"', + pyproject.read_text(), + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError(f"could not rewrite version in {pyproject}") + pyproject.write_text(text) + + +def stage_sdk(destination: Path, version: str) -> None: + copy_package(ROOT / "python" / "sdk", destination) + pyproject = destination / "pyproject.toml" + rewrite_version(pyproject, version) + text, count = re.subn( + r'"deepseek-harness-runtime-bin==[^"]+"', + f'"deepseek-harness-runtime-bin=={version}"', + pyproject.read_text(), + count=1, + ) + if count != 1: + raise RuntimeError("SDK must contain exactly one runtime dependency pin") + pyproject.write_text(text) + + +def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: + if not executable.is_file(): + raise FileNotFoundError(f"runtime executable does not exist: {executable}") + if executable.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime executable is not executable: {executable}") + copy_package(ROOT / "python" / "sdk-runtime", destination) + rewrite_version(destination / "pyproject.toml", version) + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + destination_executable = runtime_dir / executable_name + shutil.copyfile(executable, destination_executable) + destination_executable.chmod(executable.stat().st_mode & 0o777) + + +def verify_wheel( + wheel: Path, + package: str, + version: str, + platform: tuple[str, str] | None, +) -> None: + expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}" + with zipfile.ZipFile(wheel) as archive: + wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL")) + metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path)) + metadata = email.message_from_bytes(archive.read(metadata_path)) + if wheel_metadata.get_all("Tag") != [expected_tag]: + raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") + if metadata.get("Version") != version: + raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") + executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name] + if package == "runtime": + assert platform is not None + if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): + raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") + mode = archive.getinfo(executables[0]).external_attr >> 16 + if mode & stat.S_IXUSR == 0: + raise RuntimeError(f"{wheel} runtime executable lost its executable bit") + elif executables: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}") + if package == "sdk": + requirements = metadata.get_all("Requires-Dist") or [] + expected_requirement = f"deepseek-harness-runtime-bin=={version}" + if expected_requirement not in requirements: + raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b26e13a56c..94cf174468 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -151,6 +151,7 @@ function gatesForMode(selected: Mode): Gate[] { ] case 'pre-push': return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('test', 'test'), pnpmScript('snapshot', 'test:snapshot'), pnpmScript('build', 'build'), @@ -163,6 +164,7 @@ function gatesForMode(selected: Mode): Gate[] { function ciPrimaryGates(): Gate[] { return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), lintGate(), @@ -184,6 +186,7 @@ function ciPrimaryGates(): Gate[] { function ciStaticGates(): Gate[] { return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), demoSmokeGate(), ...docSyncLeafGates(), diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts new file mode 100644 index 0000000000..31d2b9e3bf --- /dev/null +++ b/scripts/verify-runtime-closure.ts @@ -0,0 +1,116 @@ +/** + * Verify that the Python single-exe deploy manifest explicitly supplies every + * required workspace peer of every workspace package in its dependency graph. + * + * `pnpm deploy --config.auto-install-peers=false` cannot repair an incomplete + * runtime root. Keeping the peer at the root also prevents a successful build + * from producing an executable that fails only when Cordis loads the plugin. + */ +import { readFile, readdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' + +interface PackageManifest { + name?: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +interface WorkspacePackage { + path: string + manifest: PackageManifest +} + +const root = resolve(import.meta.dirname, '..') +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { manifest: { type: 'string' } }, +}) +const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json') +const runtimeManifest = await loadManifest(runtimeManifestPath) +const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime' +const workspace = await loadWorkspacePackages() +const runtimeDependencies = runtimeManifest.dependencies ?? {} +const parents = new Map() +const queue: string[] = [] + +for (const dependency of Object.keys(runtimeDependencies).sort()) { + if (!workspace.has(dependency)) continue + parents.set(dependency, undefined) + queue.push(dependency) +} + +const failures: string[] = [] +for (let index = 0; index < queue.length; index += 1) { + const packageName = queue[index] + if (packageName === undefined) continue + const current = workspace.get(packageName) + if (current === undefined) continue + const peers = current.manifest.peerDependencies ?? {} + const peerMeta = current.manifest.peerDependenciesMeta ?? {} + for (const peer of Object.keys(peers).sort()) { + if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue + if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue + failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`) + } + const dependencies = { + ...current.manifest.dependencies, + ...current.manifest.optionalDependencies, + } + for (const dependency of Object.keys(dependencies).sort()) { + if (!workspace.has(dependency) || parents.has(dependency)) continue + parents.set(dependency, packageName) + queue.push(dependency) + } +} + +if (failures.length > 0) { + console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:') + for (const failure of failures) console.error(` ${failure}`) + process.exit(1) +} + +console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`) + +async function loadWorkspacePackages(): Promise> { + const paths: string[] = [] + for (const group of await childDirectories(join(root, 'packages'))) { + for (const packageDir of await childDirectories(join(root, 'packages', group))) { + paths.push(join(root, 'packages', group, packageDir, 'package.json')) + } + } + for (const packageDir of await childDirectories(join(root, 'vendor'))) { + paths.push(join(root, 'vendor', packageDir, 'package.json')) + } + const result = new Map() + for (const path of paths) { + const manifest = await loadManifest(path) + if (manifest.name !== undefined) result.set(manifest.name, { path, manifest }) + } + return result +} + +async function childDirectories(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }) + return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort() +} + +async function loadManifest(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as PackageManifest +} + +function formatChain( + runtimeName: string, + packageName: string, + parents: ReadonlyMap, +): string { + const chain = [packageName] + let parent = parents.get(packageName) + while (parent !== undefined) { + chain.unshift(parent) + parent = parents.get(parent) + } + return [runtimeName, ...chain].join(' -> ') +}