Merge branch 'worktree/forge-pr-worktree-panel' into worktree/forge-session-list
This commit is contained in:
commit
e4fd8a87ff
32 files changed
+937
-421
No files matched your search
+2
-2
@@ -21,8 +21,8 @@
|
||||
en: 985e0eb63f3d1fd6
|
||||
zh: 337cc44fadc6227e
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts:
|
||||
en: 9d885e8d58bc8bbb
|
||||
zh: 41a9779fbdea8a1a
|
||||
en: 9a4ba22142a7be24
|
||||
zh: 8553b02a923641a3
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development:
|
||||
en: b2a740a1ab7a5002
|
||||
zh: a48be6ca6fc65912
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation.
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64.
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
|
||||
|
||||
### 构建流水线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: e5c984fa32cc5e69
|
||||
zh: 25d5742b2106d830
|
||||
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision:
|
||||
en: 8353d57b20ca01c3
|
||||
zh: e0b74906846d759c
|
||||
en: 2b047b7d8ffaaeb9
|
||||
zh: 035e1a99cb0ffe84
|
||||
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered:
|
||||
en: f61da066f9ddcd62
|
||||
zh: 816fd6e0063017cd
|
||||
|
||||
@@ -20,7 +20,7 @@ The Python executable builder selects `build:artifacts` artifact compilation wit
|
||||
|
||||
PR artifact builds in CI invoke the root manifest's `build:artifacts`; a PR that edits these root manifest scripts activates the separate native-addon platform matrix, a trade-off [the build split](2026-10-11-build-artifacts-check-split.md) records. The vendor rehearsal and benchmark lanes invoke the internal build helpers directly.
|
||||
|
||||
Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Four Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most nine worker jobs initially. Once the SDK job finishes, those four Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most ten. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order.
|
||||
Benchmark sample counts stay fixed. The Linux artifact consumers emit artifacts only; the aggregate test and script type checks belong to the master lanes, and lint plus duplication run in their own lane, which compiles package declarations because oxlint's type-aware rules read them, so a red lint no longer costs a rerun of the snapshot and artifact consumers. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Five Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most eleven worker jobs initially. Once the SDK job finishes, those five Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most eleven. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the eleven-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order.
|
||||
|
||||
Each Node version runs the same eight source compatibility specs in one serial Vitest invocation. Both file parallelism and the CLI worker limit are fixed to one, and the gate sets `VITEST_MAX_WORKERS=1` because that environment variable overrides Vitest's CLI limit. Files retain fork isolation; the build-backed Node 24.9 lazy-search smoke keeps its separate build dependency. The original seven-spec paired samples preserve all 253 assertion outcomes and reduce startup-only median time from 11.52 to 8.69 seconds, without increasing owned descendant-process peak.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Python executable builder 在验证 runtime closure 后,通过 `--build-artifa
|
||||
|
||||
CI 的 PR 产物构建调用 root manifest 中的 `build:artifacts`;修改 root manifest 中这些 script 的 PR 会激活独立的 native-addon 平台矩阵,[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)记录这一取舍。vendor 演练与 benchmark lane 直接调用内部 build helper。
|
||||
|
||||
job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。四个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用九个 worker job。SDK job 完成后,这四个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。
|
||||
benchmark sample 数量保持固定。Linux 产物消费者只产出产物;聚合的 test 与 script 类型检查归 master lane;lint 与 duplication 单独占用一个 lane,该 lane 会先编译包声明(oxlint 的类型感知规则要读它们),因此 lint 变红不再让 snapshot 与 artifact 消费者整体重跑。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。五个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用十一个 worker job。SDK job 完成后,这五个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十一个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持十一个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。
|
||||
|
||||
每个 Node 版本在一次串行 Vitest 调用中运行相同的八个 source compatibility spec。文件并行和 CLI worker 上限均固定为一,gate 还设置 `VITEST_MAX_WORKERS=1`,因为该环境变量会覆盖 Vitest 的 CLI 上限。文件保持 fork 隔离;依赖构建的 Node 24.9 lazy-search smoke 保留独立的 build 依赖。原来七个 spec 的成对样本保持全部 253 个 assertion outcome,将启动开销比较的 median 从 11.52 秒降至 8.69 秒,且不增加所拥有 descendant process 的峰值。
|
||||
|
||||
|
||||
@@ -264,6 +264,10 @@ jobs:
|
||||
- name: Build single-exe
|
||||
env:
|
||||
DSH_BUILD_CLIENT_PROFILE: official
|
||||
# Brotli runs at quality 9 through the vendored pkg patch: the same
|
||||
# payload measured 16.3 s at quality 9 against 132.8 s at pkg's default
|
||||
# quality 11, for an archive 10% larger. Which quality each lane uses is
|
||||
# therefore not a workflow choice.
|
||||
run: >-
|
||||
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
|
||||
${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }}
|
||||
|
||||
@@ -3,7 +3,9 @@ name: Build PR preview
|
||||
# Every push to a pull request publishes that pull request's preview to
|
||||
# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The
|
||||
# upload carries build products only: the workflow never grants the deployment
|
||||
# platform access to this repository's sources.
|
||||
# platform access to this repository's sources. This lane also owns the
|
||||
# aggregate test and script type checks that the required Linux consumers no
|
||||
# longer repeat on every pull request.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -32,7 +34,13 @@ env:
|
||||
|
||||
jobs:
|
||||
preview:
|
||||
runs-on: ubuntu-24.04
|
||||
# The required outcome is the build, which runs four TypeScript programs
|
||||
# (Host and Client package compilation, then each face's aggregate test and
|
||||
# script check) after the Typert pass. Publishing is best effort: transport,
|
||||
# Access, and comment failures warn instead of failing the check.
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| 'ubuntu-24.04' }}
|
||||
name: cloudflare pages preview
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
@@ -70,8 +78,11 @@ jobs:
|
||||
# apps/web consumes workspace packages as built lib products, and
|
||||
# build:preview packs the image through the packer's installed bin
|
||||
# (lib/bin.js), so neither half exists before the artifact build runs.
|
||||
- name: Build workspace
|
||||
run: pnpm run build:artifacts
|
||||
# The required Linux consumers emit artifacts only; the complete build
|
||||
# here also runs the aggregate test and script checks, which share the
|
||||
# Typert output and the two package compilations.
|
||||
- name: Build workspace and check aggregate programs
|
||||
run: pnpm run build
|
||||
|
||||
- name: Build the preview page and pack the VFS image
|
||||
env:
|
||||
@@ -88,6 +99,8 @@ jobs:
|
||||
cp apps/web/dist/preview.html apps/web/dist/index.html
|
||||
|
||||
- name: Upload to Cloudflare Pages
|
||||
id: upload
|
||||
continue-on-error: true
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -112,6 +125,8 @@ jobs:
|
||||
# Accept-Encoding is sent because a browser sends it; the assertion is
|
||||
# about what the platform does with a body that is already compressed.
|
||||
- name: Verify the protected deployment serves the image
|
||||
id: verify
|
||||
continue-on-error: true
|
||||
env:
|
||||
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
|
||||
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
|
||||
@@ -151,6 +166,8 @@ jobs:
|
||||
# idempotent: a pull request opened before this workflow existed never
|
||||
# sees an `opened` event, and every later push must not restate the URL.
|
||||
- name: Comment the preview URL
|
||||
id: comment
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
@@ -168,3 +185,12 @@ jobs:
|
||||
"$marker" \
|
||||
"[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \
|
||||
| gh pr comment "$PR" --body-file -
|
||||
|
||||
# Publishing stays outside the required verdict: a Cloudflare, Access, or
|
||||
# comment failure warns, while a build failure still fails this check.
|
||||
- name: Report preview publication failures
|
||||
if: steps.upload.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.comment.outcome == 'failure'
|
||||
run: |
|
||||
message='preview publication failed; the build passed, see the upload, verify, and comment steps'
|
||||
echo "::warning::$message"
|
||||
echo "$message" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -202,12 +202,81 @@ jobs:
|
||||
path: .coverage-times.json
|
||||
key: coverage-times-Linux-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_LINUX || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
# Lint and duplication get their own lane instead of riding the artifact
|
||||
# consumers: a red lint no longer costs the snapshot and artifact consumers a
|
||||
# full rerun. The lane compiles package declarations because oxlint's
|
||||
# type-aware rules read them.
|
||||
node-24-lint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404'
|
||||
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|
||||
|| 'dsh-ubuntu-24-04-16core' }}
|
||||
name: node 24 / lint
|
||||
env:
|
||||
# Shared hosts expose host CPUs, not an isolated allocation.
|
||||
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
steps:
|
||||
# Runner cleanup owns tool caches and fixtures, including cancelled runs.
|
||||
- name: Use runner-owned temporary storage
|
||||
run: |
|
||||
echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Redirect Node compile cache to runner temp
|
||||
run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache/[email protected]
|
||||
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# oxlint's type-aware rules resolve workspace imports through the package
|
||||
# declarations, so this lane compiles them without bundling anything.
|
||||
- name: Compile package declarations
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Run lint and duplication
|
||||
run: pnpm run check:ci:lint:contracts-ready
|
||||
|
||||
node-24-bench:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| 'ubuntu-24.04' }}
|
||||
name: node 24 / benchmarks
|
||||
# Wall-clock budgets need an otherwise idle runner, so this job runs the
|
||||
# benchmark lane alone instead of joining a concurrent gate aggregate.
|
||||
# benchmark lane alone instead of joining a concurrent gate aggregate, and
|
||||
# keeps its own two-file parallelism inside that runner. The self-hosted
|
||||
# failover pool is shared, so the benchmarks stay on hosted Linux there.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
@@ -503,7 +572,7 @@ jobs:
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|
||||
|| 'ubuntu-latest' }}
|
||||
needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat]
|
||||
needs: [node-24, node-24-coverage, node-24-lint, node-24-bench, node-24-consumers, node-compat]
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- name: Fail if any needed job did not succeed
|
||||
|
||||
@@ -122,6 +122,6 @@ jobs:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic
|
||||
DSH_PRIMARY_RUNTIME: ${{ github.workspace }}/.cache/e2e-office/primary-runtime
|
||||
DSH_E2E_MAX_WORKERS: 4
|
||||
DSH_E2E_MAX_WORKERS: 6
|
||||
DSH_EXAMPLE_MODE: lib
|
||||
run: pnpm run test:e2e
|
||||
@@ -30,7 +30,8 @@ jobs:
|
||||
name: Dependency layout
|
||||
# Persistent runners accept only trusted, credential-free rehearsals.
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
&& github.repository == 'deepseek-harness/deepseek-harness'
|
||||
&& github.actor != 'dependabot[bot]'
|
||||
&& ((github.event_name == 'push' && github.ref == 'refs/heads/master')
|
||||
@@ -80,11 +81,20 @@ jobs:
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify dependency policy
|
||||
run: pnpm run verify-package-dependencies
|
||||
|
||||
- name: Verify npm install layout
|
||||
run: pnpm run verify-npm-install-layout
|
||||
# The two verifications read different inputs (manifests versus an npm
|
||||
# install in a scratch directory), so they run beside each other.
|
||||
- name: Verify dependency policy and npm install layout
|
||||
run: |
|
||||
pnpm run verify-package-dependencies &
|
||||
dependencies_pid=$!
|
||||
pnpm run verify-npm-install-layout &
|
||||
layout_pid=$!
|
||||
dependencies_status=0
|
||||
wait "$dependencies_pid" || dependencies_status=$?
|
||||
layout_status=0
|
||||
wait "$layout_pid" || layout_status=$?
|
||||
if (( dependencies_status != 0 )); then exit "$dependencies_status"; fi
|
||||
exit "$layout_status"
|
||||
|
||||
pack:
|
||||
name: Pack npm tarballs
|
||||
|
||||
@@ -102,13 +102,27 @@ function appendRecords(records: readonly (TimingRecord | MemoryRecord)[]): void
|
||||
appendFileSync(RESULTS_PATH, records.map(record => JSON.stringify(record) + '\n').join(''))
|
||||
}
|
||||
|
||||
/** Vitest global setup: discard results of an earlier run. */
|
||||
/**
|
||||
* Which invocation of a phased run this process is.
|
||||
* @returns `first` for the invocation that clears earlier results, `last` for the one that renders the report, and `all` for a single-invocation run.
|
||||
* @throws If the value is not a known phase.
|
||||
*/
|
||||
function reportPhase(): 'first' | 'last' | 'all' {
|
||||
const raw = process.env.DSH_BENCH_REPORT_PHASE
|
||||
if (raw === undefined || raw === '' || raw === 'all') return 'all'
|
||||
if (raw === 'first' || raw === 'last') return raw
|
||||
throw new Error(`DSH_BENCH_REPORT_PHASE must be "first", "last", or "all", got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
|
||||
/** Vitest global setup: discard results of an earlier run unless this phase continues one. */
|
||||
export function setup(): void {
|
||||
if (reportPhase() === 'last') return
|
||||
rmSync(REPORT_DIRECTORY, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** Vitest global teardown: render the report when any case recorded a timing. */
|
||||
/** Vitest global teardown: render the report when a case recorded a timing and this phase owns the report. */
|
||||
export function teardown(): void {
|
||||
if (reportPhase() === 'first') return
|
||||
if (!existsSync(RESULTS_PATH)) return
|
||||
const records = readFileSync(RESULTS_PATH, 'utf8').split('\n').filter(line => line !== '').map(line => JSON.parse(line) as TimingRecord | MemoryRecord)
|
||||
const cpuModel = cpus()[0]?.model.trim() ?? 'unknown CPU'
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
"test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:bench": "npm run build:bench && npm run build:web && npm run test:bench:built",
|
||||
"test:bench:built": "vitest run --config vitest.bench.config.ts",
|
||||
"test:bench:built": "tsx scripts/run-bench-suite.ts",
|
||||
"test:bench:memory": "tsx scripts/run-memory-pressure-bench.ts",
|
||||
"test:expected": "vitest run --config vitest.expected.config.ts",
|
||||
"test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Register real Office authoring visual-intent e2e cases for the selected document formats. */
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { officeModelPatches } from './real-office.ts'
|
||||
import { renderCliFixture } from './render-cli.ts'
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../../', import.meta.url))
|
||||
const assetRoot = fileURLToPath(new URL('../../assets/', import.meta.url))
|
||||
const runtime = process.env.DSH_PRIMARY_RUNTIME
|
||||
const python = runtime && join(runtime, 'dependencies/python', process.platform === 'win32' ? 'python.exe' : 'bin/python3')
|
||||
|
||||
type DocumentFormat = 'docx' | 'pptx' | 'xlsx'
|
||||
|
||||
const formats = [
|
||||
{
|
||||
contentTask: 'change the title text on slide 1 to Quarterly results without changing its formatting',
|
||||
verifyContent: "from pptx import Presentation\np=Presentation('result.pptx'); assert len(p.slides) == 1; assert p.slides[0].shapes[0].text == 'Quarterly results'",
|
||||
format: 'pptx', task: 'center the title on slide 1 and set its font color to #1F4E79',
|
||||
seed: "from pptx import Presentation\nfrom pptx.util import Inches\np=Presentation(); s=p.slides.add_slide(p.slide_layouts[6]); s.shapes.add_textbox(Inches(1),Inches(1),Inches(6),Inches(1)).text='Quarterly revenue'; p.save('input.pptx')",
|
||||
verify: "from pptx import Presentation\nfrom pptx.enum.text import PP_ALIGN\np=Presentation('result.pptx'); title=p.slides[0].shapes[0]; r=title.text_frame.paragraphs[0]; assert r.alignment == PP_ALIGN.CENTER; assert abs(2*title.left+title.width-p.slide_width) <= 1; assert str(r.runs[0].font.color.rgb).upper() == '1F4E79'",
|
||||
},
|
||||
{
|
||||
contentTask: 'change the title paragraph text to Quarterly results without changing its formatting',
|
||||
verifyContent: "from docx import Document\nd=Document('result.docx'); assert [p.text for p in d.paragraphs] == ['Quarterly results', 'Q1: 12; Q2: 18']",
|
||||
format: 'docx', task: 'center the title paragraph and set its font color to #1F4E79',
|
||||
seed: "from docx import Document\nd=Document(); d.add_paragraph('Quarterly revenue'); d.add_paragraph('Q1: 12; Q2: 18'); d.save('input.docx')",
|
||||
verify: "from docx import Document\nfrom docx.enum.text import WD_ALIGN_PARAGRAPH\nd=Document('result.docx'); p=d.paragraphs[0]; assert p.alignment == WD_ALIGN_PARAGRAPH.CENTER; assert p.runs[0].font.color.rgb == (31,78,121)",
|
||||
},
|
||||
{
|
||||
contentTask: 'change Revenue!B2 to 20 without changing the cell formatting or other data',
|
||||
verifyContent: "from openpyxl import load_workbook\nw=load_workbook('result.xlsx'); s=w['Revenue']; assert list(s.values) == [('Quarter','Revenue'),('Q1',20),('Q2',18)]",
|
||||
format: 'xlsx', task: 'center the text in Revenue!A1:B1 and set the cells\' fill color to #1F4E79',
|
||||
seed: "from openpyxl import Workbook\nw=Workbook(); s=w.active; s.title='Revenue'; s.append(['Quarter','Revenue']); s.append(['Q1',12]); s.append(['Q2',18]); w.save('input.xlsx')",
|
||||
verify: "from openpyxl import load_workbook\nw=load_workbook('result.xlsx'); s=w['Revenue']; assert all(s[c].alignment.horizontal == 'center' and s[c].fill.fgColor.rgb.endswith('1F4E79') for c in ('A1','B1')); assert s['B2'].value == 12 and s['B3'].value == 18",
|
||||
},
|
||||
] as const
|
||||
|
||||
const intents = [
|
||||
{ intent: 'explicit', suffix: 'Render the finished file and visually inspect the changed page or sheet.' },
|
||||
{ intent: 'excluded', suffix: 'Do not render or visually inspect the finished file.' },
|
||||
{ intent: 'implicit', suffix: '' },
|
||||
] as const
|
||||
|
||||
function within(parent: string, path: string): boolean {
|
||||
const part = relative(parent, path)
|
||||
return part === '' || part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the visual-intent e2e cases for the selected document formats.
|
||||
* @param selected - Document formats whose cases this spec file registers.
|
||||
* @param options - `blank` also registers the pptx case whose preview fixture produces blank pixels.
|
||||
*/
|
||||
export function registerVisualIntentCases(selected: readonly DocumentFormat[], options: { blank?: boolean } = {}): void {
|
||||
const cases = [
|
||||
...formats.filter(format => selected.includes(format.format)).flatMap(format => intents.flatMap(intent => [
|
||||
{ ...format, ...intent, kind: 'format' },
|
||||
{ ...format, ...intent, kind: 'content', task: format.contentTask, verify: format.verifyContent },
|
||||
])),
|
||||
...options.blank === true ? [{ ...formats[0], kind: 'format', intent: 'blank', suffix: intents[0].suffix }] : [],
|
||||
]
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(cases)('$format $kind $intent visual intent', async ({ format, kind, task, seed, verify, intent, suffix }) => {
|
||||
const controlRoot = await mkdtemp(join(tmpdir(), 'dsh-office-control-test-'))
|
||||
try {
|
||||
const previewRoot = join(controlRoot, 'tmp')
|
||||
await mkdir(previewRoot)
|
||||
const configPath = join(controlRoot, 'office.patch.json')
|
||||
const sessionRoot = join(controlRoot, 'sessions')
|
||||
// The model can remove its entire preview directory during cleanup.
|
||||
const callLogPath = join(controlRoot, 'cli-calls.jsonl')
|
||||
await runLoaderSmoke({
|
||||
label: `Office ${format} ${kind} ${intent} visual intent`, tempDirPrefix: `dsh-office-${format}-${kind}-${intent}-`,
|
||||
binScript: join(repo, 'apps/cli/src/bin.ts'), sourceImport: 'tsx/esm', tsconfigPath: join(repo, 'tsconfig.base.json'),
|
||||
configPath, processTimeoutMs: 110_000,
|
||||
binArgs: ['--profile', 'headless', '--patch', configPath,
|
||||
`In input.${format}, ${task}; save result.${format}. ${suffix} Respond briefly in English.`],
|
||||
// The CLI fixture and its log live outside the workspace; bwrap's private /tmp hides both.
|
||||
env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_HOME: join(controlRoot, 'home'), DSH_AGENTS_HOME: join(controlRoot, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
TMPDIR: previewRoot, TMP: previewRoot, TEMP: previewRoot,
|
||||
HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY },
|
||||
async prepare(cwd) {
|
||||
await execa(python!, ['-c', seed], { cwd })
|
||||
const fixture = await renderCliFixture(controlRoot, python!, intent === 'blank', callLogPath)
|
||||
const testAssetRoot = process.env.DSH_OFFICE_TEST_ASSET_ROOT ?? assetRoot
|
||||
await writeFile(configPath, JSON.stringify([
|
||||
{ insert: [{ id: 'skill-office', name: '@deepseek-ai/dsh-skill-office', config: { assetRoot: testAssetRoot, node: process.execPath, cli: fixture.cli } }] },
|
||||
{ insert: [{ id: 'workspace-dependencies', name: '@deepseek-ai/dsh-tool-workspace-dependencies', config: { source: runtime! } }] },
|
||||
...officeModelPatches(),
|
||||
{ id: 'session-persistence-jsonl', config: { root: sessionRoot, compression: 'none' } },
|
||||
]))
|
||||
},
|
||||
async inspect(cwd) {
|
||||
const logs = await readdir(sessionRoot, { recursive: true })
|
||||
const log = logs.find(path => path.endsWith('session.v4.jsonl'))
|
||||
expect(log, `No recorded session for ${format} ${intent} in ${sessionRoot}`).toBeDefined()
|
||||
const bytes = await readFile(join(sessionRoot, log!))
|
||||
const traceDir = process.env.DSH_OFFICE_TRACE_DIR
|
||||
if (traceDir !== undefined) {
|
||||
await mkdir(traceDir, { recursive: true })
|
||||
await writeFile(join(traceDir, `${format}-${kind}-${intent}.jsonl`), bytes)
|
||||
}
|
||||
const events = parseSessionLog(bytes.toString('utf8'))
|
||||
const calls = events.filter(event => event.type === 'tool/call').map(event => event.data)
|
||||
const loaded = calls.filter(call => call.name === 'skill').map(call => (JSON.parse(call.arguments) as { name: string }).name)
|
||||
const imageReads = calls.filter(call => call.name === 'read_image')
|
||||
const shellCommands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh')
|
||||
.map(call => (JSON.parse(call.arguments) as { command: string }).command)
|
||||
const answer = events.findLast(event => event.type === 'assistant/message')
|
||||
const text = answer?.type === 'assistant/message'
|
||||
? answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') : undefined
|
||||
const callLog = await readFile(callLogPath, 'utf8').catch((error: unknown) => {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return ''
|
||||
throw error
|
||||
})
|
||||
const renderCalls = callLog.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { args: string[]; status: number | null; pixelsBlank: boolean | null })
|
||||
.filter(call => call.args[0] === 'render')
|
||||
const diagnostic = JSON.stringify({
|
||||
scenario: `${format} ${kind} ${intent}`, loadedSkills: loaded,
|
||||
shellCommands: shellCommands.map(command => command.slice(0, 600)),
|
||||
renderCalls, imageReads: imageReads.length,
|
||||
answer: text?.slice(0, 800) ?? '(missing)',
|
||||
}, null, 2)
|
||||
if (text === undefined) throw new Error(`Agent did not finish with an answer: ${diagnostic}`)
|
||||
const checked = await execa(python!, ['-c', verify], { cwd, reject: false })
|
||||
expect(checked.exitCode, `${checked.stderr}\n${diagnostic}`).toBe(0)
|
||||
expect(loaded, diagnostic).toContain(`office-${format}`)
|
||||
if (intent === 'excluded' || kind === 'content' && intent !== 'explicit') {
|
||||
expect(loaded, diagnostic).not.toContain('office-render')
|
||||
expect(renderCalls, diagnostic).toEqual([])
|
||||
expect(imageReads, diagnostic).toEqual([])
|
||||
} else {
|
||||
expect(loaded, diagnostic).toContain('office-render')
|
||||
expect(renderCalls, diagnostic).toHaveLength(1)
|
||||
expect(imageReads.length, diagnostic).toBeLessThanOrEqual(1)
|
||||
for (const call of renderCalls) {
|
||||
const output = call.args[call.args.indexOf('--output-dir') + 1]
|
||||
expect(output, diagnostic).toBeDefined()
|
||||
expect(within(cwd, resolve(cwd, output!)), diagnostic).toBe(true)
|
||||
expect(call.status, diagnostic).toBe(0)
|
||||
}
|
||||
if (renderCalls[0]?.pixelsBlank || imageReads.length === 0) {
|
||||
expect(text.match(/Content and structure checks passed; visual inspection was not completed\./gu), diagnostic).toHaveLength(1)
|
||||
expect(text, diagnostic).not.toMatch(/all checks passed|visually (?:verified|validated|confirmed)/iu)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await rm(controlRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Real Office visual-intent e2e cases for DOCX documents. */
|
||||
import { registerVisualIntentCases } from './fixtures/visual-intent.ts'
|
||||
|
||||
registerVisualIntentCases(['docx'])
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Real Office visual-intent e2e cases for PPTX documents, including the blank-preview case. */
|
||||
import { registerVisualIntentCases } from './fixtures/visual-intent.ts'
|
||||
|
||||
registerVisualIntentCases(['pptx'], { blank: true })
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Real Office visual-intent e2e cases for XLSX documents. */
|
||||
import { registerVisualIntentCases } from './fixtures/visual-intent.ts'
|
||||
|
||||
registerVisualIntentCases(['xlsx'])
|
||||
@@ -1,152 +0,0 @@
|
||||
/** Real Office authoring tasks with explicit, excluded, and implicit visual inspection. */
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { officeModelPatches } from './fixtures/real-office.ts'
|
||||
import { renderCliFixture } from './fixtures/render-cli.ts'
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const assetRoot = fileURLToPath(new URL('../assets/', import.meta.url))
|
||||
const runtime = process.env.DSH_PRIMARY_RUNTIME
|
||||
const python = runtime && join(runtime, 'dependencies/python', process.platform === 'win32' ? 'python.exe' : 'bin/python3')
|
||||
|
||||
const formats = [
|
||||
{
|
||||
contentTask: 'change the title text on slide 1 to Quarterly results without changing its formatting',
|
||||
verifyContent: "from pptx import Presentation\np=Presentation('result.pptx'); assert len(p.slides) == 1; assert p.slides[0].shapes[0].text == 'Quarterly results'",
|
||||
format: 'pptx', task: 'center the title on slide 1 and set its font color to #1F4E79',
|
||||
seed: "from pptx import Presentation\nfrom pptx.util import Inches\np=Presentation(); s=p.slides.add_slide(p.slide_layouts[6]); s.shapes.add_textbox(Inches(1),Inches(1),Inches(6),Inches(1)).text='Quarterly revenue'; p.save('input.pptx')",
|
||||
verify: "from pptx import Presentation\nfrom pptx.enum.text import PP_ALIGN\np=Presentation('result.pptx'); title=p.slides[0].shapes[0]; r=title.text_frame.paragraphs[0]; assert r.alignment == PP_ALIGN.CENTER; assert abs(2*title.left+title.width-p.slide_width) <= 1; assert str(r.runs[0].font.color.rgb).upper() == '1F4E79'",
|
||||
},
|
||||
{
|
||||
contentTask: 'change the title paragraph text to Quarterly results without changing its formatting',
|
||||
verifyContent: "from docx import Document\nd=Document('result.docx'); assert [p.text for p in d.paragraphs] == ['Quarterly results', 'Q1: 12; Q2: 18']",
|
||||
format: 'docx', task: 'center the title paragraph and set its font color to #1F4E79',
|
||||
seed: "from docx import Document\nd=Document(); d.add_paragraph('Quarterly revenue'); d.add_paragraph('Q1: 12; Q2: 18'); d.save('input.docx')",
|
||||
verify: "from docx import Document\nfrom docx.enum.text import WD_ALIGN_PARAGRAPH\nd=Document('result.docx'); p=d.paragraphs[0]; assert p.alignment == WD_ALIGN_PARAGRAPH.CENTER; assert p.runs[0].font.color.rgb == (31,78,121)",
|
||||
},
|
||||
{
|
||||
contentTask: 'change Revenue!B2 to 20 without changing the cell formatting or other data',
|
||||
verifyContent: "from openpyxl import load_workbook\nw=load_workbook('result.xlsx'); s=w['Revenue']; assert list(s.values) == [('Quarter','Revenue'),('Q1',20),('Q2',18)]",
|
||||
format: 'xlsx', task: 'center the text in Revenue!A1:B1 and set the cells\' fill color to #1F4E79',
|
||||
seed: "from openpyxl import Workbook\nw=Workbook(); s=w.active; s.title='Revenue'; s.append(['Quarter','Revenue']); s.append(['Q1',12]); s.append(['Q2',18]); w.save('input.xlsx')",
|
||||
verify: "from openpyxl import load_workbook\nw=load_workbook('result.xlsx'); s=w['Revenue']; assert all(s[c].alignment.horizontal == 'center' and s[c].fill.fgColor.rgb.endswith('1F4E79') for c in ('A1','B1')); assert s['B2'].value == 12 and s['B3'].value == 18",
|
||||
},
|
||||
] as const
|
||||
|
||||
const intents = [
|
||||
{ intent: 'explicit', suffix: 'Render the finished file and visually inspect the changed page or sheet.' },
|
||||
{ intent: 'excluded', suffix: 'Do not render or visually inspect the finished file.' },
|
||||
{ intent: 'implicit', suffix: '' },
|
||||
] as const
|
||||
|
||||
const cases = [
|
||||
...formats.flatMap(format => intents.flatMap(intent => [
|
||||
{ ...format, ...intent, kind: 'format' },
|
||||
{ ...format, ...intent, kind: 'content', task: format.contentTask, verify: format.verifyContent },
|
||||
])),
|
||||
{ ...formats[0], kind: 'format', intent: 'blank', suffix: intents[0].suffix },
|
||||
] as const
|
||||
|
||||
function within(parent: string, path: string): boolean {
|
||||
const part = relative(parent, path)
|
||||
return part === '' || part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
|
||||
}
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(cases)('$format $kind $intent visual intent', async ({ format, kind, task, seed, verify, intent, suffix }) => {
|
||||
const controlRoot = await mkdtemp(join(tmpdir(), 'dsh-office-control-test-'))
|
||||
try {
|
||||
const previewRoot = join(controlRoot, 'tmp')
|
||||
await mkdir(previewRoot)
|
||||
const configPath = join(controlRoot, 'office.patch.json')
|
||||
const sessionRoot = join(controlRoot, 'sessions')
|
||||
// The model can remove its entire preview directory during cleanup.
|
||||
const callLogPath = join(controlRoot, 'cli-calls.jsonl')
|
||||
await runLoaderSmoke({
|
||||
label: `Office ${format} ${kind} ${intent} visual intent`, tempDirPrefix: `dsh-office-${format}-${kind}-${intent}-`,
|
||||
binScript: join(repo, 'apps/cli/src/bin.ts'), sourceImport: 'tsx/esm', tsconfigPath: join(repo, 'tsconfig.base.json'),
|
||||
configPath, processTimeoutMs: 110_000,
|
||||
binArgs: ['--profile', 'headless', '--patch', configPath,
|
||||
`In input.${format}, ${task}; save result.${format}. ${suffix} Respond briefly in English.`],
|
||||
// The CLI fixture and its log live outside the workspace; bwrap's private /tmp hides both.
|
||||
env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_HOME: join(controlRoot, 'home'), DSH_AGENTS_HOME: join(controlRoot, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
TMPDIR: previewRoot, TMP: previewRoot, TEMP: previewRoot,
|
||||
HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY },
|
||||
async prepare(cwd) {
|
||||
await execa(python!, ['-c', seed], { cwd })
|
||||
const fixture = await renderCliFixture(controlRoot, python!, intent === 'blank', callLogPath)
|
||||
const testAssetRoot = process.env.DSH_OFFICE_TEST_ASSET_ROOT ?? assetRoot
|
||||
await writeFile(configPath, JSON.stringify([
|
||||
{ insert: [{ id: 'skill-office', name: '@deepseek-ai/dsh-skill-office', config: { assetRoot: testAssetRoot, node: process.execPath, cli: fixture.cli } }] },
|
||||
{ insert: [{ id: 'workspace-dependencies', name: '@deepseek-ai/dsh-tool-workspace-dependencies', config: { source: runtime! } }] },
|
||||
...officeModelPatches(),
|
||||
{ id: 'session-persistence-jsonl', config: { root: sessionRoot, compression: 'none' } },
|
||||
]))
|
||||
},
|
||||
async inspect(cwd) {
|
||||
const logs = await readdir(sessionRoot, { recursive: true })
|
||||
const log = logs.find(path => path.endsWith('session.v4.jsonl'))
|
||||
expect(log, `No recorded session for ${format} ${intent} in ${sessionRoot}`).toBeDefined()
|
||||
const bytes = await readFile(join(sessionRoot, log!))
|
||||
const traceDir = process.env.DSH_OFFICE_TRACE_DIR
|
||||
if (traceDir !== undefined) {
|
||||
await mkdir(traceDir, { recursive: true })
|
||||
await writeFile(join(traceDir, `${format}-${kind}-${intent}.jsonl`), bytes)
|
||||
}
|
||||
const events = parseSessionLog(bytes.toString('utf8'))
|
||||
const calls = events.filter(event => event.type === 'tool/call').map(event => event.data)
|
||||
const loaded = calls.filter(call => call.name === 'skill').map(call => (JSON.parse(call.arguments) as { name: string }).name)
|
||||
const imageReads = calls.filter(call => call.name === 'read_image')
|
||||
const shellCommands = calls.filter(call => call.name === 'bash' || call.name === 'pwsh')
|
||||
.map(call => (JSON.parse(call.arguments) as { command: string }).command)
|
||||
const answer = events.findLast(event => event.type === 'assistant/message')
|
||||
const text = answer?.type === 'assistant/message'
|
||||
? answer.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') : undefined
|
||||
const callLog = await readFile(callLogPath, 'utf8').catch((error: unknown) => {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return ''
|
||||
throw error
|
||||
})
|
||||
const renderCalls = callLog.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as { args: string[]; status: number | null; pixelsBlank: boolean | null })
|
||||
.filter(call => call.args[0] === 'render')
|
||||
const diagnostic = JSON.stringify({
|
||||
scenario: `${format} ${kind} ${intent}`, loadedSkills: loaded,
|
||||
shellCommands: shellCommands.map(command => command.slice(0, 600)),
|
||||
renderCalls, imageReads: imageReads.length,
|
||||
answer: text?.slice(0, 800) ?? '(missing)',
|
||||
}, null, 2)
|
||||
if (text === undefined) throw new Error(`Agent did not finish with an answer: ${diagnostic}`)
|
||||
const checked = await execa(python!, ['-c', verify], { cwd, reject: false })
|
||||
expect(checked.exitCode, `${checked.stderr}\n${diagnostic}`).toBe(0)
|
||||
expect(loaded, diagnostic).toContain(`office-${format}`)
|
||||
if (intent === 'excluded' || kind === 'content' && intent !== 'explicit') {
|
||||
expect(loaded, diagnostic).not.toContain('office-render')
|
||||
expect(renderCalls, diagnostic).toEqual([])
|
||||
expect(imageReads, diagnostic).toEqual([])
|
||||
} else {
|
||||
expect(loaded, diagnostic).toContain('office-render')
|
||||
expect(renderCalls, diagnostic).toHaveLength(1)
|
||||
expect(imageReads.length, diagnostic).toBeLessThanOrEqual(1)
|
||||
for (const call of renderCalls) {
|
||||
const output = call.args[call.args.indexOf('--output-dir') + 1]
|
||||
expect(output, diagnostic).toBeDefined()
|
||||
expect(within(cwd, resolve(cwd, output!)), diagnostic).toBe(true)
|
||||
expect(call.status, diagnostic).toBe(0)
|
||||
}
|
||||
if (renderCalls[0]?.pixelsBlank || imageReads.length === 0) {
|
||||
expect(text.match(/Content and structure checks passed; visual inspection was not completed\./gu), diagnostic).toHaveLength(1)
|
||||
expect(text, diagnostic).not.toMatch(/all checks passed|visually (?:verified|validated|confirmed)/iu)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await rm(controlRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,22 @@
|
||||
diff --git a/lib-es5/sea-assets.js b/lib-es5/sea-assets.js
|
||||
index a0cb0de8491d4eff37aba7eaaeeec5934f5a9985..f6bb9a35ce2c1978fb08a3400a36a86ce7a43c43 100644
|
||||
--- a/lib-es5/sea-assets.js
|
||||
+++ b/lib-es5/sea-assets.js
|
||||
@@ -69,7 +69,13 @@ function resolveCompressor(type) {
|
||||
case compress_type_1.CompressType.GZip:
|
||||
return zlib.gzipSync;
|
||||
case compress_type_1.CompressType.Brotli:
|
||||
- return zlib.brotliCompressSync;
|
||||
+ // DSH patch: pkg calls brotliCompressSync without options, so every
|
||||
+ // archive is compressed at Brotli's default quality 11 -- the
|
||||
+ // slowest setting. Quality 6 keeps the archive within 12% of that
|
||||
+ // size at a fortieth of the build time.
|
||||
+ return (buf) => zlib.brotliCompressSync(buf, {
|
||||
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 6 },
|
||||
+ });
|
||||
case compress_type_1.CompressType.Zstd:
|
||||
return (0, compress_type_1.getZstdCompressSync)();
|
||||
default: {
|
||||
diff --git a/prelude/sea-bootstrap.bundle.js b/prelude/sea-bootstrap.bundle.js
|
||||
index ac14980e953134f2285e326a5111cf52de717cf5..272dbc51ccefb5ce1ab87ffcf5916fbd26225046 100644
|
||||
--- a/prelude/sea-bootstrap.bundle.js
|
||||
@@ -127,7 +146,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..272dbc51ccefb5ce1ab87ffcf5916fbd
|
||||
}
|
||||
existsSync(filePath) {
|
||||
perf2.count("existsSync calls");
|
||||
@@ -5542,6 +5606,6 @@ var require_sea_vfs_setup = __commonJS({
|
||||
@@ -5542,7 +5606,7 @@ var require_sea_vfs_setup = __commonJS({
|
||||
var require_worker_bootstrap_string = __commonJS({
|
||||
"prelude/_worker-bootstrap-string.js"(exports2, module2) {
|
||||
"use strict";
|
||||
@@ -135,6 +154,7 @@ index ac14980e953134f2285e326a5111cf52de717cf5..272dbc51ccefb5ce1ab87ffcf5916fbd
|
||||
+ module2.exports = '"use strict";\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __commonJS = (cb, mod) => function __require() {\n try {\n return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;\n } catch (e) {\n throw mod = 0, e;\n }\n};\n\n// prelude/bootstrap-shared.js\nvar require_bootstrap_shared = __commonJS({\n "prelude/bootstrap-shared.js"(exports2, module2) {\n "use strict";\n var childProcess = require("child_process");\n var { createHash } = require("crypto");\n var fs = require("fs");\n var path = require("path");\n var zlib = require("zlib");\n var { homedir } = require("os");\n var COMPRESS_NONE = 0;\n var COMPRESS_GZIP = 1;\n var COMPRESS_BROTLI = 2;\n var COMPRESS_ZSTD = 3;\n function zstdMissingError(symbol) {\n return new Error(\n "pkg: Zstd compression requires Node.js >= 22.15 (runtime missing zlib." + symbol + "). Re-package this binary with pkg >= the version that embeds Node 22.15+, or contact the distributor for a --compress Brotli/GZip build."\n );\n }\n function pickDecompressorSync(compression) {\n switch (compression) {\n case COMPRESS_NONE:\n return null;\n case COMPRESS_GZIP:\n return zlib.gunzipSync;\n case COMPRESS_BROTLI:\n return zlib.brotliDecompressSync;\n case COMPRESS_ZSTD:\n if (typeof zlib.zstdDecompressSync !== "function") {\n throw zstdMissingError("zstdDecompressSync");\n }\n return zlib.zstdDecompressSync;\n default:\n throw new Error(\n "pkg: unknown compression codec id " + compression + " in manifest"\n );\n }\n }\n function pickDecompressorAsync(compression) {\n switch (compression) {\n case COMPRESS_NONE:\n return null;\n case COMPRESS_GZIP:\n return zlib.gunzip;\n case COMPRESS_BROTLI:\n return zlib.brotliDecompress;\n case COMPRESS_ZSTD:\n if (typeof zlib.zstdDecompress !== "function") {\n throw zstdMissingError("zstdDecompress");\n }\n return zlib.zstdDecompress;\n default:\n throw new Error("pkg: unknown compression codec id " + compression);\n }\n }\n function cpRecursive(src, dest) {\n var st = fs.lstatSync(src);\n if (st.isSymbolicLink()) {\n var target = fs.readlinkSync(src);\n try {\n fs.unlinkSync(dest);\n } catch (_) {\n }\n try {\n fs.symlinkSync(target, dest);\n return;\n } catch (e) {\n if (e && (e.code === "EPERM" || e.code === "EACCES")) {\n var resolved = path.isAbsolute(target) ? target : path.join(path.dirname(src), target);\n cpRecursive(resolved, dest);\n return;\n }\n throw e;\n }\n }\n if (st.isDirectory()) {\n fs.mkdirSync(dest, { recursive: true });\n var entries = fs.readdirSync(src);\n for (var i = 0; i < entries.length; i++) {\n cpRecursive(path.join(src, entries[i]), path.join(dest, entries[i]));\n }\n return;\n }\n var srcContent = fs.readFileSync(src);\n if (fs.existsSync(dest)) {\n var destContent = fs.readFileSync(dest);\n var srcHash = createHash("sha256").update(srcContent).digest("hex");\n var destHash = createHash("sha256").update(destContent).digest("hex");\n if (srcHash === destHash) {\n return;\n }\n }\n fs.writeFileSync(dest, srcContent);\n }\n function patchDlopen(insideSnapshot) {\n var ancestor = process.dlopen;\n var PKG_NATIVE_CACHE_BASE = process.env.PKG_NATIVE_CACHE_PATH || path.join(homedir(), ".cache");\n function revertMakingLong(f) {\n if (/^\\\\\\\\\\?\\\\/.test(f)) return f.slice(4);\n return f;\n }\n process.dlopen = function dlopen() {\n var args = Array.prototype.slice.call(arguments);\n var modulePath = revertMakingLong(args[1]);\n var moduleBaseName = path.basename(modulePath);\n var moduleFolder = path.dirname(modulePath);\n if (insideSnapshot(modulePath)) {\n var moduleContent = fs.readFileSync(modulePath);\n var hash = createHash("sha256").update(moduleContent).digest("hex");\n var tmpFolder = path.join(PKG_NATIVE_CACHE_BASE, "pkg", hash);\n fs.mkdirSync(tmpFolder, { recursive: true });\n var parts = moduleFolder.split(path.sep);\n var mIndex = parts.lastIndexOf("node_modules") + 1;\n var newPath;\n if (mIndex > 0) {\n var modulePackagePath = parts.slice(mIndex).join(path.sep);\n var modulePkgFolder = parts.slice(0, mIndex + 1).join(path.sep);\n var destFolder = path.join(tmpFolder, path.basename(modulePkgFolder));\n cpRecursive(modulePkgFolder, dest
Line truncated
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5560,7 +5624,6 @@ var require_sea_bootstrap_core = __commonJS({
|
||||
var insideSnapshot = vfs.insideSnapshot;
|
||||
var SNAPSHOT_PREFIX = vfs.SNAPSHOT_PREFIX;
|
||||
|
||||
Generated
+3
-3
@@ -15,7 +15,7 @@ patchedDependencies:
|
||||
'@electron/[email protected]': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
|
||||
'@fortune-sheet/[email protected]': 8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532
|
||||
'@fortune-sheet/[email protected]': 2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37
|
||||
'@yao-pkg/[email protected]': 4d4861969595629311f7d90a0b34b15481c96d5682d828a151a104c69d4616d3
|
||||
'@yao-pkg/[email protected]': 236fb467509b18abae4c09b1727bbe0239e05a6fbb4d35f84fff3821ca2f6771
|
||||
[email protected]: b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348
|
||||
[email protected]: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
|
||||
[email protected]: 54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356
|
||||
@@ -177,7 +177,7 @@ importers:
|
||||
version: 4.1.11([email protected])
|
||||
'@yao-pkg/pkg':
|
||||
specifier: 6.21.0
|
||||
version: 6.21.0(patch_hash=4d4861969595629311f7d90a0b34b15481c96d5682d828a151a104c69d4616d3)([email protected])
|
||||
version: 6.21.0(patch_hash=236fb467509b18abae4c09b1727bbe0239e05a6fbb4d35f84fff3821ca2f6771)([email protected])
|
||||
'@yarnpkg/cli-dist':
|
||||
specifier: 4.18.0
|
||||
version: 4.18.0
|
||||
@@ -28771,7 +28771,7 @@ snapshots:
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
'@yao-pkg/[email protected](patch_hash=4d4861969595629311f7d90a0b34b15481c96d5682d828a151a104c69d4616d3)([email protected])':
|
||||
'@yao-pkg/[email protected](patch_hash=236fb467509b18abae4c09b1727bbe0239e05a6fbb4d35f84fff3821ca2f6771)([email protected])':
|
||||
dependencies:
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
|
||||
@@ -159,6 +159,19 @@ class Target {
|
||||
/**
|
||||
* Validated CLI configuration; construction owns help and parse-error exits.
|
||||
*/
|
||||
/** pkg's `--compress` values, whose build cost differs by two orders of magnitude. */
|
||||
const COMPRESS_TYPES = ['None', 'GZip', 'Brotli', 'Zstd'] as const
|
||||
type CompressType = typeof COMPRESS_TYPES[number]
|
||||
|
||||
/**
|
||||
* Narrow a raw `--compress` value to a supported pkg compressor.
|
||||
* @param value - the command-line value.
|
||||
* @returns whether pkg accepts the value.
|
||||
*/
|
||||
function isCompressType(value: string): value is CompressType {
|
||||
return (COMPRESS_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
class BuildCli {
|
||||
private constructor(
|
||||
/** Build targets; defaults to the host platform only. */
|
||||
@@ -167,6 +180,8 @@ class BuildCli {
|
||||
readonly skipBuild: boolean,
|
||||
/** Build through `pnpm run build:artifacts`, which omits the aggregate test and script typechecks. */
|
||||
readonly buildArtifacts: boolean,
|
||||
/** pkg archive compression; Brotli is the smallest and by far the slowest. */
|
||||
readonly compress: CompressType,
|
||||
/** Print every command and config patch instead of executing. */
|
||||
readonly dryRun: boolean,
|
||||
) {}
|
||||
@@ -205,7 +220,11 @@ class BuildCli {
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
return new BuildCli(targets, values['skip-build'], values['build-artifacts'], values['dry-run'])
|
||||
const compress = values.compress ?? 'Brotli'
|
||||
if (!isCompressType(compress)) {
|
||||
throw new Error(`build-exe-for-python-sdk: --compress must be one of ${COMPRESS_TYPES.join(' | ')}, got ${JSON.stringify(compress)}.`)
|
||||
}
|
||||
return new BuildCli(targets, values['skip-build'], values['build-artifacts'], compress, values['dry-run'])
|
||||
}
|
||||
|
||||
private static parseRaw(argv: string[]) {
|
||||
@@ -215,6 +234,7 @@ class BuildCli {
|
||||
'targets': { type: 'string' },
|
||||
'skip-build': { type: 'boolean', default: false },
|
||||
'build-artifacts': { type: 'boolean', default: false },
|
||||
'compress': { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'help': { type: 'boolean', default: false },
|
||||
},
|
||||
@@ -229,10 +249,11 @@ class BuildCli {
|
||||
' Default: the host platform only (on node24).',
|
||||
' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
|
||||
' --build-artifacts run `pnpm run build:artifacts` instead, omitting the aggregate test and script typechecks.',
|
||||
' --compress=<type> pkg archive compression: None | GZip | Brotli | Zstd. Default: Brotli (smallest, slowest).',
|
||||
' --dry-run print every command and config patch without executing.',
|
||||
' --help print this help.',
|
||||
'',
|
||||
'Build route: @yao-pkg/pkg --sea --compress Brotli (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
|
||||
'Build route: @yao-pkg/pkg --sea with the selected --compress type (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
|
||||
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
|
||||
].join('\n')
|
||||
}
|
||||
@@ -400,7 +421,7 @@ class SingleExeBuild {
|
||||
this.staging,
|
||||
'--sea',
|
||||
'--compress',
|
||||
'Brotli',
|
||||
this.cli.compress,
|
||||
'--targets',
|
||||
target.spec,
|
||||
'--output',
|
||||
|
||||
@@ -14,7 +14,7 @@ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{
|
||||
const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
|
||||
|
||||
describe('CI workflow', () => {
|
||||
it('keeps at most ten worker jobs active across PR workflows while Python runtime targets follow the SDK suite', () => {
|
||||
it('keeps at most eleven worker jobs active across PR workflows while Python runtime targets follow the SDK suite', () => {
|
||||
const ci = loadWorkflow('.github/workflows/ci.yml')
|
||||
const ciWindows = loadWorkflow('.github/workflows/ci-windows.yml')
|
||||
const ciPython = loadWorkflow('.github/workflows/ci-python.yml')
|
||||
@@ -86,10 +86,12 @@ describe('CI workflow', () => {
|
||||
// Python's runtime targets start only after the SDK suite; Windows starts
|
||||
// immediately. Plan and wheel stages precede the two-target matrix.
|
||||
const windowsActive = windowsJobs.length
|
||||
// The lint lane owns its own runner, so it raises the ceiling by one; the
|
||||
// self-hosted failover pool is still admitted only up to its own limit.
|
||||
const beforeSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + 1
|
||||
const afterSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + Math.max(1, targets.split(',').length)
|
||||
expect(beforeSdk).toBeLessThanOrEqual(10)
|
||||
expect(afterSdk).toBeLessThanOrEqual(10)
|
||||
expect(beforeSdk).toBeLessThanOrEqual(11)
|
||||
expect(afterSdk).toBeLessThanOrEqual(11)
|
||||
})
|
||||
|
||||
it('keeps the union of required PR verdicts equal to the pre-split blocking set', () => {
|
||||
@@ -117,6 +119,7 @@ describe('CI workflow', () => {
|
||||
'node-24-bench',
|
||||
'node-24-consumers',
|
||||
'node-24-coverage',
|
||||
'node-24-lint',
|
||||
'node-compat',
|
||||
'python-runtime',
|
||||
'python-sdk',
|
||||
@@ -371,13 +374,14 @@ describe('CI workflow', () => {
|
||||
|| !isRecord(windowsWorkflow.jobs['windows-checks-passed'])
|
||||
|| !isRecord(workflow.jobs['node-24'])
|
||||
|| !isRecord(workflow.jobs['node-24-coverage'])
|
||||
|| !isRecord(workflow.jobs['node-24-lint'])
|
||||
|| !isRecord(workflow.jobs['node-24-bench'])
|
||||
|| !isRecord(workflow.jobs['node-24-consumers'])
|
||||
|| !isRecord(workflow.jobs['node-compat'])
|
||||
|| !isRecord(workflow.jobs['all-checks-passed'])
|
||||
|| !isRecord(masterWorkflow.jobs)
|
||||
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
|
||||
throw new TypeError('CI workflow must define node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; CI Windows must define windows-build, windows-coverage, windows-native-tests, and windows-checks-passed; ci-master must define serial-windows')
|
||||
throw new TypeError('CI workflow must define node-24, node-24-coverage, node-24-lint, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; CI Windows must define windows-build, windows-coverage, windows-native-tests, and windows-checks-passed; ci-master must define serial-windows')
|
||||
}
|
||||
|
||||
const windowsBuild = windowsWorkflow.jobs['windows-build']
|
||||
@@ -670,12 +674,19 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs required benchmarks on standard hosted Linux independently of failover', () => {
|
||||
it('runs required benchmarks on a four-vCPU Blacksmith runner and falls back to hosted Linux', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
const benchmark = workflowJob(workflow, 'node-24-bench')
|
||||
const aggregate = workflowJob(workflow, 'all-checks-passed')
|
||||
|
||||
expect(benchmark['runs-on']).toBe('ubuntu-24.04')
|
||||
// The shared self-hosted pool is not an idle runner, so only the
|
||||
// Blacksmith leg moves the benchmarks off hosted Linux.
|
||||
expect(evaluateRunsOn(benchmark['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: 'blacksmith' } }))
|
||||
.toBe('blacksmith-4vcpu-ubuntu-2404')
|
||||
for (const mode of ['', 'selfhosted', 'unexpected']) {
|
||||
expect(evaluateRunsOn(benchmark['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: mode } })).toBe('ubuntu-24.04')
|
||||
}
|
||||
expect(JSON.stringify(benchmark.steps)).not.toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(benchmark.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(benchmark.needs).toBeUndefined()
|
||||
expect(benchmark['continue-on-error']).toBeUndefined()
|
||||
@@ -1078,7 +1089,7 @@ describe('DeepSeek e2e workflow', () => {
|
||||
if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps')
|
||||
|
||||
const step = e2e.steps.filter(isRecord).find(candidate => candidate.name === 'E2E tests (real DeepSeek API)')
|
||||
expect(step).toMatchObject({ env: { DSH_E2E_MAX_WORKERS: 4 } })
|
||||
expect(step).toMatchObject({ env: { DSH_E2E_MAX_WORKERS: 6 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1575,8 +1586,13 @@ describe('npm release workflows', () => {
|
||||
isRecord(step) && typeof step.run === 'string' ? [step.run] : [])
|
||||
|
||||
expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch'])
|
||||
expect(commands).toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).toContain('pnpm run verify-npm-install-layout')
|
||||
// Both verifications run from one step so the lane pays their longer half.
|
||||
const combined = commands.find(command => command.includes('verify-package-dependencies'))
|
||||
expect(combined).toContain('verify-npm-install-layout')
|
||||
expect(combined).toContain('wait "$dependencies_pid"')
|
||||
expect(commands).not.toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).not.toContain('pnpm run verify-npm-install-layout')
|
||||
expect(String(dependencies['runs-on'])).toContain('blacksmith-4vcpu-ubuntu-2404')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Worker entry: collect the source package uses of one slice of workspace packages. */
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { readAllSourceUses } from './source-parse-uses.ts'
|
||||
import type { PackageDependencyManifest } from './verify-package-dependencies.ts'
|
||||
|
||||
/** One package whose `src` tree this worker walks. */
|
||||
interface SourceUsesTask {
|
||||
/** Position of the package in the caller's selection, used to restore that order. */
|
||||
readonly index: number
|
||||
/** Package directory relative to the repository root. */
|
||||
readonly dir: string
|
||||
/** Package name, reported back so the caller can key the result. */
|
||||
readonly name: string
|
||||
/** Workspace manifest of the package, which decides @types-versus-runtime usage. */
|
||||
readonly manifest: PackageDependencyManifest
|
||||
}
|
||||
|
||||
/** Work one worker receives when it starts. */
|
||||
export interface SourceUsesWorkerRequest {
|
||||
/** Repository root containing the workspace sources. */
|
||||
readonly root: string
|
||||
/** Packages this worker walks, in caller order. */
|
||||
readonly tasks: readonly SourceUsesTask[]
|
||||
}
|
||||
|
||||
/** Source uses collected for one package. */
|
||||
export interface SourceUsesWorkerResult {
|
||||
/** Package name of the analysed package. */
|
||||
readonly name: string
|
||||
/** Bare package name to the repository paths whose source references it. */
|
||||
readonly uses: Map<string, string[]>
|
||||
}
|
||||
|
||||
const request = workerData as SourceUsesWorkerRequest
|
||||
const results: SourceUsesWorkerResult[] = request.tasks.map(task => ({
|
||||
name: task.name,
|
||||
uses: readAllSourceUses(request.root, {
|
||||
dir: task.dir,
|
||||
manifestPath: `${task.dir}/package.json`,
|
||||
manifest: task.manifest,
|
||||
name: task.name,
|
||||
}),
|
||||
}))
|
||||
parentPort?.postMessage(results)
|
||||
@@ -10,28 +10,48 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github
|
||||
env: Record<string, string>
|
||||
jobs: Record<'preview', {
|
||||
'runs-on': string
|
||||
steps: Array<{ name?: string; uses?: string; run?: string; with?: Record<string, unknown>; env?: Record<string, string> }>
|
||||
steps: Array<{ name?: string; uses?: string; run?: string; if?: string; 'continue-on-error'?: boolean; with?: Record<string, unknown>; env?: Record<string, string> }>
|
||||
}>
|
||||
}
|
||||
const preview = workflow.jobs.preview
|
||||
|
||||
describe('PR preview workflow', () => {
|
||||
it('keeps every PR author on the selected GitHub-hosted runner', () => {
|
||||
it('keeps every PR author on the selected runner', () => {
|
||||
expect(Object.keys(workflow.jobs)).toEqual(['preview'])
|
||||
expect(preview['runs-on']).toBe('ubuntu-24.04')
|
||||
// The required outcome is the build, so the lane runs on four vCPUs; the
|
||||
// hosted label stays as the fallback when the Blacksmith pool is not chosen.
|
||||
expect(preview['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
|
||||
expect(preview['runs-on']).toContain("'ubuntu-24.04'")
|
||||
expect(preview['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(workflow.on).toEqual({ pull_request: { types: ['opened', 'synchronize', 'reopened'] } })
|
||||
expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'write' })
|
||||
expect(preview.steps.find(step => step.uses === 'actions/[email protected]')?.with).toEqual({ 'persist-credentials': false })
|
||||
})
|
||||
|
||||
it('fails the check only for build failures and warns about publication', () => {
|
||||
const byName = (name: string) => preview.steps.find(step => step.name === name)
|
||||
for (const name of ['Build workspace and check aggregate programs', 'Build the preview page and pack the VFS image', 'Shape the upload']) {
|
||||
expect(byName(name)?.['continue-on-error'], `${name} must stay blocking`).toBeUndefined()
|
||||
}
|
||||
for (const name of ['Upload to Cloudflare Pages', 'Verify the protected deployment serves the image', 'Comment the preview URL']) {
|
||||
expect(byName(name)?.['continue-on-error'], `${name} must warn instead of failing`).toBe(true)
|
||||
}
|
||||
const report = byName('Report preview publication failures')
|
||||
expect(report?.if).toBe("steps.upload.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.comment.outcome == 'failure'")
|
||||
expect(report?.run).toContain('::warning::')
|
||||
})
|
||||
|
||||
it('keeps the immutable complete artifact build and restore-only dependency cache', () => {
|
||||
expect(workflow.env.PRIMARY_NODE_VERSION).toBe('24')
|
||||
expect(workflow.env.DSH_TELEMETRY_DISABLED).toBe('1')
|
||||
const commands = preview.steps.map(step => step.run)
|
||||
expect(commands).toContain('pnpm install --frozen-lockfile')
|
||||
expect(commands).toContain('pnpm run build:artifacts')
|
||||
expect(commands).toContain('pnpm run build')
|
||||
expect(commands).toContain('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview')
|
||||
expect(commands.indexOf('pnpm run build:artifacts')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
|
||||
expect(commands.indexOf('pnpm run build')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
|
||||
// The complete build also runs the aggregate type checks the required
|
||||
// consumers dropped.
|
||||
expect(commands).not.toContain('pnpm run build:check')
|
||||
expect(preview.steps.filter(step => step.uses?.startsWith('actions/cache'))).toHaveLength(1)
|
||||
expect(preview.steps.find(step => step.uses === 'actions/cache/[email protected]')?.with).toMatchObject({
|
||||
key: "${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Run the benchmark suite in the two phases its measurements tolerate. */
|
||||
import { resolve } from 'node:path'
|
||||
import { runPnpmCommand } from './compile-referenced-projects.ts'
|
||||
|
||||
/**
|
||||
* Cases whose wall-clock endpoints cannot absorb a sibling: they drive a
|
||||
* browser page, compare animation frames or paced streams, or spawn fresh
|
||||
* children and budget their startup. Corpus cases tolerate a sibling.
|
||||
*/
|
||||
export const SERIAL_BENCH_FILES = [
|
||||
'benchmarks/long-session-browser/long-session.bench.ts',
|
||||
'benchmarks/user-text/user-text.bench.client.ts',
|
||||
'benchmarks/conversation-fold/conversation-fold.bench.client.ts',
|
||||
'benchmarks/active-stream-reconnect/reconnect.bench.client.ts',
|
||||
// Fresh-child reopen case: its endpoints budget 63 ms and 120 ms, and a
|
||||
// sibling process pushed them to 76 ms and 131 ms.
|
||||
'benchmarks/session-open/session-open.bench.ts',
|
||||
// Request-history budgets 297 ms against a 212 ms healthy median; a sibling
|
||||
// pushed it to 342 ms.
|
||||
'benchmarks/agent-continuation/agent-continuation.bench.ts',
|
||||
] as const
|
||||
|
||||
const CONFIG = ['--config', 'vitest.bench.config.ts'] as const
|
||||
|
||||
/**
|
||||
* Run one phase of the suite.
|
||||
* @param root - Repository root containing the Vitest configuration.
|
||||
* @param phase - `first` clears the report and runs the CPU cases two at a time;
|
||||
* `last` runs the browser cases one at a time and renders the report.
|
||||
* @throws If the phase fails.
|
||||
*/
|
||||
export function runBenchPhase(root: string, phase: 'first' | 'last'): void {
|
||||
process.env.DSH_BENCH_REPORT_PHASE = phase
|
||||
try {
|
||||
if (phase === 'first') {
|
||||
runPnpmCommand(root, [
|
||||
'exec', 'vitest', 'run', ...CONFIG,
|
||||
...SERIAL_BENCH_FILES.map(file => `--exclude=${file}`),
|
||||
])
|
||||
return
|
||||
}
|
||||
runPnpmCommand(root, [
|
||||
'exec', 'vitest', 'run', ...CONFIG,
|
||||
'--no-file-parallelism', '--maxWorkers=1',
|
||||
...SERIAL_BENCH_FILES,
|
||||
])
|
||||
} finally {
|
||||
delete process.env.DSH_BENCH_REPORT_PHASE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run both phases, so a first-phase failure still reports every later case.
|
||||
* @param root - Repository root containing the Vitest configuration.
|
||||
* @returns the process exit code: 1 when either phase failed.
|
||||
*/
|
||||
export function runBenchSuite(root: string): number {
|
||||
let failed = false
|
||||
for (const phase of ['first', 'last'] as const) {
|
||||
try {
|
||||
runBenchPhase(root, phase)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
return failed ? 1 : 0
|
||||
}
|
||||
|
||||
if (import.meta.main) process.exitCode = runBenchSuite(resolve(import.meta.dirname, '..'))
|
||||
+12
-10
@@ -282,7 +282,7 @@ describe('gate graph validation', () => {
|
||||
'npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts',
|
||||
)
|
||||
expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
|
||||
expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
|
||||
expect(scripts['test:bench:built']).toBe('tsx scripts/run-bench-suite.ts')
|
||||
})
|
||||
|
||||
it('checks all maintained repository references locally and in CI', () => {
|
||||
@@ -837,10 +837,12 @@ describe('Typert contract preparation', () => {
|
||||
it('reuses contracts from the validated consumer build', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
||||
|
||||
expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
|
||||
displayCommand: 'pnpm run check:ci:lint:contracts-ready',
|
||||
args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
|
||||
})
|
||||
// Lint and duplication run in their own lane; the consumers graph only
|
||||
// keeps the readers that consume built artifacts.
|
||||
expect(subject.map(item => item.id)).not.toContain('lint-and-duplication')
|
||||
expect(withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready'))).toContainEqual(expect.objectContaining({
|
||||
id: 'lint',
|
||||
}))
|
||||
expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
|
||||
displayCommand: 'pnpm run doc-typecheck:contracts-ready',
|
||||
args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
|
||||
@@ -956,7 +958,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'build',
|
||||
'node-compat',
|
||||
'publint',
|
||||
'lint-and-duplication',
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
'web-snapshot',
|
||||
@@ -965,13 +966,15 @@ describe('Node 24 lane ownership', () => {
|
||||
'built-bin-smoke',
|
||||
])
|
||||
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
|
||||
expect(subject.find(item => item.id === 'build')?.env).toEqual({
|
||||
DSH_BUILD_CLIENT_PROFILE: 'official',
|
||||
// The consumers lane emits artifacts only: the aggregate test and script
|
||||
// type checks belong to the master lanes, not to every pull request.
|
||||
expect(subject.find(item => item.id === 'build')).toMatchObject({
|
||||
displayCommand: 'pnpm run build:artifacts',
|
||||
env: { DSH_BUILD_CLIENT_PROFILE: 'official' },
|
||||
})
|
||||
expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
|
||||
DSH_BUILD_CLIENT_PROFILE: 'official',
|
||||
})
|
||||
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['build'])
|
||||
for (const id of [
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
@@ -1003,7 +1006,6 @@ describe('Node 24 lane ownership', () => {
|
||||
env: { DSH_SNAPSHOT: 'replay' },
|
||||
after: [
|
||||
'publint',
|
||||
'lint-and-duplication',
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
'doc-typecheck',
|
||||
|
||||
+12
-6
@@ -279,6 +279,17 @@ function ciBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the artifacts every artifact consumer reads, without the aggregate
|
||||
* test and script type checks that the master lanes own.
|
||||
*/
|
||||
function ciArtifactBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
|
||||
return pnpmScript(id, 'build:artifacts', {
|
||||
...options,
|
||||
env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
|
||||
})
|
||||
}
|
||||
|
||||
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
|
||||
return {
|
||||
id,
|
||||
@@ -530,7 +541,6 @@ function ciConsumerGates(): Gate[] {
|
||||
// writer starts; `after` preserves the web diagnostic even if a reader fails.
|
||||
const buildArtifactReaders = [
|
||||
'publint',
|
||||
'lint-and-duplication',
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
'doc-typecheck',
|
||||
@@ -538,16 +548,12 @@ function ciConsumerGates(): Gate[] {
|
||||
'built-bin-smoke',
|
||||
]
|
||||
return [
|
||||
ciBuildGate(),
|
||||
ciArtifactBuildGate(),
|
||||
pnpmScript('node-compat', 'check:node-compat', {
|
||||
label: 'Node compatibility',
|
||||
env: { [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
|
||||
}),
|
||||
pnpmScript('publint', 'publint', { needs: builtTree }),
|
||||
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
|
||||
label: 'lint and duplication',
|
||||
needs: builtTree,
|
||||
}),
|
||||
snapshotGate(builtTree),
|
||||
expectedOutputGate(builtTree),
|
||||
webSnapshotGate(builtTree, buildArtifactReaders),
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/** Source-text parsing shared by the package dependency and client package gates. */
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import type { PackageDependencyManifest, WorkspacePackageManifest } from './verify-package-dependencies.ts'
|
||||
|
||||
export type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
|
||||
|
||||
export const ROOT_TYPE_PACKAGES: ReadonlySet<string> = new Set(['@types/react', '@types/react-dom'])
|
||||
|
||||
/**
|
||||
* Report whether a specifier names a bare package rather than a path or subpath import.
|
||||
* @param specifier - module specifier text.
|
||||
* @returns whether the specifier resolves outside this package.
|
||||
*/
|
||||
function isBareSpecifier(specifier: string): boolean {
|
||||
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the leading package segments of one bare module specifier.
|
||||
* @param specifier - module specifier text that already passed {@link isBareSpecifier}.
|
||||
* @returns the package name this specifier belongs to.
|
||||
*/
|
||||
function packageNameForUses(specifier: string): string {
|
||||
const segments = specifier.split('/')
|
||||
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the bare package name of one module specifier.
|
||||
* @param specifier - module specifier text.
|
||||
* @returns the package name, or undefined for paths, builtins, and non-package specifiers.
|
||||
*/
|
||||
export function packageNameOf(specifier: string): string | undefined {
|
||||
if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
|
||||
|| specifier.includes(':') || specifier.includes('*')) {
|
||||
return undefined
|
||||
}
|
||||
const parts = specifier.split('/')
|
||||
return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package manifests used for scope discovery and workspace-name checks.
|
||||
* @param root - Repository containing the workspace manifests.
|
||||
* @returns Every workspace package, the non-experimental release packages, and the packages the policy covers.
|
||||
*/
|
||||
export function addUse(target: Map<string, string[]>, name: string, path: string): void {
|
||||
const paths = target.get(name) ?? []
|
||||
if (!paths.includes(path)) paths.push(path)
|
||||
target.set(name, paths)
|
||||
}
|
||||
|
||||
export function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
return manifest[name] ?? {}
|
||||
}
|
||||
|
||||
export function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
manifest[name] ??= {}
|
||||
return manifest[name]
|
||||
}
|
||||
|
||||
export function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
|
||||
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
|
||||
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
|
||||
}
|
||||
|
||||
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) return true
|
||||
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
|
||||
const bindings = clause.namedBindings
|
||||
return clause.name !== undefined
|
||||
|| bindings === undefined
|
||||
|| ts.isNamespaceImport(bindings)
|
||||
|| bindings.elements.length === 0
|
||||
|| bindings.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
|
||||
if (node.isTypeOnly) return false
|
||||
const clause = node.exportClause
|
||||
if (clause === undefined || ts.isNamespaceExport(clause)) return true
|
||||
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
export function collectSourceFileUses(
|
||||
sourceFile: ts.SourceFile,
|
||||
runtimeOnly: boolean,
|
||||
key: 'local' | 'package' | 'specifier',
|
||||
includeRootRelative = false,
|
||||
): Set<string> {
|
||||
const uses = new Set<string>()
|
||||
|
||||
const add = (specifier: ts.Expression | undefined): void => {
|
||||
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
|
||||
if (key === 'local') {
|
||||
if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
|
||||
return
|
||||
}
|
||||
if (!isBareSpecifier(specifier.text)) return
|
||||
uses.add(key === 'package' ? packageNameForUses(specifier.text) : specifier.text)
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isExportDeclaration(node)) {
|
||||
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
||||
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
|
||||
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
add(node.argument.literal)
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
|
||||
add(node.arguments[0])
|
||||
} else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
|
||||
add(node.name)
|
||||
} else if (key !== 'local'
|
||||
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
|
||||
uses.add('react')
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return uses
|
||||
}
|
||||
|
||||
/**
|
||||
* Read browser-module declarations from workspace manifests.
|
||||
* @param root - Absolute repository root.
|
||||
* @returns Declarations and malformed dsh.client fields.
|
||||
*/
|
||||
export function collectSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect bare packages whose values one production source file reaches at runtime.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect exact bare specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'specifier')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect relative module specifiers used to follow one source entry's local closure.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative imports, exports, requires, and import types.
|
||||
*/
|
||||
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'local')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect local module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
|
||||
* @returns Local imports, exports, and requires that survive compilation.
|
||||
*/
|
||||
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect package-asset module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
|
||||
*/
|
||||
/**
|
||||
* Normalize a repository path to forward slashes.
|
||||
* @param path - platform path inside the repository.
|
||||
* @returns the same path with POSIX separators.
|
||||
*/
|
||||
export function normalizePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
export function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
|
||||
const uses = new Map<string, string[]>()
|
||||
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
|
||||
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
|
||||
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
|
||||
let runtimeUses: Set<string> | undefined
|
||||
for (const specifier of collectSourcePackageUses(sourcePath, source)) {
|
||||
const name = packageNameOf(specifier)
|
||||
if (name === undefined) continue
|
||||
const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
|
||||
if (!ROOT_TYPE_PACKAGES.has(typesName)
|
||||
&& declaredSections(pkg.manifest, name).length === 0
|
||||
&& declaredSections(pkg.manifest, typesName).length > 0) {
|
||||
runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
|
||||
if (!runtimeUses.has(name)) {
|
||||
addUse(uses, typesName, displayPath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
addUse(uses, name, displayPath)
|
||||
}
|
||||
}
|
||||
return uses
|
||||
}
|
||||
|
||||
@@ -139,8 +139,10 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
|
||||
it('retains the dependency and pack verification commands', () => {
|
||||
const commands = job.steps.flatMap(step => step.run === undefined ? [] : [step.run])
|
||||
if (jobId === 'dependencies') {
|
||||
expect(commands).toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).toContain('pnpm run verify-npm-install-layout')
|
||||
// Both verifications share one step so the lane pays the longer half.
|
||||
const combined = commands.find(command => command.includes('verify-package-dependencies'))
|
||||
expect(combined).toContain('verify-npm-install-layout')
|
||||
expect(combined).toContain('wait "$layout_pid"')
|
||||
} else {
|
||||
const family = file === 'release.yml' ? 'dsh' : 'vendor'
|
||||
const output = family === 'dsh' ? 'dist/npm' : 'dist/npm-vendor'
|
||||
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
} from '../packages/client/modules/src/assets.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
export {
|
||||
collectLocalSourceSpecifiers,
|
||||
collectRuntimeLocalSourceSpecifiers,
|
||||
collectRuntimeSourcePackageUses,
|
||||
collectRuntimeSourceSpecifiers,
|
||||
collectSourcePackageUses,
|
||||
} from './source-parse-uses.ts'
|
||||
import { collectSourceFileUses } from './source-parse-uses.ts'
|
||||
const GATE = 'verify-client-packages'
|
||||
const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
|
||||
const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
|
||||
@@ -89,62 +97,6 @@ export interface ClientDeclarations {
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Bare package names referenced by imports, declarations, or JSX.
|
||||
*/
|
||||
export function collectSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect bare packages whose values one production source file reaches at runtime.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect exact bare specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'specifier')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect relative module specifiers used to follow one source entry's local closure.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative imports, exports, requires, and import types.
|
||||
*/
|
||||
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'local')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect local module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
|
||||
* @returns Local imports, exports, and requires that survive compilation.
|
||||
*/
|
||||
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect package-asset module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
|
||||
*/
|
||||
export function collectSourceAssetSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return new Set([...collectSourceFileUses(sourceFile, true, 'local')].filter(isAssetSpecifier))
|
||||
@@ -227,73 +179,6 @@ export function readStylesheetAssetReferences(
|
||||
}
|
||||
}
|
||||
|
||||
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) return true
|
||||
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
|
||||
const bindings = clause.namedBindings
|
||||
return clause.name !== undefined
|
||||
|| bindings === undefined
|
||||
|| ts.isNamespaceImport(bindings)
|
||||
|| bindings.elements.length === 0
|
||||
|| bindings.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
|
||||
if (node.isTypeOnly) return false
|
||||
const clause = node.exportClause
|
||||
if (clause === undefined || ts.isNamespaceExport(clause)) return true
|
||||
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
function collectSourceFileUses(
|
||||
sourceFile: ts.SourceFile,
|
||||
runtimeOnly: boolean,
|
||||
key: 'local' | 'package' | 'specifier',
|
||||
includeRootRelative = false,
|
||||
): Set<string> {
|
||||
const uses = new Set<string>()
|
||||
|
||||
const add = (specifier: ts.Expression | undefined): void => {
|
||||
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
|
||||
if (key === 'local') {
|
||||
if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
|
||||
return
|
||||
}
|
||||
if (!isBareSpecifier(specifier.text)) return
|
||||
uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text)
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isExportDeclaration(node)) {
|
||||
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
||||
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
|
||||
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
add(node.argument.literal)
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
|
||||
add(node.arguments[0])
|
||||
} else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
|
||||
add(node.name)
|
||||
} else if (key !== 'local'
|
||||
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
|
||||
uses.add('react')
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return uses
|
||||
}
|
||||
|
||||
/**
|
||||
* Read browser-module declarations from workspace manifests.
|
||||
* @param root - Absolute repository root.
|
||||
* @returns Declarations and malformed dsh.client fields.
|
||||
*/
|
||||
export function readClientDeclarations(root: string): ClientDeclarations {
|
||||
const malformed: string[] = []
|
||||
const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
|
||||
@@ -853,10 +738,6 @@ function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | un
|
||||
return rows.has(stripped) ? stripped : undefined
|
||||
}
|
||||
|
||||
function isBareSpecifier(specifier: string): boolean {
|
||||
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import ts from 'typescript'
|
||||
import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts'
|
||||
import { PACKAGE_ASSET_EXPORT } from '../packages/client/modules/src/assets.ts'
|
||||
@@ -17,11 +18,22 @@ import {
|
||||
PACKAGE_DEPENDENCY_POLICY,
|
||||
type PackageDependencyPolicy,
|
||||
} from './package-dependency-policy.ts'
|
||||
import type {
|
||||
SourceUsesWorkerRequest,
|
||||
SourceUsesWorkerResult,
|
||||
} from './package-source-uses.worker.ts'
|
||||
import {
|
||||
ROOT_TYPE_PACKAGES,
|
||||
addUse,
|
||||
normalizePath,
|
||||
collectRuntimeLocalSourceSpecifiers,
|
||||
collectRuntimeSourcePackageUses,
|
||||
collectSourcePackageUses,
|
||||
} from './verify-client-packages.ts'
|
||||
declaredSections,
|
||||
mutableSection,
|
||||
packageNameOf,
|
||||
readAllSourceUses,
|
||||
section,
|
||||
type DependencySection,
|
||||
} from './source-parse-uses.ts'
|
||||
|
||||
const GATE = 'verify-package-dependencies'
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
@@ -29,7 +41,6 @@ const CORDIS = '@deepseek-ai/cordis'
|
||||
* Type packages declared only in the root devDependencies. Each package-local pnpm link is one more
|
||||
* location that TypeScript scans when it computes module specifiers for every JSX file.
|
||||
*/
|
||||
const ROOT_TYPE_PACKAGES: ReadonlySet<string> = new Set(['@types/react', '@types/react-dom'])
|
||||
function workspaceRange(name: string): 'workspace:*' | 'workspace:~' {
|
||||
return name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-') ? 'workspace:*' : 'workspace:~'
|
||||
}
|
||||
@@ -42,9 +53,15 @@ const WORKSPACE_MANIFEST_GLOBS = [
|
||||
'vendor/*/package.json',
|
||||
]
|
||||
|
||||
type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
|
||||
|
||||
export type PackageDependencyRole = 'client-only' | 'client-host' | 'configured-host'
|
||||
|
||||
/** One workspace package the dependency policy covers, with its resolved role. */
|
||||
export type SelectedWorkspacePackage = WorkspacePackageManifest & { readonly role: PackageDependencyRole }
|
||||
|
||||
/** Upper bound on source-walk workers: each one pays its own TypeScript module load. */
|
||||
export const MAX_DEPENDENCY_WORKERS = 16
|
||||
|
||||
/** Manifest fields read and repaired by the package dependency policy. */
|
||||
export interface PackageDependencyManifest {
|
||||
name?: string
|
||||
@@ -104,24 +121,6 @@ export interface ExpectedPackageDependency {
|
||||
readonly origins: readonly string[]
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
function packageNameOf(specifier: string): string | undefined {
|
||||
if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
|
||||
|| specifier.includes(':') || specifier.includes('*')) {
|
||||
return undefined
|
||||
}
|
||||
const parts = specifier.split('/')
|
||||
return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package manifests used for scope discovery and workspace-name checks.
|
||||
* @param root - Repository containing the workspace manifests.
|
||||
* @returns Every workspace package, the non-experimental release packages, and the packages the policy covers.
|
||||
*/
|
||||
export function readWorkspacePackageManifests(root: string): {
|
||||
all: WorkspacePackageManifest[]
|
||||
release: WorkspacePackageManifest[]
|
||||
@@ -211,12 +210,6 @@ export function discoverPackageDependencyScope(
|
||||
}
|
||||
}
|
||||
|
||||
function addUse(target: Map<string, string[]>, name: string, path: string): void {
|
||||
const paths = target.get(name) ?? []
|
||||
if (!paths.includes(path)) paths.push(path)
|
||||
target.set(name, paths)
|
||||
}
|
||||
|
||||
const NAMESPACE_RUNTIME_EXPORT = '*'
|
||||
const SIDE_EFFECT_RUNTIME_EXPORT = '(side effect)'
|
||||
|
||||
@@ -519,31 +512,6 @@ function readHostRuntimeUses(
|
||||
}
|
||||
}
|
||||
|
||||
function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
|
||||
const uses = new Map<string, string[]>()
|
||||
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
|
||||
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
|
||||
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
|
||||
let runtimeUses: Set<string> | undefined
|
||||
for (const specifier of collectSourcePackageUses(sourcePath, source)) {
|
||||
const name = packageNameOf(specifier)
|
||||
if (name === undefined) continue
|
||||
const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
|
||||
if (!ROOT_TYPE_PACKAGES.has(typesName)
|
||||
&& declaredSections(pkg.manifest, name).length === 0
|
||||
&& declaredSections(pkg.manifest, typesName).length > 0) {
|
||||
runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
|
||||
if (!runtimeUses.has(name)) {
|
||||
addUse(uses, typesName, displayPath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
addUse(uses, name, displayPath)
|
||||
}
|
||||
}
|
||||
return uses
|
||||
}
|
||||
|
||||
/**
|
||||
* Read authored and generated source usage for one already-classified package.
|
||||
* @param root - Repository containing source files and face tsconfigs.
|
||||
@@ -562,13 +530,14 @@ export async function readPackageDependencyFacts(
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
|
||||
generatedHostSource?: string,
|
||||
precomputedSourceUses?: Map<string, string[]>,
|
||||
): Promise<PackageDependencyFacts> {
|
||||
const inject = pkg.manifest.dsh?.client?.inject ?? []
|
||||
const hostRuntime = role === 'client-only'
|
||||
? { packageUses: new Map<string, string[]>(), exportUses: [] }
|
||||
: readHostRuntimeUses(root, pkg, await readHostBuildEntries(root, pkg), generatedHostSource)
|
||||
const servicePeers = policy.requiredServicePeers?.[pkg.name] ?? []
|
||||
const allSourceUses = readAllSourceUses(root, pkg)
|
||||
const allSourceUses = precomputedSourceUses ?? readAllSourceUses(root, pkg)
|
||||
for (const name of servicePeers) {
|
||||
if (!workspaceNames.has(name) || !allSourceUses.has(name)) {
|
||||
throw new Error(`${pkg.manifestPath}: requiredServicePeers must name a referenced workspace package: ${name}`)
|
||||
@@ -676,10 +645,7 @@ export async function readPackageDependencyState(
|
||||
const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
|
||||
const discovered = discoverPackageDependencyScope(packages.policy, policy)
|
||||
const generated = generatedHostSources(root, discovered.selected.filter(pkg => pkg.role !== 'client-only'))
|
||||
const facts: PackageDependencyFacts[] = []
|
||||
for (const pkg of discovered.selected) {
|
||||
facts.push(await readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy, generated.get(pkg.name)))
|
||||
}
|
||||
const facts = await readPackageDependencyFactsConcurrently(root, discovered.selected, workspaceNames, policy, generated)
|
||||
const selectedNames = new Set(facts.map(fact => fact.manifest.name))
|
||||
return {
|
||||
facts,
|
||||
@@ -696,6 +662,117 @@ export async function readPackageDependencyState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read dependency facts for every selected package, walking package sources in parallel.
|
||||
*
|
||||
* Walking a package's `src` tree with the TypeScript parser is the expensive part of
|
||||
* this gate and shares no state across packages, so those walks run in worker threads
|
||||
* while the caller's package order is preserved for the policy checks.
|
||||
* @param root - Repository containing the workspace sources and build configs.
|
||||
* @param packages - Selected packages, in policy order.
|
||||
* @param workspaceNames - Identities of every workspace package.
|
||||
* @param policy - Reviewed scope overrides and Host export classifications.
|
||||
* @param generated - Host modules the batched Typert pass emitted in memory, keyed by package name.
|
||||
* @param workers - Worker count for the source walk; defaults to the host CPU count, capped at {@link MAX_DEPENDENCY_WORKERS}.
|
||||
* @returns Facts in the same order as `packages`.
|
||||
*/
|
||||
export async function readPackageDependencyFactsConcurrently(
|
||||
root: string,
|
||||
packages: readonly SelectedWorkspacePackage[],
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
policy: PackageDependencyPolicy,
|
||||
generated: ReadonlyMap<string, string>,
|
||||
workers = Math.min(availableParallelism(), packages.length, MAX_DEPENDENCY_WORKERS),
|
||||
): Promise<PackageDependencyFacts[]> {
|
||||
const sourceUses = await readSourceUses(root, packages, workers)
|
||||
const facts: PackageDependencyFacts[] = []
|
||||
for (const pkg of packages) {
|
||||
facts.push(await readPackageDependencyFacts(
|
||||
root,
|
||||
pkg,
|
||||
pkg.role,
|
||||
workspaceNames,
|
||||
policy,
|
||||
generated.get(pkg.name),
|
||||
sourceUses.get(pkg.dir),
|
||||
))
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
/** Source uses per package directory, collected either in-process or by worker threads. */
|
||||
async function readSourceUses(
|
||||
root: string,
|
||||
packages: readonly SelectedWorkspacePackage[],
|
||||
workers: number,
|
||||
): Promise<Map<string, Map<string, string[]>>> {
|
||||
if (workers < 2 || packages.length < 2) {
|
||||
return new Map(packages.map(pkg => [pkg.dir, readAllSourceUses(root, pkg)]))
|
||||
}
|
||||
const sizes = sliceSizes(packages.length, workers)
|
||||
const tasks = packages.map((pkg, index) => ({ index, dir: pkg.dir, name: pkg.name, manifest: pkg.manifest }))
|
||||
const slices: typeof tasks[] = []
|
||||
let offset = 0
|
||||
for (const size of sizes) {
|
||||
slices.push(tasks.slice(offset, offset + size))
|
||||
offset += size
|
||||
}
|
||||
const collected = (await Promise.all(slices.map(async slice => runSourceUsesWorker(root, slice)))).flat()
|
||||
const byName = new Map(collected.map(result => [result.name, result.uses]))
|
||||
return new Map(packages.map(pkg => [pkg.dir, byName.get(pkg.name) ?? new Map<string, string[]>()]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one slice of packages in a worker thread.
|
||||
* @param root - Repository containing the workspace sources.
|
||||
* @param tasks - Packages this worker walks.
|
||||
* @returns one result per task.
|
||||
* @throws If the worker crashes or exits before reporting.
|
||||
*/
|
||||
function runSourceUsesWorker(root: string, tasks: readonly SourceUsesTaskForWorker[]): Promise<SourceUsesWorkerResult[]> {
|
||||
const request: SourceUsesWorkerRequest = { root, tasks }
|
||||
const url = new URL('./package-source-uses.worker.ts', import.meta.url)
|
||||
return new Promise((resolveResults, rejectResults) => {
|
||||
const worker = new Worker(url, { workerData: request })
|
||||
let settled = false
|
||||
worker.once('message', (results: SourceUsesWorkerResult[]) => {
|
||||
settled = true
|
||||
void worker.terminate()
|
||||
resolveResults(results)
|
||||
})
|
||||
worker.once('error', (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
rejectResults(error)
|
||||
})
|
||||
worker.once('exit', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
rejectResults(new Error(`${GATE}: source-uses worker exited with ${String(code)} before reporting`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface SourceUsesTaskForWorker {
|
||||
readonly index: number
|
||||
readonly dir: string
|
||||
readonly name: string
|
||||
readonly manifest: PackageDependencyManifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute a task count over a worker count as evenly as possible.
|
||||
* @param tasks - Number of tasks to place.
|
||||
* @param workers - Number of workers that must stay busy.
|
||||
* @returns One slice size per non-empty slice.
|
||||
*/
|
||||
export function sliceSizes(tasks: number, workers: number): number[] {
|
||||
const slices = Math.min(tasks, Math.max(1, workers))
|
||||
const base = Math.floor(tasks / slices)
|
||||
const remainder = tasks % slices
|
||||
return Array.from({ length: slices }, (_, index) => base + (index < remainder ? 1 : 0))
|
||||
}
|
||||
|
||||
/** Derive the required npm section for each relationship owned by the policy. */
|
||||
export function expectedPackageDependencies(
|
||||
facts: PackageDependencyFacts,
|
||||
@@ -790,20 +867,6 @@ export function formatPeerRequiredRuntimeDependencies(state: PackageDependencySt
|
||||
]
|
||||
}
|
||||
|
||||
function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
return manifest[name] ?? {}
|
||||
}
|
||||
|
||||
function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
manifest[name] ??= {}
|
||||
return manifest[name]
|
||||
}
|
||||
|
||||
function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
|
||||
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
|
||||
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
|
||||
}
|
||||
|
||||
function describeSections(sections: readonly DependencySection[]): string {
|
||||
return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts'
|
||||
* CI performance gate. Node CPU cases use compiled plain-Node workers under
|
||||
* `benchmarks/.dsh-build/`; browser cases drive built Client artifacts through
|
||||
* the shared shipped-composition Web scaffold.
|
||||
* Files run one at a time so a measurement never shares the CPU with another
|
||||
* benchmark.
|
||||
* Two files run at a time so the four-vCPU CI runner keeps both cores busy.
|
||||
* Budgets keep their reviewed CI scale and headroom; a pairing that inflates an
|
||||
* endpoint surfaces as a failing budget and is never loosened to accept it.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()],
|
||||
@@ -20,8 +21,8 @@ export default defineConfig({
|
||||
'benchmarks/**/*.bench.ts',
|
||||
'benchmarks/**/*.bench.client.ts',
|
||||
],
|
||||
fileParallelism: false,
|
||||
maxWorkers: 1,
|
||||
fileParallelism: true,
|
||||
maxWorkers: 2,
|
||||
testTimeout: 600_000,
|
||||
hookTimeout: 120_000,
|
||||
disableConsoleIntercept: true,
|
||||
|
||||
Reference in new issue
Block a user