From 29db1e22626b287028e3bc6b1f6996f4ab28ab0c Mon Sep 17 00:00:00 2001 From: YKDZ <3070799584@QQ.COM> Date: Fri, 16 Oct 2026 03:03:12 +0800 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20=E9=AA=8C=E8=AF=81=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=BB=BB=E5=8A=A1=E6=B2=BB=E7=90=86=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源码树与本地 main 提交 00d302cfc6dc78b32113aefea3c7d584ccc8c6f7 完全一致;通过 PR 执行真实完整验收。 --- .devcontainer/Dockerfile | 29 +- .devcontainer/devcontainer-lock.json | 9 + .devcontainer/devcontainer.json | 24 +- .dockerignore | 7 + .github/README.md | 19 +- .github/dependabot.yml | 4 + .github/workflows/check.yml | 83 +- apps/cli/package.json | 11 +- apps/cli/test/e2e/environment-contracts.ts | 101 +- apps/cli/test/e2e/environment-runtime.ts | 19 +- apps/cli/test/e2e/run-journeys.ts | 17 +- .../turbo-private-environment.test.ts | 67 - apps/cli/turbo.json | 136 +- apps/web/Dockerfile | 22 +- apps/web/Dockerfile.dockerignore | 1 + apps/web/Dockerfile.playwright-client | 12 +- apps/web/package.json | 15 +- apps/web/scripts/acceptance-inputs.ts | 38 + apps/web/scripts/check-compose-deployment.ts | 69 +- .../scripts/check-standalone-deployment.ts | 119 +- apps/web/scripts/deployment-preparation.ts | 54 + apps/web/scripts/environment-lifecycle.ts | 25 +- apps/web/scripts/prepare-acceptance.ts | 41 + apps/web/scripts/prepare-deployment.ts | 107 + .../scripts/run-managed-service-acceptance.ts | 210 +- apps/web/scripts/run-playwright.ts | 22 +- .../test/docker-compose-deployment.test.ts | 69 - .../e2e/managed-service-product-chain.spec.ts | 17 +- apps/web/test/e2e/support/managed-service.ts | 1 + apps/web/turbo.json | 180 +- fixtures/controlled-fanbox-session/README.md | 22 +- fixtures/controlled-source-io/README.md | 16 +- fixtures/live-corpus/README.md | 2 +- fixtures/preview-corpus/audio-posts/README.md | 2 +- fixtures/preview-corpus/coverage.json | 414 +++ fixtures/preview-corpus/manifest.json | 603 +++++ .../publication-screen/README.md | 6 +- package.json | 37 +- packages/application/package.json | 3 +- .../test/artifact-byte-access.test.ts | 42 +- .../application/test/asset-refresh.test.ts | 445 +++- .../test/information-fulfillment.test.ts | 30 +- .../test/observed-root-execution.test.ts | 68 +- .../test/observed-root-registrations.test.ts | 31 +- .../application/test/playback-plan.test.ts | 27 +- .../test/source-artifact-work.test.ts | 6 +- packages/application/test/tsconfig.json | 4 - packages/application/tsconfig.json | 2 +- packages/application/turbo.json | 62 +- packages/cli-delivery/package.json | 3 +- packages/cli-delivery/turbo.json | 65 +- packages/composition/package.json | 6 +- ...gumi-source-connection-concurrency.test.ts | 20 +- ...umi-source-connection-crash-safety.test.ts | 11 +- .../test/drizzle-recognition-adoption.test.ts | 167 +- .../test/entity-model-migration.test.ts | 19 +- .../composition/test/epub-recognition.test.ts | 3 +- .../test/information-fulfillment.test.ts | 18 +- .../composition/test/local-intake.test.ts | 16 +- .../test/local-material-confirmation.test.ts | 9 +- .../composition/test/pdf-recognition.test.ts | 40 +- .../test/product-operation-lease.test.ts | 20 +- .../test/relationship-definitions.test.ts | 3 +- .../test/relationship-entity-search.test.ts | 28 +- .../test/source-connections.test.ts | 18 +- .../test/source-refresh-run.test.ts | 27 +- .../composition/test/sqlite-catalogue.test.ts | 2361 ++++++++++------- packages/composition/test/tsconfig.json | 4 - packages/composition/tsconfig.json | 2 +- packages/composition/turbo.json | 74 +- packages/db-migrations/package.json | 7 +- packages/db-migrations/turbo.json | 88 +- packages/domain/package.json | 3 +- packages/domain/test/local-provenance.test.ts | 31 +- .../observed-material-association.test.ts | 28 +- .../test/relationship-qualifiers.test.ts | 11 +- .../source-association-correction.test.ts | 3 + packages/domain/tsconfig.json | 2 +- packages/domain/turbo.json | 62 +- packages/environment/package.json | 6 +- .../src/internal/container-operation.ts | 5 +- .../test/container-environment.test.ts | 164 +- packages/environment/turbo.json | 72 +- packages/filesystem/package.json | 5 +- packages/filesystem/src/index.ts | 25 +- .../test/artifact-frame-proof.test.ts | 6 +- packages/filesystem/tsconfig.json | 2 +- packages/filesystem/turbo.json | 87 +- packages/playback-format/package.json | 3 +- packages/playback-format/turbo.json | 65 +- packages/presentation/package.json | 3 +- .../test/asset-refresh-selection.test.ts | 16 +- .../presentation/test/bangumi-view.test.ts | 10 +- packages/presentation/test/view-facts.ts | 2 +- packages/presentation/tsconfig.json | 2 +- packages/presentation/turbo.json | 57 +- packages/product-application/package.json | 3 +- packages/product-application/tsconfig.json | 2 +- packages/product-application/turbo.json | 65 +- packages/product-data/package.json | 3 +- packages/product-data/tsconfig.json | 2 +- packages/product-data/turbo.json | 65 +- packages/sqlite/package.json | 3 +- packages/sqlite/turbo.json | 67 +- packages/test-support/package.json | 3 +- packages/test-support/src/index.ts | 7 +- .../test/controlled-source-io.test.ts | 14 +- packages/test-support/tsconfig.json | 2 +- packages/test-support/turbo.json | 65 +- packages/typescript-config/turbo.json | 15 +- packages/web-delivery/package.json | 3 +- packages/web-delivery/turbo.json | 65 +- packages/workspace-automation/README.md | 222 ++ packages/workspace-automation/package.json | 29 + ...build-controlled-fanbox-session-fixture.ts | 11 +- .../build-controlled-source-io-fixture.ts | 11 +- .../scripts/build-development.ts | 56 + .../scripts}/ci-environment.ts | 288 +- .../scripts/corpus-verification.ts | 235 ++ .../scripts}/development-environment.ts | 6 +- .../scripts}/development-task.ts | 2 +- .../workspace-automation/scripts/inputs.ts | 9 + .../scripts}/preview-environment.ts | 291 +- .../scripts}/verify-audio-post-corpus.ts | 56 +- .../verify-publication-screen-corpus.ts | 143 +- packages/workspace-automation/tsconfig.json | 13 + packages/workspace-automation/turbo.json | 27 + pnpm-lock.yaml | 301 ++- pnpm-workspace.yaml | 2 +- scripts/README.md | 109 - scripts/build-development.ts | 66 - scripts/build-production-workspace.ts | 87 - scripts/check-toolchain-versions.ts | 491 ++++ scripts/preview-product.ts | 2 - scripts/run-turbo-boundaries.ts | 74 + scripts/task-gates.ts | 590 ++++ scripts/test/task-gates.test.ts | 274 ++ sources/bangumi/package.json | 3 +- sources/bangumi/test/capabilities.test.ts | 50 +- sources/bangumi/tsconfig.json | 2 +- sources/bangumi/turbo.json | 65 +- sources/dlsite/package.json | 3 +- sources/dlsite/test/capabilities.test.ts | 7 +- sources/dlsite/tsconfig.json | 2 +- sources/dlsite/turbo.json | 65 +- sources/epub/package.json | 3 +- sources/epub/test/epub.test.ts | 21 +- sources/epub/tsconfig.json | 2 +- sources/epub/turbo.json | 65 +- sources/fanbox/package.json | 3 +- .../test/browser-product-assembly.test.ts | 69 +- sources/fanbox/test/capabilities.test.ts | 36 +- sources/fanbox/test/tsconfig.json | 4 - sources/fanbox/tsconfig.json | 2 +- sources/fanbox/turbo.json | 65 +- sources/kadokawa-jp/package.json | 3 +- sources/kadokawa-jp/test/capabilities.test.ts | 13 +- sources/kadokawa-jp/tsconfig.json | 2 +- sources/kadokawa-jp/turbo.json | 65 +- sources/nbinet/package.json | 3 +- sources/nbinet/test/nbinet.test.ts | 30 +- sources/nbinet/tsconfig.json | 2 +- sources/nbinet/turbo.json | 65 +- sources/omdb/package.json | 3 +- sources/omdb/test/capabilities.test.ts | 18 +- sources/omdb/tsconfig.json | 2 +- sources/omdb/turbo.json | 65 +- sources/pdf/package.json | 3 +- sources/pdf/test/pdf.test.ts | 12 +- sources/pdf/tsconfig.json | 2 +- sources/pdf/turbo.json | 65 +- sources/taiwan-kadokawa/package.json | 3 +- .../taiwan-kadokawa/test/capabilities.test.ts | 25 +- sources/taiwan-kadokawa/tsconfig.json | 2 +- sources/taiwan-kadokawa/turbo.json | 65 +- sources/tmdb/package.json | 3 +- sources/tmdb/test/capabilities.test.ts | 25 +- sources/tmdb/tsconfig.json | 2 +- sources/tmdb/turbo.json | 65 +- tsconfig.json | 2 +- turbo.json | 204 +- 181 files changed, 8993 insertions(+), 3031 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json delete mode 100644 apps/cli/test/integration/turbo-private-environment.test.ts create mode 100644 apps/web/scripts/acceptance-inputs.ts create mode 100644 apps/web/scripts/deployment-preparation.ts create mode 100644 apps/web/scripts/prepare-acceptance.ts create mode 100644 apps/web/scripts/prepare-deployment.ts delete mode 100644 apps/web/test/docker-compose-deployment.test.ts create mode 100644 fixtures/preview-corpus/coverage.json create mode 100644 fixtures/preview-corpus/manifest.json delete mode 100644 packages/application/test/tsconfig.json delete mode 100644 packages/composition/test/tsconfig.json create mode 100644 packages/workspace-automation/README.md create mode 100644 packages/workspace-automation/package.json rename {scripts => packages/workspace-automation/scripts}/build-controlled-fanbox-session-fixture.ts (98%) rename {scripts => packages/workspace-automation/scripts}/build-controlled-source-io-fixture.ts (95%) create mode 100644 packages/workspace-automation/scripts/build-development.ts rename {scripts => packages/workspace-automation/scripts}/ci-environment.ts (70%) create mode 100644 packages/workspace-automation/scripts/corpus-verification.ts rename {scripts => packages/workspace-automation/scripts}/development-environment.ts (75%) rename {scripts => packages/workspace-automation/scripts}/development-task.ts (99%) create mode 100644 packages/workspace-automation/scripts/inputs.ts rename {scripts => packages/workspace-automation/scripts}/preview-environment.ts (90%) rename {scripts => packages/workspace-automation/scripts}/verify-audio-post-corpus.ts (88%) rename {scripts => packages/workspace-automation/scripts}/verify-publication-screen-corpus.ts (81%) create mode 100644 packages/workspace-automation/tsconfig.json create mode 100644 packages/workspace-automation/turbo.json delete mode 100644 scripts/README.md delete mode 100644 scripts/build-development.ts delete mode 100644 scripts/build-production-workspace.ts create mode 100644 scripts/check-toolchain-versions.ts delete mode 100644 scripts/preview-product.ts create mode 100644 scripts/run-turbo-boundaries.ts create mode 100644 scripts/task-gates.ts create mode 100644 scripts/test/task-gates.test.ts delete mode 100644 sources/fanbox/test/tsconfig.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e372387..06800b7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,20 +2,41 @@ ARG NODE_VERSION FROM node:${NODE_VERSION}-bookworm-slim SHELL ["/bin/bash", "-o", "pipefail", "-c"] -ARG PACKAGE_MANAGER_PIN ENV COREPACK_HOME="/corepack" ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH" +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ + && apt-get install -y --no-install-recommends \ + bubblewrap \ + ca-certificates \ + curl \ + file \ + git \ + iproute2 \ + jq \ + openssh-client \ + procps \ + python-is-python3 \ + python3 \ + ripgrep \ + unzip \ && rm -rf /var/lib/apt/lists/* \ + && git config --system init.defaultBranch main \ && install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" "$PNPM_HOME/bin" \ && corepack enable --install-directory "$PNPM_HOME" \ - && corepack prepare "${PACKAGE_MANAGER_PIN}" --activate \ && chmod -R a+rX "$COREPACK_HOME" "$PNPM_HOME" ARG PLAYWRIGHT_CLI_PACKAGE -RUN npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install-deps chromium +RUN npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install --with-deps chromium + +RUN install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* RUN apt-get update \ && apt-get install -y --no-install-recommends shellcheck \ diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..05b2ad3 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "1.10.1", + "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0", + "integrity": "sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 076c47f..e581dd2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,14 +3,30 @@ "build": { "dockerfile": "Dockerfile", "args": { - "NODE_VERSION": "24", - "PACKAGE_MANAGER_PIN": "pnpm@11.17.0", - "PLAYWRIGHT_CLI_PACKAGE": "@playwright/test@^1.61.1" + "NODE_VERSION": "24.21.0", + "PLAYWRIGHT_CLI_PACKAGE": "@playwright/test@1.62.0" } }, + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + "containerEnv": { + "PNPM_CONFIG_STORE_DIR": "/pnpm/store", + "DIGITAL_DEPLOYMENT_BUILD_NETWORK": "host" + }, + "mounts": [ + { + "source": "${devcontainerId}-pnpm-store", + "target": "/pnpm/store", + "type": "volume" + } + ], "customizations": { "vscode": { "extensions": ["oxc.oxc-vscode"] } - } + }, + "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", + "workspaceFolder": "${localWorkspaceFolder}", + "runArgs": ["--network=host"] } diff --git a/.dockerignore b/.dockerignore index 0f67c16..4331fb4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,12 @@ .git .scratch +.digital +.env +.env.local +.env.*.local +*.env +credentials/ +secrets/ **/node_modules **/dist **/*.tsbuildinfo diff --git a/.github/README.md b/.github/README.md index 5df47b1..da5904f 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,22 +1,21 @@ # CI 运行与证据 -默认 `Check` 工作流在三个独立的 GitHub 托管工作区并发运行:根检查仍为 `pnpm run check`;CLI 仍调用本地 `test:e2e` 的受控旅程;Web 仍调用真实 HTTP 浏览器入口。默认范围不需要个人来源凭据、私有语料、开发监听或 Docker。 +每次 PR 和推送到 main,`Check` 在独占检出中调用同一个根治理实现,先运行轻检查,再运行全部默认重检查。默认范围包括受控 CLI 旅程、浏览器、managed HTTP,以及 standalone/Compose 部署;不需要个人来源凭据或私有素材。Docker daemon、挂载权限和浏览器是受支持运行平台的前置条件,缺失时检查失败。 -本地可按相同阶段复现(先冻结安装,CLI/Web 先按正式 Turbo 图构建依赖,Web 安装 Chromium): +本地完整验收使用 `pnpm check:full`。CI 收集与上传交接可在独占的新检出中按同一阶段复现: ```sh pnpm install --frozen-lockfile -pnpm exec turbo run build --filter=@digital/web --filter=@digital/cli --filter=@digital/db-migrations -node --conditions=digital-source scripts/ci-environment.ts run cli -node --conditions=digital-source scripts/ci-environment.ts collect cli +TURBO_CONCURRENCY=2 DIGITAL_AUTOMATION_WORKSPACE="$PWD" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts run +DIGITAL_AUTOMATION_WORKSPACE="$PWD" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts collect ``` -将 `cli` 换为 `web` 可运行浏览器链;`root` 需要独占的新工作区,运行前会登记环境目录为空。证据默认保存到 `.scratch/ci/`,可用 `DIGITAL_CI_EVIDENCE` 选择本次独立目录。每次重跑应使用新目录,避免覆盖前次证据。 +`run` 直接调用公共门禁的治理实现;包内原生准备任务负责浏览器与正式镜像,不在工作流复制构建逻辑。Turbo 并发限制为 2,镜像构建继续使用 Docker/BuildKit 的层缓存。每次重跑使用新的独占检出和证据目录。 -CI 根检查按托管运行器的资源预算将 Turbo 任务并发限制为 2,CLI/Web 保持默认 10;本地相同条件用 `TURBO_CONCURRENCY=2 node --conditions=digital-source scripts/ci-environment.ts run root` 复现,内部仍执行 `pnpm run check`,测试和作业期限保持不变。 +证据默认保存到 `.scratch/ci/full`,可用 `DIGITAL_CI_EVIDENCE` 指定目录。相同变量传到正式 CLI、HTTP 和部署叶入口:它们保留实际产品数据、归属和场景结果,成功后把环境交给 CI 收尾。CLI 保留已有的命令行交接参数,HTTP 保留已有的 keep-on-success 参数;本地未启用 CI 交接时,成功场景按正式生命周期回收,失败现场保留。 -CLI 的 `--retain-on-success` 保持成功结局,停止并确认退出后暂不删除产品数据;不提供该选项时仍按原本行为回收。Web 使用已有 `--keep-on-success true`,成功后服务仍运行,后续 `collect` 会经公共 stop/inspect 停止并独立确认退出,然后收集整个真实产品数据根和停止后的完整日志。适用的浏览器诊断随场景结果保留。收集失败不会阻止其他明确自有环境的停止。 +`collect` 独立停止并检查本次自有服务和容器,收集整个真实产品数据根及停止后的日志。部署通过公共容器管理接口保存数据,浏览器诊断和命令结果一并进入本次证据目录。执行失败或取消后仍运行停止、收集和上传;一个环境收集失败不阻止其他自有环境停止。 -CI 在执行失败、超时或取消后仍进入独立停止、收集与上传步骤。上传要求有真实文件;只有实际 upload-artifact 成功,并通过 GitHub API 核实产物 ID、名称、运行归属和有效字节后,才调用公共 delete。保存或上传失败不执行删除;未知退出状态保留载体,执行、保存和清理结果分别报告。删除后的结果另写入实际作业日志,避免只留在已经上传之后的本地文件里。本地 `collect` 成功仅证明收集完成,不能代替真实上传。 +只有实际上传成功,并通过 GitHub API 核实产物 ID、名称、运行归属及有效字节后,`delete` 才调用正式删除。保存或上传失败保留原载体,未知退出状态不删除。执行、保存、上传和清理结果分别报告,删除后的结果也写入实际作业日志。本地 `collect` 成功不能替代真实上传核验。 -Docker 部署只在手工触发时显式选择 `deployment`。该作业先使用现有 Dockerfile 构建 standalone/runtime 两目标,向原 `check:deployment` 传入两份明确镜像引用,再运行既有部署断言。未选择或未执行该作业不表示部署通过。完整真实资料与来源验收继续使用各自显式入口和既有条件,不纳入默认 CI。 +真实资料、真实来源和 provided 服务继续使用所属包的显式条件验收入口;普通门禁报告这些任务未执行。完整受控验收通过不代表真实来源验收通过。 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 19400d7..806323c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,10 @@ version: 2 updates: + - package-ecosystem: devcontainers + directory: / + schedule: + interval: weekly - package-ecosystem: npm directory: / schedule: diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4464693..9650fb8 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -5,12 +5,6 @@ on: push: branches: - main - workflow_dispatch: - inputs: - deployment: - description: 显式构建镜像并执行已有 Docker 部署入口 - type: boolean - default: false permissions: contents: read @@ -22,15 +16,12 @@ concurrency: jobs: check: - name: ${{ matrix.capability }} + name: 完整轻重验收 runs-on: ubuntu-latest - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - capability: [root, cli, web] + timeout-minutes: 150 env: - DIGITAL_CI_EVIDENCE: ${{ github.workspace }}/.scratch/ci/${{ matrix.capability }} + DIGITAL_AUTOMATION_WORKSPACE: ${{ github.workspace }} + DIGITAL_CI_EVIDENCE: ${{ github.workspace }}/.scratch/ci/full steps: - name: 检出独立工作区 uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 @@ -48,33 +39,29 @@ jobs: cache: true - name: 准备本作业临时目录 run: | - ci_tmp="$RUNNER_TEMP/d18-${{ matrix.capability }}" + ci_tmp="$RUNNER_TEMP/digital-check-${{ github.run_id }}-${{ github.run_attempt }}" mkdir -m 700 -p "$ci_tmp" printf 'TMPDIR=%s\n' "$ci_tmp" >> "$GITHUB_ENV" - name: 安装锁定依赖 run: pnpm install --frozen-lockfile - - name: 安装 Chromium - run: pnpm --filter @digital/web exec playwright install --with-deps chromium - - name: 构建场景正式依赖 - if: matrix.capability != 'root' - run: pnpm exec turbo run build --filter=@digital/web --filter=@digital/cli --filter=@digital/db-migrations - name: 运行相同本地入口 id: execution - timeout-minutes: 30 + timeout-minutes: 120 env: - TURBO_CONCURRENCY: ${{ matrix.capability == 'root' && '2' || '10' }} - run: node --conditions=digital-source scripts/ci-environment.ts run ${{ matrix.capability }} + TURBO_CONCURRENCY: "2" + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts run - name: 停止自有环境并收集真实数据及完整日志 id: collection if: always() - run: node --conditions=digital-source scripts/ci-environment.ts collect ${{ matrix.capability }} + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts collect - name: 上传场景证据 id: upload if: always() uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 with: - name: ci-${{ matrix.capability }}-${{ github.run_id }}-${{ github.run_attempt }} + name: ci-full-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ env.DIGITAL_CI_EVIDENCE }} + include-hidden-files: true if-no-files-found: error retention-days: 7 - name: 核实上传产物后显式删除自有环境 @@ -83,8 +70,8 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} DIGITAL_CI_ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} - DIGITAL_CI_ARTIFACT_NAME: ci-${{ matrix.capability }}-${{ github.run_id }}-${{ github.run_attempt }} - run: node --conditions=digital-source scripts/ci-environment.ts delete ${{ matrix.capability }} + DIGITAL_CI_ARTIFACT_NAME: ci-full-${{ github.run_id }}-${{ github.run_attempt }} + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts delete - name: 分别报告执行、保存、上传与清理结果 if: always() env: @@ -92,46 +79,4 @@ jobs: DIGITAL_CI_COLLECTION_OUTCOME: ${{ steps.collection.outcome }} DIGITAL_CI_UPLOAD_OUTCOME: ${{ steps.upload.outcome }} DIGITAL_CI_DELETION_OUTCOME: ${{ steps.deletion.outcome }} - run: node --conditions=digital-source scripts/ci-environment.ts result ${{ matrix.capability }} - - deployment: - name: 显式 Docker 部署 - if: github.event_name == 'workflow_dispatch' && inputs.deployment - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - with: - persist-credentials: false - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 - with: - node-version-file: package.json - package-manager-cache: false - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - cache: true - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - name: 安装依赖与浏览器 - run: | - pnpm install --frozen-lockfile - pnpm --filter @digital/web exec playwright install --with-deps chromium - - name: 构建正式部署镜像与 checker 依赖 - run: | - pnpm exec turbo run build --filter=@digital/web --filter=@digital/cli --filter=@digital/db-migrations - docker build -f apps/web/Dockerfile --target standalone -t completion-ci-standalone:${{ github.run_id }} . - docker build -f apps/web/Dockerfile --target runtime -t completion-ci-runtime:${{ github.run_id }} . - - name: 调用原部署入口 - env: - DIGITAL_DEPLOYMENT_STANDALONE_IMAGE: completion-ci-standalone:${{ github.run_id }} - DIGITAL_DEPLOYMENT_RUNTIME_IMAGE: completion-ci-runtime:${{ github.run_id }} - DIGITAL_DEPLOYMENT_ARTIFACTS: ${{ github.workspace }}/.scratch/ci/deployment/standalone - DIGITAL_COMPOSE_CHECK_ARTIFACTS: ${{ github.workspace }}/.scratch/ci/deployment/compose - run: pnpm run check:deployment - - name: 上传部署诊断 - if: always() - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 - with: - name: deployment-${{ github.run_id }}-${{ github.run_attempt }} - path: .scratch/ci/deployment - if-no-files-found: error - retention-days: 7 + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts result diff --git a/apps/cli/package.json b/apps/cli/package.json index 4f87738..c4dc2fa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,7 +20,8 @@ "digital-source": "./src/main.ts", "types": "./dist/main.d.ts", "default": "./dist/main.js" - } + }, + "./cli": "./dist/cli.js" }, "publishConfig": { "access": "public" @@ -33,9 +34,7 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "postbuild": "node -e \"if (process.platform !== 'win32') require('node:fs').chmodSync('dist/cli.js', 0o755)\"", "prepack": "pnpm exec turbo run build --filter=.", - "pretest": "pnpm --filter @digital/sqlite build", - "pretest:e2e": "pnpm --filter @digital/sqlite build && pnpm --filter @digital/cli build", - "test": "vitest run test/unit test/integration --reporter=verbose", + "test": "vitest run test/unit test/integration --exclude '**/controlled-fanbox-session.test.ts' --exclude '**/offline-backup.test.ts' --reporter=verbose", "test:e2e": "node --conditions=digital-source test/e2e/run-journeys.ts --default source distribution", "test:corpus-real": "node --conditions=digital-source test/e2e/run-journeys.ts --real source distribution", "test:e2e:bangumi-live": "DIGITAL_E2E_JOURNEY=bangumi-episodic-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", @@ -43,9 +42,9 @@ "test:e2e:pdf-live": "DIGITAL_E2E_JOURNEY=pdf-publication-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", "test:e2e:tmdb-omdb-live": "DIGITAL_E2E_JOURNEY=tmdb-omdb-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", "test:e2e:deep-integrity": "DIGITAL_DEEP_CORPUS_INTEGRITY=1 node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - "pretest:e2e:environment": "pnpm --filter @digital/sqlite build && pnpm --filter @digital/cli build", "test:e2e:environment": "node --conditions=digital-source test/e2e/environment-contracts.ts", - "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", + "test:product": "vitest run test/integration/controlled-fanbox-session.test.ts test/integration/offline-backup.test.ts --reporter=verbose" }, "dependencies": { "@digital/cli-delivery": "workspace:*", diff --git a/apps/cli/test/e2e/environment-contracts.ts b/apps/cli/test/e2e/environment-contracts.ts index 5a951a1..1a7523e 100644 --- a/apps/cli/test/e2e/environment-contracts.ts +++ b/apps/cli/test/e2e/environment-contracts.ts @@ -10,7 +10,15 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; -import { cp, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import { + cp, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -25,6 +33,7 @@ import { fingerprintEnvironment, openProvidedEnvironment, prepareScenarioEnvironment, + journeyEvidenceDirectory, processStartTimeTicks, recycleOwnedEnvironment, reserveFreePort, @@ -55,8 +64,8 @@ const fixtureRoot = path.resolve( "fixtures", "controlled-source-io", ); -/** 与验收运行器共用的证据目录:父终端污染检查据此核对本次新增的那一份。 */ -const evidenceRoot = path.join(workspaceRoot, "run", "02-evidence"); +/** 所属证据根;正式子进程各自分配独立目录和交接台账。 */ +const evidenceRoot = journeyEvidenceDirectory; /** 本套件自己拥有的环境一律带 completion-02- 前缀,端口只取 53001—53999 内的空闲结果。 */ function nameFor(purpose: string): { runId: string; environment: string } { @@ -227,13 +236,12 @@ function waitForAbort( */ async function waitForOwnedSceneInFlight( environmentsRoot: string, - before: ReadonlySet, + handoffLedger: string, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; for (;;) { - for (const entry of await readdir(environmentsRoot).catch(() => [])) { - if (before.has(entry) || !entry.startsWith("completion-02-")) continue; + for (const entry of await childOwnedEnvironments(handoffLedger)) { const opened = await stat( path.join(environmentsRoot, entry, "product-data", "catalogue.sqlite"), ).then( @@ -903,8 +911,9 @@ const checks: readonly Check[] = [ name: "polluted-parent-terminal-never-reaches-production-entry", async run({ signal }) { const pollutedRoot = await mkdtemp(path.join(tmpdir(), "02-polluted-")); - const beforeEvidence = new Set( - await readdir(evidenceRoot).catch(() => []), + await mkdir(evidenceRoot, { recursive: true }); + const evidenceDirectory = await mkdtemp( + path.join(evidenceRoot, "parent-pollution-"), ); const polluted = { ...process.env, @@ -922,8 +931,10 @@ const checks: readonly Check[] = [ const result = await runNode( [ "--conditions=digital-source", - "apps/cli/test/e2e/run-journeys.ts", + path.join(e2eRoot, "run-journeys.ts"), "distribution", + "--evidence-directory", + evidenceDirectory, ], polluted, signal, @@ -943,7 +954,7 @@ const checks: readonly Check[] = [ [], `父终端遗留的产品根不得被写入:${written.join("、")}`, ); - const evidence = await readNewestEvidence(evidenceRoot, beforeEvidence); + const evidence = await readSingleEvidence(evidenceDirectory); assert.equal(stringField(evidence, "sourceMode"), "controlled"); assert.match( stringField(evidence, "productDataRoot") ?? "", @@ -1388,18 +1399,21 @@ const checks: readonly Check[] = [ ".digital", "environments", ); - const beforeDirectories = new Set( - await readdir(environmentsRoot).catch(() => []), - ); - const beforeEvidence = new Set( - await readdir(evidenceRoot).catch(() => []), + await mkdir(evidenceRoot, { recursive: true }); + const evidenceDirectory = await mkdtemp( + path.join(evidenceRoot, "formal-entry-signal-"), ); + const handoffLedger = path.join(evidenceDirectory, "owners.ndjson"); const child = spawn( process.execPath, [ "--conditions=digital-source", - "apps/cli/test/e2e/run-journeys.ts", + path.join(e2eRoot, "run-journeys.ts"), "distribution", + "--evidence-directory", + evidenceDirectory, + "--handoff-ledger", + handoffLedger, ], { cwd: workspaceRoot, @@ -1430,11 +1444,7 @@ const checks: readonly Check[] = [ let observed = ""; try { observed = await Promise.race([ - waitForOwnedSceneInFlight( - environmentsRoot, - beforeDirectories, - 120_000, - ), + waitForOwnedSceneInFlight(environmentsRoot, handoffLedger, 120_000), exitCode.then((code) => { throw new Error( `正式入口在收到信号前就结束了(exit=${String(code)}),窗口不足以证明中断收尾:\n${stdout}\n${stderr}`, @@ -1444,7 +1454,7 @@ const checks: readonly Check[] = [ child.kill("SIGINT"); const exited = await exitCode; assert.notEqual(exited, 0, "被信号中断的正式入口不得报告成功"); - const evidence = await readNewestEvidence(evidenceRoot, beforeEvidence); + const evidence = await readSingleEvidence(evidenceDirectory); assert.equal(stringField(evidence, "outcome"), "interrupted"); assert.equal( evidence["deleteStatus"], @@ -1509,12 +1519,8 @@ const checks: readonly Check[] = [ if (child.exitCode === null && child.signalCode === null) child.kill("SIGINT"); await exitCode.catch(() => undefined); - for (const entry of await readdir(environmentsRoot).catch(() => [])) - if ( - !beforeDirectories.has(entry) && - entry.startsWith("completion-02-") - ) - await finalizeOwnedEnvironment(entry, signal); + for (const environment of await childOwnedEnvironments(handoffLedger)) + await finalizeOwnedEnvironment(environment, signal); } }, }, @@ -1891,22 +1897,29 @@ async function loadControlledJourney(): Promise { return loaded.default; } -/** 共享证据目录会累积历次结果:只采信本次运行新增的那一份。 */ -async function readNewestEvidence( - directory: string, - before: ReadonlySet, -): Promise { - const entries = await readdir(directory); - const fresh = entries.filter( - (entry) => entry.endsWith(".json") && !before.has(entry), - ); - const [name] = fresh; - assert.ok( - name !== undefined && fresh.length === 1, - `本次运行应只新增一份证据,实际 ${fresh.length} 份`, - ); - const text = await readFile(path.join(directory, name), "utf8"); - return jsonObject(text, "证据"); +/** 只采信本次正式子进程在 prepare 前交出的资源归属,不认领目录增量。 */ +async function childOwnedEnvironments( + ledgerPath: string, +): Promise { + let contents: string; + try { + contents = await readFile(ledgerPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + // 写入尚未完成的末行没有交接意义;prepare 只在完整追加成功后开始。 + return contents + .split("\n") + .slice(0, -1) + .map((line) => { + const owner = jsonObject(line, "子进程交接台账"); + const environment = stringField(owner, "environment"); + assert.equal(owner["workspacePath"], workspaceRoot); + assert(typeof environment === "string"); + assert(environment.startsWith("completion-02-")); + return environment; + }); } async function readSingleEvidence(directory: string): Promise { diff --git a/apps/cli/test/e2e/environment-runtime.ts b/apps/cli/test/e2e/environment-runtime.ts index acd56fd..32dfb96 100644 --- a/apps/cli/test/e2e/environment-runtime.ts +++ b/apps/cli/test/e2e/environment-runtime.ts @@ -678,15 +678,16 @@ export async function processStartTimeTicks( const runtimeRoot = path.dirname(fileURLToPath(import.meta.url)); -/** 本票独占的启动登记表:位于作者树 run/ 下,前缀 02-。 */ -export const startLedgerPath = path.resolve( - runtimeRoot, - "..", - "..", - "..", - "..", - "run", - "02-start-ledger.ndjson", +/** 本地证据归 CLI 的临时输出;CI 显式提供统一的证据根。 */ +export const journeyEvidenceDirectory = path.join( + process.env.DIGITAL_CI_EVIDENCE === undefined + ? path.resolve(runtimeRoot, "../..", ".turbo/acceptance") + : path.resolve(process.env.DIGITAL_CI_EVIDENCE, "cli"), + "journeys", +); +export const startLedgerPath = path.join( + journeyEvidenceDirectory, + "start-ledger.ndjson", ); export async function appendStartLedger( diff --git a/apps/cli/test/e2e/run-journeys.ts b/apps/cli/test/e2e/run-journeys.ts index e3a89ff..216492f 100644 --- a/apps/cli/test/e2e/run-journeys.ts +++ b/apps/cli/test/e2e/run-journeys.ts @@ -15,6 +15,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { mergeCommandEnvironment, prepareScenarioEnvironment, + journeyEvidenceDirectory, recycleOwnedEnvironment, reserveFreePort, retainProvidedEnvironment, @@ -421,11 +422,11 @@ export type JourneyEnvironmentLifecycle = { }): Promise; }; -const evidenceRoot = path.join(workspaceRoot, "run", "02-evidence"); +const evidenceRoot = journeyEvidenceDirectory; /** * 自有场景世代:环境容器由环境包公开布局放在本树工作区内(`.digital/environments/`), - * 名字始终带 completion-02- 前缀;调用方只在 `run/` 下保存证据,绝不把工作区当成临时目录删除。 + * 名字始终带 completion-02- 前缀;调用方只在明确的临时证据目录保存结果,绝不把工作区当成临时目录删除。 * 世代记录里的 Web 端口一律先在本票区间内实际探测空闲:宁可显式给一个探测过的端口, * 也不让环境包按名字哈希算出区间外的监听口。 */ @@ -992,7 +993,17 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { retainOnSuccess?: boolean; handoffLedger?: string; evidenceDirectory?: string; - } = {}; + } = + process.env.DIGITAL_CI_EVIDENCE === undefined + ? {} + : { + retainOnSuccess: true, + handoffLedger: path.join( + process.env.DIGITAL_CI_EVIDENCE, + "owners.ndjson", + ), + evidenceDirectory: evidenceRoot, + }; const modes: string[] = []; for (let index = 0; index < args.length; index++) { const argument = args[index]!; diff --git a/apps/cli/test/integration/turbo-private-environment.test.ts b/apps/cli/test/integration/turbo-private-environment.test.ts deleted file mode 100644 index b88b672..0000000 --- a/apps/cli/test/integration/turbo-private-environment.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -describe("root Turbo private integration environment", () => { - it("passes credentials only to the live E2E task that needs them", async () => { - const configuration = JSON.parse( - await readFile( - path.resolve(import.meta.dirname, "../../../../turbo.json"), - "utf8", - ), - ) as { - readonly tasks?: { - readonly "test:e2e"?: { - readonly cache?: boolean; - readonly passThroughEnv?: readonly string[]; - }; - readonly "test:e2e:tmdb-omdb-live"?: { - readonly cache?: boolean; - readonly dependsOn?: readonly string[]; - readonly passThroughEnv?: readonly string[]; - }; - readonly "test:e2e:bangumi-live"?: { - readonly cache?: boolean; - readonly dependsOn?: readonly string[]; - readonly passThroughEnv?: readonly string[]; - }; - }; - }; - - expect("globalPassThroughEnv" in configuration).toBe(false); - expect(configuration.tasks?.["test:e2e"]).toEqual({ - dependsOn: ["build"], - cache: false, - passThroughEnv: ["TMPDIR", "TMP", "TEMP"], - }); - expect(configuration.tasks?.["test:e2e:tmdb-omdb-live"]).toEqual({ - dependsOn: ["build"], - cache: false, - passThroughEnv: ["DIGITAL_TMDB_ACCESS_TOKEN", "DIGITAL_OMDB_API_KEY"], - }); - expect(configuration.tasks?.["test:e2e:bangumi-live"]).toEqual({ - dependsOn: ["build"], - cache: false, - passThroughEnv: [ - "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", - "DIGITAL_BANGUMI_CLIENT_ID", - "DIGITAL_BANGUMI_CLIENT_SECRET", - "DIGITAL_BANGUMI_REDIRECT_URI", - ], - }); - - const cliPackage = JSON.parse( - await readFile( - path.resolve(import.meta.dirname, "../../package.json"), - "utf8", - ), - ) as { readonly scripts?: Readonly> }; - expect(cliPackage.scripts?.["test:e2e:tmdb-omdb-live"]).toBe( - "DIGITAL_E2E_JOURNEY=tmdb-omdb-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - ); - expect(cliPackage.scripts?.["test:e2e:bangumi-live"]).toBe( - "DIGITAL_E2E_JOURNEY=bangumi-episodic-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - ); - }); -}); diff --git a/apps/cli/turbo.json b/apps/cli/turbo.json index 53ee16c..dc1eeb2 100644 --- a/apps/cli/turbo.json +++ b/apps/cli/turbo.json @@ -2,14 +2,142 @@ "extends": ["//"], "tags": ["composition-root"], "tasks": { - "build": { "outputs": ["dist/**"] }, - "test": { "dependsOn": ["build", "@digital/sqlite#build"] }, - "test:e2e": { - "dependsOn": ["build", "@digital/sqlite#build"], + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "postbuild": { + "tags": ["work:lifecycle"], + "cache": false + }, + "prepack": { + "tags": ["work:lifecycle"], "cache": false }, + "test": { + "dependsOn": ["build", "@digital/sqlite#build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "test:e2e": { + "dependsOn": [ + "build", + "@digital/sqlite#build", + "@digital/web#build", + "@digital/web#test:e2e" + ], + "cache": false, + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"], + "tags": ["quality:heavy"] + }, + "test:corpus-real": { + "dependsOn": ["build"], + "cache": false, + "passThroughEnv": [ + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_TMDB_ACCESS_TOKEN", + "DIGITAL_OMDB_API_KEY", + "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", + "DIGITAL_BANGUMI_CLIENT_ID", + "DIGITAL_BANGUMI_CLIENT_SECRET", + "DIGITAL_BANGUMI_REDIRECT_URI", + "DIGITAL_FANBOX_LIVE_FAILURE_EVIDENCE_PATH", + "DIGITAL_CI_EVIDENCE" + ], + "tags": ["quality:conditional"] + }, + "test:e2e:bangumi-live": { + "dependsOn": ["build"], + "cache": false, + "passThroughEnv": [ + "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", + "DIGITAL_BANGUMI_CLIENT_ID", + "DIGITAL_BANGUMI_CLIENT_SECRET", + "DIGITAL_BANGUMI_REDIRECT_URI", + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE" + ], + "tags": ["quality:conditional"] + }, + "test:e2e:fanbox-live": { + "tags": ["quality:conditional"], + "cache": false, + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"] + }, + "test:e2e:pdf-live": { + "tags": ["quality:conditional"], + "cache": false, + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"] + }, + "test:e2e:tmdb-omdb-live": { + "dependsOn": ["build"], + "cache": false, + "passThroughEnv": [ + "DIGITAL_TMDB_ACCESS_TOKEN", + "DIGITAL_OMDB_API_KEY", + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE" + ], + "tags": ["quality:conditional"] + }, + "test:e2e:deep-integrity": { + "tags": ["quality:conditional"], + "cache": false, + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"] + }, "test:e2e:environment": { "dependsOn": ["build", "@digital/sqlite#build"], + "cache": false, + "tags": ["quality:heavy"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test:product": { + "dependsOn": ["build", "@digital/sqlite#build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:heavy"], "cache": false } } diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 35674eb..d7ca7dd 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,21 +1,23 @@ # syntax=docker/dockerfile:1.7 +# @template-mirror NODE_VERSION +ARG NODE_VERSION="24.21.0" +# @end-template-mirror + ARG DEPLOYMENT_BUILD_ID="local" -FROM node:24-bookworm-slim AS base -# 与根 package.json 的 packageManager 保持一致:pruner 的 `pnpm fetch` 在 package.json 就位前 -# 运行(用此激活版本),`pnpm install --offline` 在其后运行(corepack 读 packageManager)。 -# 两者若跨 pnpm 主版本,fetch 写入的离线元数据命名空间与 install 读取的不兼容,报 -# ERR_PNPM_NO_OFFLINE_META。统一为工作区声明的 pnpm@12.3.4 以保证同一版本与裁剪一致性。 -ARG PACKAGE_MANAGER_PIN="pnpm@12.3.4" +FROM node:${NODE_VERSION}-bookworm-slim AS base ENV COREPACK_HOME="/corepack" ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH" WORKDIR /repo -RUN install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" \ +COPY package.json /root-manifest.json +RUN install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" "$PNPM_HOME/bin" \ && corepack enable --install-directory "$PNPM_HOME" \ + && package_manager="$(node -p "require('/root-manifest.json').packageManager")" \ + && rm /root-manifest.json \ && for attempt in 1 2 3; do \ - if corepack prepare "$PACKAGE_MANAGER_PIN" --activate; then break; fi; \ + if corepack prepare "$package_manager" --activate; then break; fi; \ [ "$attempt" -lt 3 ] || exit 1; \ sleep 2; \ done \ @@ -37,10 +39,10 @@ COPY --from=pruner /repo/tsconfig.json /repo/tsconfig.config.json ./ RUN pnpm fetch COPY --from=pruner /repo/out/json/ ./ RUN pnpm install --offline --frozen-lockfile -COPY --from=pruner /repo/out/full/ ./ # FANBOX production transport 与 package 中固定的 Playwright 版本共用同一 Chromium revision。 ENV PLAYWRIGHT_BROWSERS_PATH="/ms-playwright" RUN pnpm --filter @digital/fanbox exec playwright install chromium +COPY --from=pruner /repo/out/full/ ./ # 由 Turbo 按正式 workspace 依赖图构建交付所需包,再由 deploy 在隔离的部署目录中定点 # 注入其发布文件。裁剪只决定包的存在,构建顺序与闭包由 Turbo 计算。 RUN TURBO_TELEMETRY_DISABLED=1 pnpm exec turbo run build \ @@ -48,7 +50,7 @@ RUN TURBO_TELEMETRY_DISABLED=1 pnpm exec turbo run build \ RUN pnpm --config.inject-workspace-packages=true --filter ./apps/web deploy --prod /runtime-deploy RUN pnpm --config.inject-workspace-packages=true --filter ./packages/db-migrations deploy --prod /migration-deploy -FROM node:24-bookworm-slim AS application-runtime +FROM node:${NODE_VERSION}-bookworm-slim AS application-runtime ARG DEPLOYMENT_BUILD_ID LABEL org.opencontainers.image.version="$DEPLOYMENT_BUILD_ID" ENV NODE_ENV="production" \ diff --git a/apps/web/Dockerfile.dockerignore b/apps/web/Dockerfile.dockerignore index 8243776..be1e49a 100644 --- a/apps/web/Dockerfile.dockerignore +++ b/apps/web/Dockerfile.dockerignore @@ -13,6 +13,7 @@ apps/web/node_modules/.tmp # 本地机密、agent 上下文与教学资料不得进入生产构建上下文。 .scratch/ +credentials/ .agents/ AGENTS.md GLOSSARY*.md diff --git a/apps/web/Dockerfile.playwright-client b/apps/web/Dockerfile.playwright-client index 9b2543e..60df672 100644 --- a/apps/web/Dockerfile.playwright-client +++ b/apps/web/Dockerfile.playwright-client @@ -1,8 +1,12 @@ -FROM node:24-bookworm-slim -ARG PACKAGE_MANAGER_PIN="pnpm@12.3.4" +# @template-mirror NODE_VERSION +ARG NODE_VERSION="24.21.0" +# @end-template-mirror +FROM node:${NODE_VERSION}-bookworm-slim +COPY package.json /root-manifest.json RUN corepack enable \ - && corepack prepare "$PACKAGE_MANAGER_PIN" --activate \ - && test "$(pnpm --version)" = "12.3.4" + && package_manager="$(node -p "require('/root-manifest.json').packageManager")" \ + && rm /root-manifest.json \ + && corepack prepare "$package_manager" --activate RUN apt-get update && apt-get install -y --no-install-recommends \ libnspr4 libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \ diff --git a/apps/web/package.json b/apps/web/package.json index 333f1bd..ca8d1b4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,20 +35,23 @@ "scripts": { "build": "vike build", "deployment": "node --conditions=digital-source scripts/check-standalone-deployment.ts && node --conditions=digital-source scripts/check-compose-deployment.ts", - "dev": "node --conditions=digital-source ../../scripts/development-environment.ts", + "dev": "pnpm --workspace-root run preview:dev", "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint": "oxlint --quiet --format=unix --type-aware --config ../../oxlint.config.ts .", - "lint:fix": "oxlint --type-aware --format=unix --config ../../oxlint.config.ts . --fix", + "lint": "oxlint --quiet --format=unix --type-aware --config ../../oxlint.config.ts . && shellcheck scripts/container-entrypoint.sh scripts/compose-entrypoint.sh", + "lint:fix": "oxlint --type-aware --format=unix --config ../../oxlint.config.ts . --fix && shellcheck scripts/container-entrypoint.sh scripts/compose-entrypoint.sh", "preview": "vike preview", - "pretest": "pnpm --filter @digital/sqlite build", "start": "node ./dist/server/index.mjs", - "test": "vitest run --reporter=agent --silent=passed-only --passWithNoTests", + "test": "vitest run --exclude '**/process-lifecycle.test.ts' --reporter=agent --silent=passed-only --passWithNoTests", "test:e2e": "node scripts/run-playwright.ts", "test:e2e:managed-service": "node scripts/run-managed-service-acceptance.ts --mode owned", "test:e2e:provided-service": "node scripts/run-managed-service-acceptance.ts --mode provided", "typecheck": "node --conditions=digital-source scripts/run-vue-tsc.ts --build --noEmit --pretty false", - "dev:serve": "node --conditions=digital-source ../../scripts/development-task.ts" + "dev:serve": "pnpm --workspace-root run development:serve", + "test:process": "vitest run test/process-lifecycle.test.ts --reporter=agent --silent=passed-only", + "browser:prepare": "playwright install --with-deps chromium", + "acceptance:prepare": "node --conditions=digital-source scripts/prepare-acceptance.ts", + "deployment:prepare": "node --conditions=digital-source scripts/prepare-deployment.ts" }, "dependencies": { "@digital/composition": "workspace:*", diff --git a/apps/web/scripts/acceptance-inputs.ts b/apps/web/scripts/acceptance-inputs.ts new file mode 100644 index 0000000..71223ef --- /dev/null +++ b/apps/web/scripts/acceptance-inputs.ts @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export const acceptanceInputsPath = path.resolve( + import.meta.dirname, + "../.turbo/acceptance/inputs.json", +); + +export type AcceptanceInputs = { + readonly workspacePath: string; + readonly sourceDirectory: string; + readonly composeFile: string; +}; + +export async function acceptanceInputs(): Promise { + try { + const inputs = JSON.parse( + await readFile(acceptanceInputsPath, "utf8"), + ) as AcceptanceInputs; + for (const value of [ + inputs.workspacePath, + inputs.sourceDirectory, + inputs.composeFile, + ]) + if (typeof value !== "string" || !path.isAbsolute(value)) + throw new Error( + "验收输入必须包含工作区、受控来源与 Compose 文件的绝对路径", + ); + return inputs; + } catch (cause) { + throw new Error( + "验收输入尚未准备;请通过 Turbo 任务运行正式 acceptance:prepare 前置", + { + cause, + }, + ); + } +} diff --git a/apps/web/scripts/check-compose-deployment.ts b/apps/web/scripts/check-compose-deployment.ts index ab8bf80..55a2048 100644 --- a/apps/web/scripts/check-compose-deployment.ts +++ b/apps/web/scripts/check-compose-deployment.ts @@ -15,21 +15,35 @@ import { intakeDirectories, } from "@digital/test-support/product-client"; -import { fileCommandJournal, runCommand } from "./environment-lifecycle.ts"; +import { deploymentImages } from "./deployment-preparation.ts"; +import { + appendLedger, + claimFreePort, + fileCommandJournal, + runCommand, +} from "./environment-lifecycle.ts"; -const repositoryRoot = path.resolve(import.meta.dirname, "../../.."); +const { + workspacePath: repositoryRoot, + sourceDirectory, + composeFile, + standaloneImage: migrationImage, + runtimeImage, +} = await deploymentImages(); const uniqueId = `completion-16-${process.pid}-${Date.now()}`; const artifacts = path.resolve( process.env.DIGITAL_COMPOSE_CHECK_ARTIFACTS ?? - `.scratch/deployment/${uniqueId}`, + path.join( + process.env.DIGITAL_CI_EVIDENCE ?? ".scratch/deployment", + uniqueId, + ), ); -const port = Number(process.env.DIGITAL_COMPOSE_CHECK_PORT ?? "3000"); +const port = + process.env.DIGITAL_COMPOSE_CHECK_PORT === undefined + ? await claimFreePort({ first: 0, last: 0 }) + : Number(process.env.DIGITAL_COMPOSE_CHECK_PORT); const uid = Number(process.env.DIGITAL_COMPOSE_CHECK_UID ?? "1001"); const gid = Number(process.env.DIGITAL_COMPOSE_CHECK_GID ?? "1001"); -const migrationImage = process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE; -const runtimeImage = process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE; -if (!migrationImage || !runtimeImage) - throw new Error("必须显式提供已构建的迁移及运行镜像引用"); const endpoint = { command: process.env.DIGITAL_DEPLOYMENT_DOCKER_COMMAND ?? "docker", arguments: JSON.parse( @@ -46,6 +60,7 @@ const operations: unknown[] = []; const saveErrors: string[] = []; const cleanupErrors: string[] = []; let mainError: string | undefined; +let executionCompleted = false; let saveNumber = 0; async function saveOperations(): Promise { @@ -109,6 +124,11 @@ async function prepare( environment: `${uniqueId}-${suffix}`, }; environments.push(reference); + if (process.env.DIGITAL_CI_EVIDENCE !== undefined) + await appendLedger( + path.join(process.env.DIGITAL_CI_EVIDENCE, "container-owners.ndjson"), + reference, + ); success( await operate({ ...reference, @@ -116,7 +136,7 @@ async function prepare( endpoint, image: migrationImage!, compose: { - file: path.join(repositoryRoot, "docker-compose.yml"), + file: composeFile, configuration: { DIGITAL_MIGRATION_IMAGE: migrationImage!, DIGITAL_RUNTIME_IMAGE: runtimeImage!, @@ -207,6 +227,7 @@ async function save(reference: ContainerReference): Promise { } async function remove(reference: ContainerReference): Promise { await save(reference); + if (process.env.DIGITAL_CI_EVIDENCE !== undefined) return; success( await operate({ ...reference, action: "delete", deleteData: true }, true), ); @@ -266,11 +287,9 @@ async function assertOwnersStopped( try { await mkdir(artifacts, { recursive: true, mode: 0o700 }); - await cp( - path.join(repositoryRoot, "fixtures/controlled-source-io/material"), - observed, - { recursive: true }, - ); + await cp(path.join(sourceDirectory, "material"), observed, { + recursive: true, + }); await chmod(observed, 0o755); const materialsBefore = await digestDirectory(observed); const normal = await prepare("normal"); @@ -403,6 +422,7 @@ try { await stop(failed, false); await save(failed); assert.equal(await digestDirectory(observed), materialsBefore); + executionCompleted = true; } catch (error) { mainError = error instanceof Error ? (error.stack ?? error.message) : String(error); @@ -419,6 +439,27 @@ try { cleanupErrors.push(`${reference.environment}: ${String(error)}`); } } + if ( + executionCompleted && + saveErrors.length === 0 && + cleanupErrors.length === 0 && + process.env.DIGITAL_CI_EVIDENCE === undefined + ) { + for (const reference of environments) { + const observed = await operate({ ...reference, action: "inspect" }, true); + if (observed.status !== "ok") { + cleanupErrors.push(`${reference.environment}: 回收前状态未确认`); + continue; + } + if (observed.remaining.length === 0 && !observed.observation?.dataPresent) + continue; + try { + await remove(reference); + } catch (error) { + cleanupErrors.push(`${reference.environment}: ${String(error)}`); + } + } + } await saveOperations(); if (saveErrors.length > 0 || cleanupErrors.length > 0) process.exitCode = 1; } diff --git a/apps/web/scripts/check-standalone-deployment.ts b/apps/web/scripts/check-standalone-deployment.ts index 2924d92..14f1365 100644 --- a/apps/web/scripts/check-standalone-deployment.ts +++ b/apps/web/scripts/check-standalone-deployment.ts @@ -11,17 +11,28 @@ import { type ContainerRequest, } from "@digital/environment/container"; -import { fileCommandJournal, runCommand } from "./environment-lifecycle.ts"; +import { deploymentImages } from "./deployment-preparation.ts"; +import { + appendLedger, + claimFreePort, + fileCommandJournal, + runCommand, +} from "./environment-lifecycle.ts"; -const repositoryRoot = path.resolve(import.meta.dirname, "../../.."); +const { + workspacePath: repositoryRoot, + sourceDirectory: fixture, + standaloneImage, + runtimeImage, +} = await deploymentImages(); const uniqueId = `completion-15-${process.pid}-${Date.now()}`; const artifacts = path.resolve( - process.env.DIGITAL_DEPLOYMENT_ARTIFACTS ?? `.scratch/deployment/${uniqueId}`, + process.env.DIGITAL_DEPLOYMENT_ARTIFACTS ?? + path.join( + process.env.DIGITAL_CI_EVIDENCE ?? ".scratch/deployment", + uniqueId, + ), ); -const standaloneImage = process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE; -const runtimeImage = process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE; -if (!standaloneImage || !runtimeImage) - throw new Error("必须显式提供已构建的 standalone 和 runtime 镜像引用"); const endpoint: ContainerEndpoint = { command: process.env.DIGITAL_DEPLOYMENT_DOCKER_COMMAND ?? "docker", arguments: JSON.parse( @@ -29,8 +40,10 @@ const endpoint: ContainerEndpoint = { ) as string[], host: process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock", }; -const port = Number(process.env.DIGITAL_DEPLOYMENT_PORT ?? "8080"); -const fixture = path.join(repositoryRoot, "fixtures/controlled-source-io"); +const port = + process.env.DIGITAL_DEPLOYMENT_PORT === undefined + ? await claimFreePort({ first: 0, last: 0 }) + : Number(process.env.DIGITAL_DEPLOYMENT_PORT); const controller = new AbortController(); for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort(new Error(`收到 ${signal}`))); @@ -49,6 +62,7 @@ const archiveArguments = JSON.parse( const saveErrors: string[] = []; const cleanupErrors: string[] = []; let mainError: string | undefined; +let executionCompleted = false; async function operate( request: ContainerRequest, @@ -99,6 +113,11 @@ async function prepare( environment: `${uniqueId}-${name}`, }; environments.push(reference); + if (process.env.DIGITAL_CI_EVIDENCE !== undefined) + await appendLedger( + path.join(process.env.DIGITAL_CI_EVIDENCE, "container-owners.ndjson"), + reference, + ); requireSuccess( await operate({ ...reference, @@ -285,6 +304,7 @@ async function finish(reference: ContainerReference) { true, ); requireSuccess(saved); + if (process.env.DIGITAL_CI_EVIDENCE !== undefined) return; requireSuccess( await operate({ ...reference, action: "delete", deleteData: true }, true), ); @@ -331,6 +351,9 @@ async function browser( productDataRoot: "/data", materialDirectory: "/controlled-source/material/RJ01668691", manifestPath: path.join(fixture, "manifest.json"), + expectedCoverFile: containerClient + ? "/client-controlled-source/responses/dlsite-cover.png" + : path.join(fixture, "responses", "dlsite-cover.png"), sourceMode: "controlled", productLogFile: "", run: { @@ -353,9 +376,9 @@ async function browser( ...reference, action: "build", contextPath: repositoryRoot, - dockerfile: path.join( - repositoryRoot, - "apps/web/Dockerfile.playwright-client", + dockerfile: path.resolve( + import.meta.dirname, + "../Dockerfile.playwright-client", ), }), ); @@ -376,6 +399,11 @@ async function browser( timeoutMilliseconds: 600_000, mounts: [ { source: repositoryRoot, target: "/workspace", readonly: true }, + { + source: fixture, + target: "/client-controlled-source", + readonly: true, + }, { source: browsers, target: "/playwright-browsers", readonly: true }, { source: handoff, target: "/browser-handoff.json", readonly: true }, ], @@ -420,8 +448,6 @@ async function browser( const args = input === undefined ? [ - "--dir", - "apps/web", "exec", "playwright", "test", @@ -437,7 +463,7 @@ async function browser( phase: "browser", command: process.env.DIGITAL_DEPLOYMENT_BROWSER_COMMAND ?? "pnpm", args, - cwd: repositoryRoot, + cwd: path.resolve(import.meta.dirname, ".."), env: { ...process.env, DIGITAL_ACCEPTANCE_HANDOFF: handoff, @@ -478,6 +504,11 @@ try { assert.equal(Object.keys(prepareOnly.ports ?? {}).length, 0); } else { environments.push(prepared); + if (process.env.DIGITAL_CI_EVIDENCE !== undefined) + await appendLedger( + path.join(process.env.DIGITAL_CI_EVIDENCE, "container-owners.ndjson"), + prepared, + ); // 显式接续已登记环境;先保全上次失败数据,不重做已证明的空卷/仅准备步骤。 requireSuccess( await operate( @@ -594,7 +625,7 @@ try { damaged, `const {DatabaseSync}=await import('node:sqlite');const db=new DatabaseSync('/data/catalogue.sqlite',{readOnly:true});const failure=db.prepare('SELECT id,message FROM __digital_catalogue_migration_failures').get();if(failure?.id!=='20260820000000_v1_catalogue_baseline'||failure.message!=='Error')throw Error('failure ledger changed');if(!db.prepare('SELECT 1 FROM schema_migrations WHERE id=?').get(${JSON.stringify(marker)}))throw Error('original state lost');console.log(JSON.stringify({failure,retained:true}));db.close();`, ); - // 失败现场只保存;删除留给后续明确动作,container export 不作为卷备份。 + // 先证明失败现场可完整保存;整体验收通过后再按归属回收。 requireSuccess( await operate( { @@ -606,19 +637,63 @@ try { true, ), ); + executionCompleted = true; } catch (error) { mainError = String(error); process.exitCode = 1; console.error(error); } finally { for (const reference of environments) { - const result = await operate({ ...reference, action: "stop" }, true); - cleanupErrors.push(...result.cleanupErrors); - saveErrors.push(...result.saveErrors); - if (result.status !== "ok") - cleanupErrors.push( - `${reference.environment}: ${result.failure?.message ?? result.status}`, + try { + const result = await operate({ ...reference, action: "stop" }, true); + cleanupErrors.push(...result.cleanupErrors); + saveErrors.push(...result.saveErrors); + if (result.status !== "ok") + cleanupErrors.push( + `${reference.environment}: ${result.failure?.message ?? result.status}`, + ); + } catch (error) { + cleanupErrors.push(`${reference.environment}: ${String(error)}`); + } + } + if ( + executionCompleted && + saveErrors.length === 0 && + cleanupErrors.length === 0 && + process.env.DIGITAL_CI_EVIDENCE === undefined + ) { + for (const reference of environments) { + const observed = await operate({ ...reference, action: "inspect" }, true); + if (observed.status !== "ok") { + cleanupErrors.push(`${reference.environment}: 回收前状态未确认`); + continue; + } + if (observed.remaining.length === 0 && !observed.observation?.dataPresent) + continue; + const saved = await operate( + { + ...reference, + action: "save", + includeData: true, + directory: path.join(artifacts, `${reference.environment}-final`), + }, + true, ); + if (saved.status !== "ok") { + saveErrors.push(`${reference.environment}: 回收前证据保存失败`); + continue; + } + const deleted = await operate( + { ...reference, action: "delete", deleteData: true }, + true, + ); + if ( + deleted.status !== "ok" || + deleted.remaining.length > 0 || + deleted.observation?.dataPresent + ) + cleanupErrors.push(`${reference.environment}: 资源回收未完成`); + } } await writeFile( path.join(artifacts, "outcome.json"), diff --git a/apps/web/scripts/deployment-preparation.ts b/apps/web/scripts/deployment-preparation.ts new file mode 100644 index 0000000..e6808d9 --- /dev/null +++ b/apps/web/scripts/deployment-preparation.ts @@ -0,0 +1,54 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + acceptanceInputs, + type AcceptanceInputs, +} from "./acceptance-inputs.ts"; + +export const preparedDeploymentPath = path.resolve( + import.meta.dirname, + "../.turbo/deployment/prepared.json", +); + +export type PreparedDeployment = AcceptanceInputs & { + readonly standaloneImage: string; + readonly runtimeImage: string; +}; + +export async function deploymentImages(): Promise { + const inputs = await acceptanceInputs(); + const standaloneImage = process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE; + const runtimeImage = process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE; + if (standaloneImage !== undefined || runtimeImage !== undefined) { + if (!standaloneImage || !runtimeImage) + throw new Error( + "显式部署需要同时提供 standalone 与 runtime 两份镜像引用", + ); + return { + ...inputs, + standaloneImage, + runtimeImage, + }; + } + let prepared: PreparedDeployment; + try { + prepared = JSON.parse( + await readFile(preparedDeploymentPath, "utf8"), + ) as PreparedDeployment; + } catch (error) { + throw new Error( + "部署镜像尚未准备;请通过 deployment 的 Turbo 任务运行其正式前置", + { cause: error }, + ); + } + if ( + prepared.workspacePath !== inputs.workspacePath || + prepared.sourceDirectory !== inputs.sourceDirectory || + prepared.composeFile !== inputs.composeFile || + !prepared.standaloneImage || + !prepared.runtimeImage + ) + throw new Error("已准备镜像与本次工作区不一致;请重新运行正式部署准备"); + return prepared; +} diff --git a/apps/web/scripts/environment-lifecycle.ts b/apps/web/scripts/environment-lifecycle.ts index 5ae4a83..f1301ed 100644 --- a/apps/web/scripts/environment-lifecycle.ts +++ b/apps/web/scripts/environment-lifecycle.ts @@ -2,14 +2,6 @@ import { appendFile, mkdir } from "node:fs/promises"; import { createServer } from "node:net"; import path from "node:path"; -/** 公开生命周期入口:只消费 `digital-environment` 已构建的 CLI 交付物。 */ -export const environmentCliRelativePath = path.join( - "packages", - "environment", - "dist", - "cli.js", -); - /** 本票(Web 真实 HTTP 验收)只允许使用 54001—54999 端口段。 */ export const ownedPortRange = { first: 54_001, last: 54_999 } as const; @@ -213,21 +205,28 @@ export async function claimFreePort( ): Promise { for (let port = range.first; port <= range.last; port += 1) { const available = await probePort(port); - if (available) return port; + if (available !== undefined) return available; } throw new Error(`自有端口段 ${range.first}—${range.last} 内没有可用端口`); } -function probePort(port: number): Promise { +function probePort(port: number): Promise { return new Promise((resolve) => { const server = createServer(); - const settle = (value: boolean): void => { + const settle = (value: number | undefined): void => { server.close(() => { resolve(value); }); }; - server.once("error", () => resolve(false)); - server.once("listening", () => settle(true)); + server.once("error", () => resolve(undefined)); + server.once("listening", () => { + const address = server.address(); + settle( + typeof address === "object" && address !== null + ? address.port + : undefined, + ); + }); server.listen(port, "127.0.0.1"); }); } diff --git a/apps/web/scripts/prepare-acceptance.ts b/apps/web/scripts/prepare-acceptance.ts new file mode 100644 index 0000000..f8502a7 --- /dev/null +++ b/apps/web/scripts/prepare-acceptance.ts @@ -0,0 +1,41 @@ +import { mkdir, rename, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + acceptanceInputsPath, + type AcceptanceInputs, +} from "./acceptance-inputs.ts"; + +const flags = new Map(); +const argv = process.argv.slice(2); +for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if ( + !key || + !["--workspace", "--source-directory", "--compose-file"].includes(key) || + !value + ) + throw new Error(`验收准备参数无效:${argv.slice(index).join(" ")}`); + flags.set(key, value); +} +function absolutePath(flag: string): string { + const value = flags.get(flag); + if (!value || !path.isAbsolute(value)) + throw new Error(`验收准备需要显式绝对路径 ${flag}`); + return value; +} +const inputs: AcceptanceInputs = { + workspacePath: absolutePath("--workspace"), + sourceDirectory: absolutePath("--source-directory"), + composeFile: absolutePath("--compose-file"), +}; +for (const directory of [inputs.workspacePath, inputs.sourceDirectory]) + if (!(await stat(directory)).isDirectory()) + throw new Error(`验收输入不是目录:${directory}`); +if (!(await stat(inputs.composeFile)).isFile()) + throw new Error(`Compose 输入不是文件:${inputs.composeFile}`); +await mkdir(path.dirname(acceptanceInputsPath), { recursive: true }); +const temporary = `${acceptanceInputsPath}.${process.pid}`; +await writeFile(temporary, `${JSON.stringify(inputs, null, 2)}\n`); +await rename(temporary, acceptanceInputsPath); diff --git a/apps/web/scripts/prepare-deployment.ts b/apps/web/scripts/prepare-deployment.ts new file mode 100644 index 0000000..d813dc0 --- /dev/null +++ b/apps/web/scripts/prepare-deployment.ts @@ -0,0 +1,107 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { acceptanceInputs } from "./acceptance-inputs.ts"; +import { preparedDeploymentPath } from "./deployment-preparation.ts"; + +const { workspacePath, sourceDirectory, composeFile } = + await acceptanceInputs(); +const endpoint = process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock"; +if (!endpoint.startsWith("unix://")) + throw new Error("受控部署验收需要可访问本工作区挂载的 Unix Docker daemon"); +const dockerCommand = process.env.DIGITAL_DEPLOYMENT_DOCKER_COMMAND ?? "docker"; +const dockerArguments = JSON.parse( + process.env.DIGITAL_DEPLOYMENT_DOCKER_ARGUMENTS ?? "[]", +) as string[]; +const identity = createHash("sha256") + .update(workspacePath) + .digest("hex") + .slice(0, 16); +const standaloneImage = + process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE ?? + `digital-check-${identity}:standalone`; +const runtimeImage = + process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE ?? + `digital-check-${identity}:runtime`; +const suppliedImages = + process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE !== undefined || + process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE !== undefined; +if ( + suppliedImages && + (!process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE || + !process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE) +) + throw new Error("显式部署需要同时提供 standalone 与 runtime 两份镜像引用"); +const cancellation = new AbortController(); +for (const signal of ["SIGINT", "SIGTERM"] as const) + process.once(signal, () => cancellation.abort(signal)); + +async function docker(args: readonly string[]): Promise { + if (cancellation.signal.aborted) throw new Error("部署准备已取消"); + const child = spawn( + dockerCommand, + [...dockerArguments, "--host", endpoint, ...args], + { + cwd: workspacePath, + stdio: "inherit", + }, + ); + const cancel = (): void => { + child.kill("SIGTERM"); + }; + cancellation.signal.addEventListener("abort", cancel, { once: true }); + try { + const status = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code)); + }); + if (status !== 0 || cancellation.signal.aborted) + throw new Error( + `Docker 部署准备失败:${args[0]},退出码 ${String(status)}`, + ); + } finally { + cancellation.signal.removeEventListener("abort", cancel); + } +} + +await docker(["info", "--format", "{{.ID}}"]); +await docker(["compose", "version"]); +if (suppliedImages) { + await docker(["image", "inspect", standaloneImage, runtimeImage]); +} else + for (const [target, tag] of [ + ["standalone", standaloneImage], + ["runtime", runtimeImage], + ] as const) + await docker([ + "build", + ...(process.env.DIGITAL_DEPLOYMENT_BUILD_NETWORK === undefined + ? [] + : ["--network", process.env.DIGITAL_DEPLOYMENT_BUILD_NETWORK]), + "--file", + path.resolve(import.meta.dirname, "../Dockerfile"), + "--target", + target, + "--tag", + tag, + workspacePath, + ]); +await mkdir(path.dirname(preparedDeploymentPath), { recursive: true }); +const temporary = `${preparedDeploymentPath}.${process.pid}`; +await writeFile( + temporary, + JSON.stringify( + { + workspacePath, + sourceDirectory, + composeFile, + standaloneImage, + runtimeImage, + }, + null, + 2, + ), +); +await rename(temporary, preparedDeploymentPath); diff --git a/apps/web/scripts/run-managed-service-acceptance.ts b/apps/web/scripts/run-managed-service-acceptance.ts index cc67d22..4a6a86a 100644 --- a/apps/web/scripts/run-managed-service-acceptance.ts +++ b/apps/web/scripts/run-managed-service-acceptance.ts @@ -2,11 +2,12 @@ import { spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { copyFile, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { acceptanceInputs } from "./acceptance-inputs.ts"; import { appendLedger, claimFreePort, - environmentCliRelativePath, fileCommandJournal, lifecycleResult, nestedRun, @@ -15,16 +16,9 @@ import { type LifecycleResult, } from "./environment-lifecycle.ts"; -const repositoryRoot = path.resolve(import.meta.dirname, "..", "..", ".."); const webPackageRoot = path.resolve(import.meta.dirname, ".."); -const environmentCliPath = path.join( - repositoryRoot, - environmentCliRelativePath, -); -const fixtureDirectory = path.join( - repositoryRoot, - "fixtures", - "controlled-source-io", +const environmentCliPath = fileURLToPath( + import.meta.resolve("@digital/environment/cli"), ); const acceptanceConfig = "playwright.acceptance.config.ts"; const productChainSpec = "test/e2e/managed-service-product-chain.spec.ts"; @@ -32,12 +26,8 @@ const providedVerificationSpec = "test/e2e/provided-service-verification.spec.ts"; const readyPath = "/api/ready"; /** 直接运行本包的 Playwright CLI,让浏览器场景自成可取消的进程组。 */ -const playwrightCli = path.join( - webPackageRoot, - "node_modules", - "@playwright", - "test", - "cli.js", +const playwrightCli = fileURLToPath( + import.meta.resolve("@playwright/test/cli"), ); /** 浏览器在取消后仍可能收尾较慢;超过这个时限就强制结束整个子进程。 */ @@ -53,6 +43,7 @@ type Options = { readonly artifactsDirectory: string; readonly materialDirectory: string; readonly manifestPath: string; + readonly expectedCoverFile: string; readonly contentIdentity: string; readonly sourceMode: string; readonly baseUrl?: string; @@ -81,7 +72,7 @@ function requireFact(value: string, label: string): string { return value; } -function parseArgs(argv: readonly string[]): Options { +async function parseArgs(argv: readonly string[]): Promise { const flags = new Map(); for (let index = 0; index < argv.length; index += 2) { const key = argv[index]; @@ -93,11 +84,14 @@ function parseArgs(argv: readonly string[]): Options { const mode = flags.get("mode"); if (mode !== "owned" && mode !== "provided") throw new Error("必须给出 --mode owned 或 --mode provided"); + const inputs = mode === "owned" ? await acceptanceInputs() : undefined; + const workspacePath = path.resolve( + flags.get("workspace") ?? inputs?.workspacePath ?? webPackageRoot, + ); const stamp = new Date().toISOString().replace(/[:.]/gu, "-"); const defaultArtifacts = path.join( - repositoryRoot, - ".scratch", - "acceptance", + process.env.DIGITAL_CI_EVIDENCE ?? + path.join(webPackageRoot, ".turbo", "acceptance", "runs"), `${mode}-${stamp}`, ); const providedBaseUrl = @@ -105,7 +99,7 @@ function parseArgs(argv: readonly string[]): Options { return { mode, environment: flags.get("environment") ?? `completion-03-${mode}-${stamp}`, - workspacePath: path.resolve(flags.get("workspace") ?? repositoryRoot), + workspacePath, ledgerPath: path.resolve( flags.get("ledger") ?? process.env.DIGITAL_ACCEPTANCE_LEDGER ?? @@ -116,19 +110,28 @@ function parseArgs(argv: readonly string[]): Options { process.env.DIGITAL_ACCEPTANCE_ARTIFACTS ?? defaultArtifacts, ), - materialDirectory: path.resolve( - flags.get("material") ?? - path.join(fixtureDirectory, "material", "RJ01668691"), - ), - manifestPath: path.resolve( - flags.get("manifest") ?? path.join(fixtureDirectory, "manifest.json"), - ), + materialDirectory: flags.has("material") + ? path.resolve(flags.get("material")!) + : inputs === undefined + ? "" + : path.join(inputs.sourceDirectory, "material", "RJ01668691"), + manifestPath: flags.has("manifest") + ? path.resolve(flags.get("manifest")!) + : inputs === undefined + ? "" + : path.join(inputs.sourceDirectory, "manifest.json"), + expectedCoverFile: + inputs === undefined + ? "" + : path.join(inputs.sourceDirectory, "responses", "dlsite-cover.png"), contentIdentity: flags.get("identity") ?? "sha256:0595bf9c66f739a60b67ac434391e7e274d5f268df5509637c48b73c4fefa6e8", sourceMode: flags.get("source-mode") ?? "controlled", ...(providedBaseUrl === undefined ? {} : { baseUrl: providedBaseUrl }), - keepOnSuccess: flags.get("keep-on-success") === "true", + keepOnSuccess: + flags.get("keep-on-success") === "true" || + (mode === "owned" && process.env.DIGITAL_CI_EVIDENCE !== undefined), prepareTimeoutMs: Number(flags.get("prepare-timeout-ms") ?? 900_000), startTimeoutMs: Number(flags.get("start-timeout-ms") ?? 300_000), }; @@ -383,7 +386,7 @@ function describeError(error: unknown): string { } async function main(argv: readonly string[]): Promise { - const options = parseArgs(argv); + const options = await parseArgs(argv); const journal = fileCommandJournal( path.join(options.artifactsDirectory, "commands.ndjson"), ); @@ -647,6 +650,7 @@ async function main(argv: readonly string[]): Promise { productDataRoot, materialDirectory: options.materialDirectory, manifestPath: options.manifestPath, + expectedCoverFile: options.expectedCoverFile, contentIdentity: options.contentIdentity, ...(options.mode === "owned" ? { sourceMode: options.sourceMode } @@ -824,6 +828,48 @@ async function main(argv: readonly string[]): Promise { let reclaimed = false; const ownerPreserved = options.mode === "provided"; + let interruptedKeepFinished = false; + const finishInterruptedKeep = async (): Promise => { + if ( + options.mode !== "owned" || + !interrupted || + !serviceLeftRunning || + interruptedKeepFinished + ) + return; + interruptedKeepFinished = true; + dataRetained = true; + try { + const stopped = await lifecycle("stop", [], false); + if (stopped !== undefined) + cleanupErrors.push(...cleanupErrorsOf(stopped, "stop")); + if ( + stopped === undefined || + (stopped.status !== "stopped" && stopped.status !== "already-stopped") + ) + cleanupErrors.push( + stopped === undefined + ? "取消留服务时 stop 没有返回结构化结果:现场按未知存活保留" + : `取消留服务时 stop 返回 ${stopped.status}:现场按实况保留`, + ); + const observed = await lifecycle("inspect", [], false); + ownerConfirmation = summarizeObserved(observed); + const liveness = statusFact(nestedRun(observed?.record ?? {}), [ + "processLiveness", + ]); + serviceLeftRunning = liveness !== "exited" && liveness !== "absent"; + if (serviceLeftRunning) + cleanupErrors.push( + `取消留服务后的独立退出确认未通过:进程存活 ${liveness || "未知"}`, + ); + const tailCopy = await copyLogs(logSourceFiles(observed), logsDirectory); + saveErrors.push(...tailCopy.failures); + retainedLogs = [...new Set([...retainedLogs, ...tailCopy.copied])]; + } catch (error) { + cleanupErrors.push(`取消留服务时收尾异常:${describeError(error)}`); + } + }; + // 先保存执行结果与证据,再停止/删除自有资源。 // 收尾序列自身的异常也必须留下可辨认的记录,不能把自有服务丢成无人认领的活进程。 try { @@ -839,7 +885,7 @@ async function main(argv: readonly string[]): Promise { dataRetained = true; const observed = await lifecycle("inspect", [], false); ownerConfirmation = summarizeObserved(observed); - } else if (executionSucceeded && options.keepOnSuccess) { + } else if (executionSucceeded && !interrupted && options.keepOnSuccess) { // 调用方有意把自己拥有的服务留作外部提供方便:不调用 stop/delete,只如实记录实况。 serviceLeftRunning = true; dataRetained = true; @@ -876,8 +922,8 @@ async function main(argv: readonly string[]): Promise { saveErrors.push(...tailCopy.failures); retainedLogs = [...new Set([...retainedLogs, ...tailCopy.copied])]; - if (!executionSucceeded) { - // 失败:停止自有服务,保留可显式重启的产品数据与完整脱敏日志。 + if (!executionSucceeded || interrupted) { + // 失败或取消:停止自有服务,保留可显式重启的产品数据与完整脱敏日志。 dataRetained = true; } else if (saveErrors.length > 0) { // 证据保存失败:只停止自有服务,不删除任何证据载体,保存失败单列。 @@ -954,52 +1000,62 @@ async function main(argv: readonly string[]): Promise { } } - // 收尾结果单独入账:执行成功与停止、删除、证据保存失败必须分别可辨。 - if (options.mode === "owned") { - try { - await appendLedger(options.ledgerPath, { - event: "outcome", - mode: options.mode, - environment: options.environment, - port: outcome.port ?? null, - executionSucceeded, - interrupted, - dataRetained, - serviceLeftRunning, - reclaimed, - mainError, - cleanupErrors, - saveErrors, - }); - } catch (error) { - saveErrors.push({ - stage: "ledger:outcome", - target: options.ledgerPath, - message: describeError(error), - }); - } - } else { - try { - await appendLedger(options.ledgerPath, { - event: "outcome", - mode: options.mode, - baseUrl, - executionSucceeded, - interrupted, - lifecycleCalls: (outcome.lifecycleCalls as string[]).length, - ownerPreserved, - mainError, - saveErrors, - }); - } catch (error) { - saveErrors.push({ - stage: "ledger:outcome", - target: options.ledgerPath, - message: describeError(error), - }); + const saveFinalOutcome = async (): Promise => { + // 收尾结果单独入账:执行成功与停止、删除、证据保存失败必须分别可辨。 + if (options.mode === "owned") { + try { + await appendLedger(options.ledgerPath, { + event: "outcome", + mode: options.mode, + environment: options.environment, + port: outcome.port ?? null, + executionSucceeded, + interrupted, + dataRetained, + serviceLeftRunning, + reclaimed, + mainError, + cleanupErrors, + saveErrors, + }); + } catch (error) { + saveErrors.push({ + stage: "ledger:outcome", + target: options.ledgerPath, + message: describeError(error), + }); + } + } else { + try { + await appendLedger(options.ledgerPath, { + event: "outcome", + mode: options.mode, + baseUrl, + executionSucceeded, + interrupted, + lifecycleCalls: (outcome.lifecycleCalls as string[]).length, + ownerPreserved, + mainError, + saveErrors, + }); + } catch (error) { + saveErrors.push({ + stage: "ledger:outcome", + target: options.ledgerPath, + message: describeError(error), + }); + } } + await writeOutcome("final"); + }; + + await finishInterruptedKeep(); + const interruptedBeforeFinalSave = interrupted; + await saveFinalOutcome(); + if (interrupted && !interruptedBeforeFinalSave) { + await finishInterruptedKeep(); + await saveFinalOutcome(); } - await writeOutcome("final"); process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); diff --git a/apps/web/scripts/run-playwright.ts b/apps/web/scripts/run-playwright.ts index 2facef7..b889548 100644 --- a/apps/web/scripts/run-playwright.ts +++ b/apps/web/scripts/run-playwright.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { createServer } from "node:net"; +import path from "node:path"; const externalBaseUrlName = "PLAYWRIGHT_EXTERNAL_BASE_URL"; const externalReadinessTimeoutMs = 30_000; @@ -98,10 +99,23 @@ if (externalServiceUrl) { } const command = process.platform === "win32" ? "playwright.cmd" : "playwright"; -const child = spawn(command, ["test", ...process.argv.slice(2)], { - env, - stdio: "inherit", -}); +const child = spawn( + command, + [ + "test", + ...(process.env.DIGITAL_CI_EVIDENCE === undefined + ? [] + : [ + `--output=${path.join(process.env.DIGITAL_CI_EVIDENCE, "browser")}`, + "--reporter=list", + ]), + ...process.argv.slice(2), + ], + { + env, + stdio: "inherit", + }, +); const forwardedSignals = ["SIGINT", "SIGTERM"] as const; const forwardSignal = (signal: NodeJS.Signals): void => { diff --git a/apps/web/test/docker-compose-deployment.test.ts b/apps/web/test/docker-compose-deployment.test.ts deleted file mode 100644 index 21ca2a6..0000000 --- a/apps/web/test/docker-compose-deployment.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -const repositoryRoot = path.resolve(import.meta.dirname, "../../.."); - -describe("正式单机 Compose 部署合同", () => { - it("拒绝把本地机密、agent-local 与教学资料带入 Web Docker context", async () => { - const dockerignore = await readFile( - path.join(repositoryRoot, "apps/web/Dockerfile.dockerignore"), - "utf8", - ); - const requiredRules = [ - ".scratch/", - ".agents/", - "AGENTS.md", - "GLOSSARY*.md", - "docs/", - ".env*", - "MISSION.md", - "RESOURCES.md", - "lessons/", - "reference/", - "learning-records/", - "assets/", - ]; - - for (const rule of requiredRules) { - expect(dockerignore.split(/\r?\n/u), rule).toContain(rule); - } - }); - - it("把迁移与 start-only Web 分开,并声明非 root、绝对容器路径、只读观察根和 readiness 健康检查", async () => { - const compose = await readFile( - path.join(repositoryRoot, "docker-compose.yml"), - "utf8", - ); - - expect(compose).toMatch(/^\s*migrate:\s*$/mu); - expect(compose).toMatch(/^\s*web:\s*$/mu); - expect(compose).toMatch(/condition:\s*service_completed_successfully/u); - expect(compose).toMatch(/target:\s*standalone/u); - expect(compose).toMatch(/target:\s*runtime/u); - expect(compose).toMatch(/user:\s*["']?\$\{DIGITAL_UID/u); - expect(compose).toContain("/data"); - expect(compose).toContain("/observed/media"); - expect(compose).toMatch(/read_only:\s*true/u); - expect(compose).toMatch(/create_host_path:\s*false/u); - expect(compose).toMatch(/\/api\/ready/u); - expect(compose).not.toMatch( - /DIGITAL_HOST_PATH|HOST_TO_CONTAINER|path mapping/iu, - ); - }); - - it("把真实 Compose 场景验证纳入正式 deployment gate", async () => { - const packageJson = await readFile( - path.join(repositoryRoot, "apps/web/package.json"), - "utf8", - ); - const manifest = JSON.parse(packageJson) as { - readonly scripts: Readonly>; - }; - - expect(manifest.scripts.deployment).toContain( - "check-compose-deployment.ts", - ); - }); -}); diff --git a/apps/web/test/e2e/managed-service-product-chain.spec.ts b/apps/web/test/e2e/managed-service-product-chain.spec.ts index 89354e9..9514eb7 100644 --- a/apps/web/test/e2e/managed-service-product-chain.spec.ts +++ b/apps/web/test/e2e/managed-service-product-chain.spec.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import path from "node:path"; import { expect, test, type Locator, type Page } from "@playwright/test"; @@ -14,20 +13,6 @@ import { type ManagedServiceHandoff, } from "./support/managed-service.ts"; -const repositoryRoot = path.resolve( - import.meta.dirname, - "..", - "..", - "..", - "..", -); -const coverFile = path.join( - repositoryRoot, - "fixtures", - "controlled-source-io", - "responses", - "dlsite-cover.png", -); const libraryName = "受控夹具资产库"; const editedLibraryName = "受控夹具资产库已改名"; const unlistedProduct = "RJ01497788"; @@ -160,7 +145,7 @@ test("受管理真实服务:浏览器从空白正式迁移起步,添加资 expect(subject.recognitionState).toBe("recognized"); expect(JSON.stringify(subject.current)).toContain("RJ01668691"); - const cover = await readFile(coverFile); + const cover = await readFile(handoff.expectedCoverFile); const coverDigest = createHash("sha256").update(cover).digest("hex"); // 托管封面地址由同一服务在详情投影里给出,指向该产品自己按内容摘要服务的字节。 diff --git a/apps/web/test/e2e/support/managed-service.ts b/apps/web/test/e2e/support/managed-service.ts index c315c0c..a732f3b 100644 --- a/apps/web/test/e2e/support/managed-service.ts +++ b/apps/web/test/e2e/support/managed-service.ts @@ -12,6 +12,7 @@ export type ManagedServiceHandoff = { readonly productDataRoot: string; readonly materialDirectory: string; readonly manifestPath: string; + readonly expectedCoverFile: string; readonly sourceMode: string; readonly productLogFile: string; readonly run: { diff --git a/apps/web/turbo.json b/apps/web/turbo.json index fb0b26e..ebab2a5 100644 --- a/apps/web/turbo.json +++ b/apps/web/turbo.json @@ -3,14 +3,188 @@ "tags": ["composition-root"], "tasks": { "build": { - "outputs": ["dist/**"] + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "deployment": { + "cache": false, + "passThroughEnv": [ + "DIGITAL_DEPLOYMENT_*", + "DIGITAL_COMPOSE_CHECK_*", + "DIGITAL_DEPLOYMENT_PLAYWRIGHT_CLIENT", + "PLAYWRIGHT_BROWSERS_PATH", + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE", + "DOCKER_HOST", + "DOCKER_CONTEXT" + ], + "tags": ["quality:heavy"], + "dependsOn": [ + "build", + "@digital/cli#build", + "@digital/db-migrations#build", + "deployment:prepare", + "browser:prepare" + ] + }, + "dev": { + "tags": ["work:development"], + "cache": false + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "preview": { + "tags": ["work:development"], + "cache": false + }, + "start": { + "tags": ["work:development"], + "cache": false }, "test": { - "dependsOn": ["$TURBO_EXTENDS$", "@digital/db-migrations#build"] + "dependsOn": [ + "build", + "@digital/db-migrations#build", + "@digital/sqlite#build" + ], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] }, "test:e2e": { - "dependsOn": ["$TURBO_EXTENDS$", "@digital/db-migrations#build"], + "dependsOn": ["build", "@digital/db-migrations#build", "browser:prepare"], + "cache": false, + "passThroughEnv": [ + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE", + "PLAYWRIGHT_BROWSERS_PATH" + ], + "tags": ["quality:heavy"] + }, + "test:e2e:managed-service": { + "dependsOn": [ + "@digital/web#build", + "@digital/environment#build", + "browser:prepare", + "@digital/db-migrations#build", + "//#acceptance:prepare" + ], + "cache": false, + "passThroughEnv": [ + "DIGITAL_ACCEPTANCE_LEDGER", + "DIGITAL_ACCEPTANCE_ARTIFACTS", + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE", + "PLAYWRIGHT_BROWSERS_PATH" + ], + "tags": ["quality:heavy"] + }, + "test:e2e:provided-service": { + "dependsOn": [ + "@digital/web#build", + "browser:prepare", + "@digital/db-migrations#build" + ], + "cache": false, + "passThroughEnv": [ + "DIGITAL_ACCEPTANCE_BASE_URL", + "DIGITAL_ACCEPTANCE_LEDGER", + "DIGITAL_ACCEPTANCE_ARTIFACTS", + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE", + "PLAYWRIGHT_BROWSERS_PATH" + ], + "tags": ["quality:conditional"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:serve": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "package.json", + "vite.config.ts", + "+server.ts", + "scripts/start-development.ts", + "server/**", + "pages/**/+*.ts", + "$TURBO_ROOT$/packages/workspace-automation/scripts/development-task.ts" + ], + "passThroughEnv": [ + "DIGITAL_DEVELOPMENT_SESSION", + "DIGITAL_DEVELOPMENT_WORKSPACE", + "DIGITAL_DEVELOPMENT_ENVIRONMENT" + ], + "tags": ["work:development"] + }, + "test:process": { + "dependsOn": ["build", "@digital/db-migrations#build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:heavy"], "cache": false + }, + "browser:prepare": { + "tags": ["work:preparation"], + "cache": false, + "passThroughEnv": ["PLAYWRIGHT_BROWSERS_PATH"] + }, + "acceptance:prepare": { + "tags": ["work:preparation"], + "cache": false + }, + "deployment:prepare": { + "tags": ["work:preparation"], + "cache": false, + "dependsOn": ["//#acceptance:prepare"], + "passThroughEnv": [ + "DIGITAL_DEPLOYMENT_*", + "DIGITAL_COMPOSE_CHECK_*", + "DIGITAL_CI_EVIDENCE", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_BUILDKIT", + "BUILDKIT_PROGRESS" + ] } } } diff --git a/fixtures/controlled-fanbox-session/README.md b/fixtures/controlled-fanbox-session/README.md index 3bc3de6..24a6f95 100644 --- a/fixtures/controlled-fanbox-session/README.md +++ b/fixtures/controlled-fanbox-session/README.md @@ -21,16 +21,16 @@ ## 真实、缩减与代表性取值 -| 项目 | 取值来源 | 说明 | -| --- | --- | --- | -| creatorId `customudon`、postId `7225781`、`2023-12-31T17:10:55+09:00`、标题 `Movie \| コスプレ4`、形态 `file`、封面 present | 真实参考 | 与独立审阅 oracle 一致 | -| 真实根与文件层级 | 真实参考(只读核对) | `.scratch/acceptance-corpus/assets/media/animation/Custom Udon/23.12.31 コスプレ4`,原文件为 `Season 1/S01E01 - Cosplay4.mp4`(533393220 字节)、`Specials/S00E01 - Voice.mp4`(346704967 字节)、`poster.jpg`(67869 字节);本夹具不改原根 | -| 缩减父目录名 `customudon` | 输入变换 | 既有创作者标识规则不接受空格,`Custom Udon` 无法命中约定,故改为 oracle 已确认的 creatorId | -| 缩减目录内的 MP4 | 自造 | 由 `scripts/build-controlled-fanbox-session-fixture.ts` 生成的结构正确最小盒序列,不是可播放视频,也不是原帖附件字节 | -| 摘要与详情里的 `feeRequired`、`user.name`、附件 `f1` 的名称/大小/扩展名/地址 | 协议代表值 | 按既有解析契约给出,只覆盖实际走到的分支;不声称完整创作者历史,也不声称附件字节 | -| 主图地址与 PNG 字节 | 协议代表值 | oracle 只记录封面 present,未保留地址与字节;不得写成该帖真实封面下载录像 | -| `login-status` 的 `{ "body": 0 }` | 协议代表值 | 连接验证要求的成功信封形态 | -| 合成 userAgent/Cookie 值 | 自造 | 没有真实登录能力;`manifest-mismatched-context.json` 声明另一个合成上下文摘要,用于证明摘要属于请求键 | +| 项目 | 取值来源 | 说明 | +| --------------------------------------------------------------------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| creatorId `customudon`、postId `7225781`、`2023-12-31T17:10:55+09:00`、标题 `Movie \| コスプレ4`、形态 `file`、封面 present | 真实参考 | 与独立审阅 oracle 一致 | +| 真实根与文件层级 | 真实参考(只读核对) | `.scratch/acceptance-corpus/assets/media/animation/Custom Udon/23.12.31 コスプレ4`,原文件为 `Season 1/S01E01 - Cosplay4.mp4`(533393220 字节)、`Specials/S00E01 - Voice.mp4`(346704967 字节)、`poster.jpg`(67869 字节);本夹具不改原根 | +| 缩减父目录名 `customudon` | 输入变换 | 既有创作者标识规则不接受空格,`Custom Udon` 无法命中约定,故改为 oracle 已确认的 creatorId | +| 缩减目录内的 MP4 | 自造 | 由 `@digital/workspace-automation` 的受控会话生成器 生成的结构正确最小盒序列,不是可播放视频,也不是原帖附件字节 | +| 摘要与详情里的 `feeRequired`、`user.name`、附件 `f1` 的名称/大小/扩展名/地址 | 协议代表值 | 按既有解析契约给出,只覆盖实际走到的分支;不声称完整创作者历史,也不声称附件字节 | +| 主图地址与 PNG 字节 | 协议代表值 | oracle 只记录封面 present,未保留地址与字节;不得写成该帖真实封面下载录像 | +| `login-status` 的 `{ "body": 0 }` | 协议代表值 | 连接验证要求的成功信封形态 | +| 合成 userAgent/Cookie 值 | 自造 | 没有真实登录能力;`manifest-mismatched-context.json` 声明另一个合成上下文摘要,用于证明摘要属于请求键 | ## 清单内容身份 @@ -59,7 +59,7 @@ ## 重新生成与核验 ``` -node scripts/build-controlled-fanbox-session-fixture.ts +pnpm fixture:fanbox-session ``` 脚本重写资料、响应与合成凭据文件,打印各清单的绝对路径与内容身份,以及合成上下文的摘要标识, diff --git a/fixtures/controlled-source-io/README.md b/fixtures/controlled-source-io/README.md index 880cd05..980d2d8 100644 --- a/fixtures/controlled-source-io/README.md +++ b/fixtures/controlled-source-io/README.md @@ -18,18 +18,18 @@ ## 真实、缩减与代表性取值 -| 项目 | 取值来源 | 说明 | -| --- | --- | --- | -| 产品编号 RJ01668691、标题、简介、发售日、年龄分级、语言、maker_id | 真实参考 | 与人工复核 oracle 一致,接口结构按 DLsite 官方响应形态书写 | -| 价格、封面图片地址 | 协议代表值 | 独立 oracle 未记录这两项;按 DLsite 响应形态给出,不代表该作品真实下载录像 | -| 作品页 HTML 的圈名、声优/脚本/插画、 genres 链接 | 协议代表值 | 按解析器要求的表格与链接结构构造,非逐字节真实页面 | -| 封面 PNG 与音轨 WAV | 自造 | 由 `scripts/build-controlled-source-io-fixture.ts` 生成,字节确定 | -| 全库结构统计(`regularFileCount`、`descendantDirectoryCount`、`treeMetadataSha256`) | 不适用 | oracle 中的这些数值描述完整验收语料,缩减夹具只含一个文件,不能据此比对 | +| 项目 | 取值来源 | 说明 | +| ------------------------------------------------------------------------------------ | ---------- | -------------------------------------------------------------------------- | +| 产品编号 RJ01668691、标题、简介、发售日、年龄分级、语言、maker_id | 真实参考 | 与人工复核 oracle 一致,接口结构按 DLsite 官方响应形态书写 | +| 价格、封面图片地址 | 协议代表值 | 独立 oracle 未记录这两项;按 DLsite 响应形态给出,不代表该作品真实下载录像 | +| 作品页 HTML 的圈名、声优/脚本/插画、 genres 链接 | 协议代表值 | 按解析器要求的表格与链接结构构造,非逐字节真实页面 | +| 封面 PNG 与音轨 WAV | 自造 | 由 `@digital/workspace-automation` 的受控来源生成器 生成,字节确定 | +| 全库结构统计(`regularFileCount`、`descendantDirectoryCount`、`treeMetadataSha256`) | 不适用 | oracle 中的这些数值描述完整验收语料,缩减夹具只含一个文件,不能据此比对 | ## 重新生成与核验 ``` -node --experimental-strip-types scripts/build-controlled-source-io-fixture.ts +pnpm fixture:source-io ``` 脚本重写两个媒体文件,并打印两份清单的绝对路径与内容身份,供环境配置直接使用。 diff --git a/fixtures/live-corpus/README.md b/fixtures/live-corpus/README.md index b92b925..9785897 100644 --- a/fixtures/live-corpus/README.md +++ b/fixtures/live-corpus/README.md @@ -4,7 +4,7 @@ `pnpm check` 使用随仓库取得的受控代表资料,不运行这些私人语料旅程,也不继承终端里的旅程选择或真实来源凭据。CLI 的普通检查保留受控资产库旅程、未识别目录旅程及基础命令;其他业务单元和页面专项继续运行。 -`pnpm check:corpus-real` 显式运行原真实语料观察和 CLI 旅程。素材需自行放在工作区的 `.scratch/acceptance-corpus/assets/`,相对目录与 `files-from.txt` 一致;该目录保持不跟踪。范围仍为 29 根安全观察、24 根识别、5 根明确未识别,不要求产生 29 个资产。 +`pnpm exec turbo run test:corpus-real` 显式运行原真实语料观察和 CLI 旅程。素材需自行放在工作区的 `.scratch/acceptance-corpus/assets/`,相对目录与 `files-from.txt` 一致;该目录保持不跟踪。范围仍为 29 根安全观察、24 根识别、5 根明确未识别,不要求产生 29 个资产。 来源条件沿原专项入口:TMDB/OMDb 需要其访问凭据,Bangumi 需要已有有效授权的数据根,FANBOX 需要已配置的浏览器连接。缺少素材或授权属于条件缺失;具备条件后的请求或业务失败属于验收失败。受控验证通过不能替代真实来源采集成功。各来源仍可通过 CLI 包的 `test:e2e:*live` 命令单独运行。 diff --git a/fixtures/preview-corpus/audio-posts/README.md b/fixtures/preview-corpus/audio-posts/README.md index e31afb1..0270498 100644 --- a/fixtures/preview-corpus/audio-posts/README.md +++ b/fixtures/preview-corpus/audio-posts/README.md @@ -20,7 +20,7 @@ 通过现有公开环境生命周期与普通目录客户端执行: ```sh -node scripts/verify-audio-post-corpus.ts <独立环境名> <证据文件> <空闲端口> +pnpm verify:audio-posts <独立环境名> <证据文件> <空闲端口> ``` 末尾可指定 `cases.json` 中的资料 ID,选择同一集合的子集。验证通过正式资产库、扫描、资产刷新、内容读取和产物 HTTP 入口检查结果,分别记录安全观察、识别和未识别;停止后重开检查可读状态,比较素材前后内容摘要。失败时保存证据并停止自有服务,保留可排查现场;成功后才回收新建环境。资料脚本位于根目录既有类型、格式和 lint 检查范围内。 diff --git a/fixtures/preview-corpus/coverage.json b/fixtures/preview-corpus/coverage.json new file mode 100644 index 0000000..cb6a9bf --- /dev/null +++ b/fixtures/preview-corpus/coverage.json @@ -0,0 +1,414 @@ +{ + "formatVersion": 1, + "safetyRoots": 29, + "recognitionRoots": 24, + "unrecognizedRoots": 5, + "roots": [ + { + "id": "RJ01029383", + "batch": "audio-posts", + "directory": "audio-posts/material/RJ01029383", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 0 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/RJ01029383" + } + }, + { + "id": "RJ01049458", + "batch": "audio-posts", + "directory": "audio-posts/material/RJ01049458", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 1 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/RJ01049458" + } + }, + { + "id": "RJ01115164", + "batch": "audio-posts", + "directory": "audio-posts/material/RJ01115164", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 2 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/RJ01115164" + } + }, + { + "id": "RJ01131487", + "batch": "audio-posts", + "directory": "audio-posts/material/RJ01131487", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 3 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/RJ01131487" + } + }, + { + "id": "RJ01196709", + "batch": "audio-posts", + "directory": "audio-posts/material/RJ01196709", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 4 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/RJ01196709" + } + }, + { + "id": "RJ01497788", + "batch": "audio-posts", + "directory": "audio-posts/material/RJ01497788", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 5 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/RJ01497788" + } + }, + { + "id": "RJ01668691", + "batch": "audio-posts", + "directory": "audio-posts/material/RJ01668691", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 6 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/RJ01668691" + } + }, + { + "id": "VJ014635", + "batch": "audio-posts", + "directory": "audio-posts/material/VJ014635", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 7 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "kikoeru/VoiceWork/VJ014635" + } + }, + { + "id": "fanbox-7576910", + "batch": "audio-posts", + "directory": "audio-posts/material/customudon/24.03.07 ひみつ", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 8 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/animation/Custom Udon/24.03.07 ひみつ" + } + }, + { + "id": "fanbox-7225781", + "batch": "audio-posts", + "directory": "audio-posts/material/customudon/23.12.31 コスプレ4", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 9 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/animation/Custom Udon/23.12.31 コスプレ4" + } + }, + { + "id": "fanbox-231859", + "batch": "audio-posts", + "directory": "audio-posts/material/customudon/18.12.19 プールで2", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 10 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/animation/Custom Udon/18.12.19 プールで2" + } + }, + { + "id": "fanbox-9135862", + "batch": "audio-posts", + "directory": "audio-posts/material/customudon/25.01.01 年始の挨拶(おまけ動画付き)", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 11 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/animation/Custom Udon/25.01.01 年始の挨拶(おまけ動画付き)" + } + }, + { + "id": "fanbox-1739441", + "batch": "audio-posts", + "directory": "audio-posts/material/customudon/20.12.29 今年も一年ありがとうございました。", + "recognition": "required", + "case": { + "file": "audio-posts/cases.json", + "index": 12 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/animation/Custom Udon/20.12.29 今年も一年ありがとうございました。" + } + }, + { + "id": "unrecognized-1", + "batch": "audio-posts", + "directory": "audio-posts/material/unrecognized/25時、ナイトコードで。 — 25時、ナイトコードで。 SEKAI ALBUM vol.3 (2025) {BRMM-10894} [CD-FLAC]", + "recognition": "unrecognized", + "case": { + "file": "audio-posts/cases.json", + "index": 13 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/music/25時、ナイトコードで。 — 25時、ナイトコードで。 SEKAI ALBUM vol.3 (2025) {BRMM-10894} [CD-FLAC]" + } + }, + { + "id": "unrecognized-2", + "batch": "audio-posts", + "directory": "audio-posts/material/unrecognized/[EAC][110612][ボーマス16][同人][Baguettes Ensemble][The Vocaloid Jazz sessions Vol.3][FLAC+CUE+LOG]", + "recognition": "unrecognized", + "case": { + "file": "audio-posts/cases.json", + "index": 14 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/music/[EAC][110612][ボーマス16][同人][Baguettes Ensemble][The Vocaloid Jazz sessions Vol.3][FLAC+CUE+LOG]" + } + }, + { + "id": "unrecognized-3", + "batch": "audio-posts", + "directory": "audio-posts/material/unrecognized/『プロジェクトセカイ カラフルステージ! feat. 初音ミク』25時、ナイトコードで。 8th Single「演劇/25時の情熱」[FLAC]", + "recognition": "unrecognized", + "case": { + "file": "audio-posts/cases.json", + "index": 15 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/music/『プロジェクトセカイ カラフルステージ! feat. 初音ミク』25時、ナイトコードで。 8th Single「演劇/25時の情熱」[FLAC]" + } + }, + { + "id": "unrecognized-4", + "batch": "audio-posts", + "directory": "audio-posts/material/unrecognized/プロジェクトセカイ カラフルステージ! feat. 初音ミク 公式ビジュアルファンブック Vol.3[332P][JPG]", + "recognition": "unrecognized", + "case": { + "file": "audio-posts/cases.json", + "index": 16 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/pics/プロジェクトセカイ カラフルステージ! feat. 初音ミク 公式ビジュアルファンブック Vol.3[332P][JPG]" + } + }, + { + "id": "unrecognized-5", + "batch": "audio-posts", + "directory": "audio-posts/material/unrecognized/Boku no Risou no Isekai Seikatsu - 04", + "recognition": "unrecognized", + "case": { + "file": "audio-posts/cases.json", + "index": 17 + }, + "reference": { + "file": "audio-posts/references.json", + "logicalRoot": "media/Boku no Risou no Isekai Seikatsu - 04" + } + }, + { + "id": "epub-1", + "batch": "publication-screen", + "directory": "publication-screen/material/books/妹妹人生 上", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 0 + }, + "reference": { + "file": "publication-screen/references.json", + "logicalRoot": "appdata/booklore/books/轻小说/入间人间/妹妹人生 上" + } + }, + { + "id": "epub-2", + "batch": "publication-screen", + "directory": "publication-screen/material/books/妹妹人生 下", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 1 + }, + "reference": { + "file": "publication-screen/references.json", + "logicalRoot": "appdata/booklore/books/轻小说/入间人间/妹妹人生 下" + } + }, + { + "id": "epub-3", + "batch": "publication-screen", + "directory": "publication-screen/material/books/架构整洁之道", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 2 + }, + "reference": { + "file": "publication-screen/references.json", + "logicalRoot": "appdata/booklore/books/技术书/孙宇聪/架构整洁之道" + } + }, + { + "id": "epub-4", + "batch": "publication-screen", + "directory": "publication-screen/material/books/程序员修炼之道:通向务实的最高境界(第2版)", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 3 + }, + "reference": { + "file": "publication-screen/references.json", + "logicalRoot": "appdata/booklore/books/技术书/(美)David Thomas(大卫·托马斯),Andrew Hunt(安德鲁·亨特)/程序员修炼之道:通向务实的最高境界(第2版)" + } + }, + { + "id": "epub-5", + "batch": "publication-screen", + "directory": "publication-screen/material/books/幼女战记(1)", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 4 + }, + "reference": { + "file": "publication-screen/references.json", + "logicalRoot": "appdata/booklore/books/漫画/Carlo Zen 東條チカ/幼女战记(1)" + } + }, + { + "id": "epub-6", + "batch": "publication-screen", + "directory": "publication-screen/material/books/幼女战记(2)", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 5 + }, + "reference": { + "file": "publication-screen/references.json", + "logicalRoot": "appdata/booklore/books/漫画/Carlo Zen 東條チカ/幼女战记(2)" + } + }, + { + "id": "pdf-three", + "batch": "publication-screen", + "directory": "publication-screen/material/books/やがて君になる 佐伯沙弥香について", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 6 + }, + "reference": { + "file": "publication-screen/references.json", + "logicalRoot": "appdata/booklore/books/轻小说/入间人间/やがて君になる 佐伯沙弥香について" + } + }, + { + "id": "anime-bloom", + "batch": "publication-screen", + "directory": "publication-screen/material/anime/终将成为你 (2018)", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 7 + }, + "reference": { + "file": "publication-screen/references.json", + "pointer": "/video/series" + } + }, + { + "id": "anime-frieren", + "batch": "publication-screen", + "directory": "publication-screen/material/anime/葬送的芙莉莲 (2023)", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 8 + }, + "reference": { + "file": "publication-screen/references.json", + "pointer": "/video/multiSeasonSeries" + } + }, + { + "id": "film-strict", + "batch": "publication-screen", + "directory": "publication-screen/material/films/恐怖直播 (2013)", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 9 + }, + "reference": { + "file": "publication-screen/references.json", + "pointer": "/video/film" + } + }, + { + "id": "film-directed", + "batch": "publication-screen", + "directory": "publication-screen/material/films/[怒火救援].Man.on.Fire.2004.Blu-ray.1080p.AVC.DTS-HD.MA5.1-CMCT", + "recognition": "required", + "case": { + "file": "publication-screen/cases.json", + "index": 10 + }, + "reference": { + "file": "publication-screen/references.json", + "pointer": "/video/collectorFilm" + } + } + ] +} diff --git a/fixtures/preview-corpus/manifest.json b/fixtures/preview-corpus/manifest.json new file mode 100644 index 0000000..d74d696 --- /dev/null +++ b/fixtures/preview-corpus/manifest.json @@ -0,0 +1,603 @@ +{ + "formatVersion": 1, + "ordinary": [ + { + "id": "RJ01029383-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01029383", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/RJ01029383.json" + }, + { + "id": "RJ01029383-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/RJ01029383.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/RJ01029383.html" + }, + { + "id": "RJ01029383-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01029383_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "RJ01049458-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01049458", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/RJ01049458.json" + }, + { + "id": "RJ01049458-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/RJ01049458.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/RJ01049458.html" + }, + { + "id": "RJ01049458-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01049458_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "RJ01115164-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01115164", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/RJ01115164.json" + }, + { + "id": "RJ01115164-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/RJ01115164.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/RJ01115164.html" + }, + { + "id": "RJ01115164-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01115164_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "RJ01131487-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01131487", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/RJ01131487.json" + }, + { + "id": "RJ01131487-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/RJ01131487.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/RJ01131487.html" + }, + { + "id": "RJ01131487-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01131487_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "RJ01196709-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01196709", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/RJ01196709.json" + }, + { + "id": "RJ01196709-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/RJ01196709.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/RJ01196709.html" + }, + { + "id": "RJ01196709-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01196709_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "RJ01497788-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01497788", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/RJ01497788.json" + }, + { + "id": "RJ01497788-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/RJ01497788.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/RJ01497788.html" + }, + { + "id": "RJ01497788-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01497788_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "RJ01668691-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01668691", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/RJ01668691.json" + }, + { + "id": "RJ01668691-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/RJ01668691.html" + }, + { + "id": "RJ01668691-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01668691_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "VJ014635-json", + "url": "https://www.dlsite.com/maniax/product/info/ajax?product_id=VJ014635", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/VJ014635.json" + }, + { + "id": "VJ014635-html", + "url": "https://www.dlsite.com/maniax/work/=/product_id/VJ014635.html", + "method": "GET", + "status": 200, + "mediaType": "text/html", + "responseFile": "audio-posts/responses/VJ014635.html" + }, + { + "id": "VJ014635-cover", + "url": "https://img.dlsite.jp/modpub/images2/work/doujin/VJ014635_img_main.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "fanbox-cover-7576910", + "url": "https://downloads.fanbox.cc/images/post/7576910/controlled-cover.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "fanbox-cover-7225781", + "url": "https://downloads.fanbox.cc/images/post/7225781/controlled-cover.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "fanbox-cover-231859", + "url": "https://downloads.fanbox.cc/images/post/231859/controlled-cover.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "fanbox-cover-1739441", + "url": "https://downloads.fanbox.cc/images/post/1739441/controlled-cover.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "audio-posts/responses/cover.png" + }, + { + "id": "epub-5-nbinet", + "url": "https://nbinet.primo.exlibrisgroup.com/primaws/rest/pub/pnxs?inst=886NCL_NBINET&vid=886NCL_NBINET%3ANBINET&lang=zh-tw&q=isbn%2Cexact%2C9789864738281&scope=MyInstitution&tab=NBINET&skipDelivery=Y", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/epub-5-nbinet.json" + }, + { + "id": "epub-6-nbinet", + "url": "https://nbinet.primo.exlibrisgroup.com/primaws/rest/pub/pnxs?inst=886NCL_NBINET&vid=886NCL_NBINET%3ANBINET&lang=zh-tw&q=isbn%2Cexact%2C9789864738298&scope=MyInstitution&tab=NBINET&skipDelivery=Y", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/epub-6-nbinet.json" + }, + { + "id": "bangumi-oauth", + "url": "https://bgm.tv/oauth/access_token", + "method": "POST", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-oauth.json" + }, + { + "id": "bangumi-subject-243981", + "url": "https://api.bgm.tv/v0/subjects/243981", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-subject-243981.json" + }, + { + "id": "bangumi-cover-243981", + "url": "https://lain.bgm.tv/pic/cover/l/corpus-243981.jpg", + "method": "GET", + "status": 200, + "mediaType": "image/jpeg", + "responseFile": "publication-screen/responses/cover.jpg" + }, + { + "id": "bangumi-relations-243981", + "url": "https://api.bgm.tv/v0/subjects/243981/subjects", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-relations-243981.json" + }, + { + "id": "bangumi-episodes-243981", + "url": "https://api.bgm.tv/v0/episodes?subject_id=243981&type=0&limit=100&offset=0", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episodes-243981.json" + }, + { + "id": "bangumi-episode-831401", + "url": "https://api.bgm.tv/v0/episodes/831401", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episode-831401.json" + }, + { + "id": "bangumi-episode-832691", + "url": "https://api.bgm.tv/v0/episodes/832691", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episode-832691.json" + }, + { + "id": "bangumi-subject-400602", + "url": "https://api.bgm.tv/v0/subjects/400602", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-subject-400602.json" + }, + { + "id": "bangumi-cover-400602", + "url": "https://lain.bgm.tv/pic/cover/l/corpus-400602.jpg", + "method": "GET", + "status": 200, + "mediaType": "image/jpeg", + "responseFile": "publication-screen/responses/cover.jpg" + }, + { + "id": "bangumi-relations-400602", + "url": "https://api.bgm.tv/v0/subjects/400602/subjects", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-relations-400602.json" + }, + { + "id": "bangumi-episodes-400602", + "url": "https://api.bgm.tv/v0/episodes?subject_id=400602&type=0&limit=100&offset=0", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episodes-400602.json" + }, + { + "id": "bangumi-episode-1227087", + "url": "https://api.bgm.tv/v0/episodes/1227087", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episode-1227087.json" + }, + { + "id": "bangumi-episode-1257306", + "url": "https://api.bgm.tv/v0/episodes/1257306", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episode-1257306.json" + }, + { + "id": "bangumi-subject-515759", + "url": "https://api.bgm.tv/v0/subjects/515759", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-subject-515759.json" + }, + { + "id": "bangumi-cover-515759", + "url": "https://lain.bgm.tv/pic/cover/l/corpus-515759.jpg", + "method": "GET", + "status": 200, + "mediaType": "image/jpeg", + "responseFile": "publication-screen/responses/cover.jpg" + }, + { + "id": "bangumi-relations-515759", + "url": "https://api.bgm.tv/v0/subjects/515759/subjects", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-relations-515759.json" + }, + { + "id": "bangumi-episodes-515759", + "url": "https://api.bgm.tv/v0/episodes?subject_id=515759&type=0&limit=100&offset=0", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episodes-515759.json" + }, + { + "id": "bangumi-episode-1575936", + "url": "https://api.bgm.tv/v0/episodes/1575936", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-episode-1575936.json" + }, + { + "id": "bangumi-search", + "url": "https://api.bgm.tv/v0/search/subjects", + "method": "POST", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/bangumi-search.json" + }, + { + "id": "tmdb-auth", + "url": "https://api.themoviedb.org/3/authentication", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-auth.json" + }, + { + "id": "omdb-auth", + "url": "https://www.omdbapi.com/?apikey=synthetic-omdb-key&i=tt3896198", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/omdb-auth.json" + }, + { + "id": "tmdb-search-209764", + "url": "https://api.themoviedb.org/3/search/movie?query=%E6%81%90%E6%80%96%E7%9B%B4%E6%92%AD&primary_release_year=2013&language=zh-CN&page=1", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-search-209764.json" + }, + { + "id": "tmdb-movie-209764", + "url": "https://api.themoviedb.org/3/movie/209764?language=zh-CN&append_to_response=alternative_titles%2Cimages&include_image_language=zh%2Cnull%2Cen", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-movie-209764.json" + }, + { + "id": "tmdb-translations-209764", + "url": "https://api.themoviedb.org/3/movie/209764/translations", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-translations-209764.json" + }, + { + "id": "tmdb-credits-209764", + "url": "https://api.themoviedb.org/3/movie/209764/credits?language=zh-CN", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-credits-209764.json" + }, + { + "id": "tmdb-209764-poster", + "url": "https://image.tmdb.org/t/p/original/corpus-209764-poster.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "publication-screen/responses/cover.png" + }, + { + "id": "tmdb-209764-backdrop", + "url": "https://image.tmdb.org/t/p/original/corpus-209764-backdrop.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "publication-screen/responses/cover.png" + }, + { + "id": "omdb-tt2990738", + "url": "https://www.omdbapi.com/?apikey=synthetic-omdb-key&i=tt2990738&plot=full&r=json", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/omdb-tt2990738.json" + }, + { + "id": "tmdb-movie-9509", + "url": "https://api.themoviedb.org/3/movie/9509?language=zh-CN&append_to_response=alternative_titles%2Cimages&include_image_language=zh%2Cnull%2Cen", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-movie-9509.json" + }, + { + "id": "tmdb-translations-9509", + "url": "https://api.themoviedb.org/3/movie/9509/translations", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-translations-9509.json" + }, + { + "id": "tmdb-credits-9509", + "url": "https://api.themoviedb.org/3/movie/9509/credits?language=zh-CN", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/tmdb-credits-9509.json" + }, + { + "id": "tmdb-9509-poster", + "url": "https://image.tmdb.org/t/p/original/corpus-9509-poster.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "publication-screen/responses/cover.png" + }, + { + "id": "tmdb-9509-backdrop", + "url": "https://image.tmdb.org/t/p/original/corpus-9509-backdrop.png", + "method": "GET", + "status": 200, + "mediaType": "image/png", + "responseFile": "publication-screen/responses/cover.png" + }, + { + "id": "omdb-tt0328107", + "url": "https://www.omdbapi.com/?apikey=synthetic-omdb-key&i=tt0328107&plot=full&r=json", + "method": "GET", + "status": 200, + "mediaType": "application/json", + "responseFile": "publication-screen/responses/omdb-tt0328107.json" + } + ], + "session": [ + { + "id": "fanbox-login", + "creatorId": "customudon", + "endpoint": "login-status", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-login.json" + }, + { + "id": "fanbox-verify", + "creatorId": "customudon", + "endpoint": "creator-summaries", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-verify.json", + "limit": 1 + }, + { + "id": "fanbox-summaries", + "creatorId": "customudon", + "endpoint": "creator-summaries", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-summaries.json", + "limit": 300 + }, + { + "id": "fanbox-detail-7576910", + "creatorId": "customudon", + "endpoint": "post-detail", + "postId": "7576910", + "access": "connected", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-7576910.json" + }, + { + "id": "fanbox-detail-7225781", + "creatorId": "customudon", + "endpoint": "post-detail", + "postId": "7225781", + "access": "connected", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-7225781.json" + }, + { + "id": "fanbox-detail-231859", + "creatorId": "customudon", + "endpoint": "post-detail", + "postId": "231859", + "access": "connected", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-231859.json" + }, + { + "id": "fanbox-detail-9135862", + "creatorId": "customudon", + "endpoint": "post-detail", + "postId": "9135862", + "access": "connected", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-9135862.json" + }, + { + "id": "fanbox-detail-1739441", + "creatorId": "customudon", + "endpoint": "post-detail", + "postId": "1739441", + "access": "connected", + "privateContextIdentity": "sha256:2ceb9d8131a38d5dff4b845ab507b2ad39aa2a04b3cb834901663c999c1e1efd", + "status": 200, + "mediaType": "application/json", + "responseFile": "audio-posts/responses/fanbox-1739441.json" + } + ] +} diff --git a/fixtures/preview-corpus/publication-screen/README.md b/fixtures/preview-corpus/publication-screen/README.md index 5b61096..9aed769 100644 --- a/fixtures/preview-corpus/publication-screen/README.md +++ b/fixtures/preview-corpus/publication-screen/README.md @@ -17,9 +17,9 @@ TMDB 与 OMDb 的电影、IMDb 编号及年份来自原参考;来源响应中 在仓库根目录运行,环境名、证据文件和端口须由调用者独占: ```bash -node scripts/verify-publication-screen-corpus.ts publication-screen-demo /tmp/publication-screen-demo.json 54013 -node scripts/verify-publication-screen-corpus.ts publication-only /tmp/publication-only.json 54014 epub-1 pdf-three -node scripts/verify-publication-screen-corpus.ts screen-only /tmp/screen-only.json 54015 anime-frieren film-directed +pnpm verify:publication-screen publication-screen-demo /tmp/publication-screen-demo.json 54013 +pnpm verify:publication-screen publication-only /tmp/publication-only.json 54014 epub-1 pdf-three +pnpm verify:publication-screen screen-only /tmp/screen-only.json 54015 anime-frieren film-directed ``` 实际结果及失败范围由每次证据文件给出。该集合证明普通目录接入、实际格式解析、身份与关系、来源贡献及封面可读性,不代表完整阅读、播放、性能或真实网络验收。 diff --git a/package.json b/package.json index 8620b69..9bd32d5 100644 --- a/package.json +++ b/package.json @@ -4,24 +4,29 @@ "private": true, "type": "module", "scripts": { - "boundaries": "turbo boundaries --no-color", + "boundaries": "node --conditions=digital-source scripts/run-turbo-boundaries.ts && node --conditions=digital-source scripts/check-toolchain-versions.ts", "db:migrate": "pnpm --filter @digital/db-migrations prepare-only", "environment": "pnpm --filter @digital/environment cli", - "check": "pnpm boundaries && turbo run format:check lint typecheck build test test:e2e --continue=dependencies-successful --output-logs=errors-only --log-order=grouped --log-prefix=task", - "check:cli-environment": "turbo run test:e2e:environment --filter=@digital/cli --output-logs=errors-only --log-order=grouped --log-prefix=task", - "check:corpus-real": "turbo run test:corpus-real --output-logs=errors-only --log-order=grouped --log-prefix=task", - "check:deployment": "turbo run deployment --output-logs=errors-only --log-order=grouped --log-prefix=task", - "fix": "turbo run lint:fix format:write --continue=dependencies-successful --output-logs=full --log-order=grouped --log-prefix=task", - "format:check": "oxfmt --list-different --config oxfmt.config.ts package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig.json tsconfig.config.json oxfmt.config.ts oxlint.config.ts .gitignore .vscode .github .devcontainer scripts", - "format:write": "oxfmt --write --config oxfmt.config.ts package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig.json tsconfig.config.json oxfmt.config.ts oxlint.config.ts .gitignore .vscode .github .devcontainer scripts", - "lint": "oxlint --quiet --format=unix --type-aware --config oxlint.config.ts oxfmt.config.ts oxlint.config.ts scripts", - "lint:fix": "oxlint --format=unix --type-aware --config oxlint.config.ts oxfmt.config.ts oxlint.config.ts scripts --fix", - "preview": "node scripts/preview-environment.ts", - "preview:verify": "node scripts/preview-environment.ts verify", - "verify:web:http": "turbo run test:e2e:managed-service", - "verify:web:http:provided": "turbo run test:e2e:provided-service", + "check": "node --conditions=digital-source scripts/task-gates.ts check", + "fix": "node --conditions=digital-source scripts/task-gates.ts fix", + "format:check": "oxfmt --list-different --config oxfmt.config.ts package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig.json tsconfig.config.json *.config.ts .gitignore .dockerignore docker-compose.yml .vscode .github .devcontainer scripts", + "format:write": "oxfmt --write --config oxfmt.config.ts package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig.json tsconfig.config.json *.config.ts .gitignore .dockerignore docker-compose.yml .vscode .github .devcontainer scripts", + "lint": "oxlint --quiet --format=unix --type-aware --config oxlint.config.ts *.config.ts scripts", + "lint:fix": "oxlint --format=unix --type-aware --config oxlint.config.ts *.config.ts scripts --fix", + "preview": "DIGITAL_AUTOMATION_WORKSPACE=\"$PWD\" DIGITAL_AUTOMATION_SOURCE_DIRECTORY=\"$PWD/fixtures/controlled-source-io\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/preview-environment.ts", + "preview:verify": "DIGITAL_AUTOMATION_WORKSPACE=\"$PWD\" DIGITAL_AUTOMATION_SOURCE_DIRECTORY=\"$PWD/fixtures/controlled-source-io\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/preview-environment.ts verify", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "preview:dev": "node --conditions=digital-source scripts/development-environment.ts" + "preview:dev": "DIGITAL_AUTOMATION_WORKSPACE=\"$PWD\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/development-environment.ts", + "check:heavy": "node --conditions=digital-source scripts/task-gates.ts check:heavy", + "check:full": "node --conditions=digital-source scripts/task-gates.ts check:full", + "test": "node --test scripts/test/*.test.ts", + "development:build": "DIGITAL_AUTOMATION_WORKSPACE=\"$PWD\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/build-development.ts", + "development:serve": "DIGITAL_AUTOMATION_WORKSPACE=\"$PWD\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/development-task.ts", + "acceptance:prepare": "pnpm --filter @digital/web run acceptance:prepare --workspace \"$PWD\" --source-directory \"$PWD/fixtures/controlled-source-io\" --compose-file \"$PWD/docker-compose.yml\"", + "verify:audio-posts": "DIGITAL_AUTOMATION_WORKSPACE=\"$PWD\" DIGITAL_AUTOMATION_SOURCE_DIRECTORY=\"$PWD/fixtures/preview-corpus/audio-posts\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/verify-audio-post-corpus.ts", + "verify:publication-screen": "DIGITAL_AUTOMATION_WORKSPACE=\"$PWD\" DIGITAL_AUTOMATION_SOURCE_DIRECTORY=\"$PWD/fixtures/preview-corpus/publication-screen\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/verify-publication-screen-corpus.ts", + "fixture:source-io": "DIGITAL_AUTOMATION_OUTPUT_DIRECTORY=\"$PWD/fixtures/controlled-source-io\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/build-controlled-source-io-fixture.ts", + "fixture:fanbox-session": "DIGITAL_AUTOMATION_OUTPUT_DIRECTORY=\"$PWD/fixtures/controlled-fanbox-session\" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/build-controlled-fanbox-session-fixture.ts" }, "devDependencies": { "@types/node": "catalog:", @@ -32,7 +37,7 @@ "typescript-7": "catalog:" }, "engines": { - "node": "24" + "node": "24.21.0" }, "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457" } diff --git a/packages/application/package.json b/packages/application/package.json index 951726c..74d8a7c 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/application", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/domain": "workspace:*" diff --git a/packages/application/test/artifact-byte-access.test.ts b/packages/application/test/artifact-byte-access.test.ts index 9a05ea4..a5af39c 100644 --- a/packages/application/test/artifact-byte-access.test.ts +++ b/packages/application/test/artifact-byte-access.test.ts @@ -4,7 +4,6 @@ import { expect, it } from "vitest"; import { ApplicationArtifactByteAccess, - ArtifactByteAccessError, ExecutionHost, type ArtifactByteCleanupReporter, type ArtifactByteSource, @@ -372,6 +371,7 @@ it.each([ }, onOpen(count) { if (failure === "content-change" && count === 3) return "changed"; + return undefined; }, onClose() { closes += 1; @@ -389,7 +389,7 @@ it.each([ await expect( access.describe({ artifactRevisionId: "revision-inspected-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: failure === "root-revoke" ? "artifact-location-revoked" @@ -430,7 +430,7 @@ it("keeps inspected descriptors inside the shared 256KiB logical probe budget", await expect( access.describe({ artifactRevisionId: "revision-inspected-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-integrity-failed", }); expect(closes).toBe(2); @@ -489,7 +489,7 @@ function confirmationAccess(input: { return { source: { ...source("good"), - close: input.closeSource, + close: async () => await input.closeSource(), }, suggestedFilename: "file.txt", observedBinding: { @@ -550,7 +550,7 @@ it("does not publish a managed descriptor after source close starts Host shutdow await expect( access.describe({ artifactRevisionId: "revision-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "application-shutting-down", }); await expect(shutdown).resolves.toBeUndefined(); @@ -616,11 +616,11 @@ it("keeps observed-root cancellation primary when candidate close and cleanup re await expect( access.describe({ artifactRevisionId: "revision-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-location-revoked", }); expect(sourceCloseAttempts).toBe(1); - expect(cleanupPrimary).toMatchObject({ + expect(cleanupPrimary).toMatchObject({ code: "artifact-location-revoked", }); await expect(host.close()).resolves.toBeUndefined(); @@ -676,7 +676,7 @@ it("preserves observed-root cancellation before a candidate can be returned", as await expect( access.describe({ artifactRevisionId: "revision-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-location-revoked", }); expect(sourceClosed).toBe(1); @@ -741,7 +741,7 @@ it("keeps an observed binding active through the final describe cancellation che await expect( access.describe({ artifactRevisionId: "revision-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-location-revoked", }); expect(bindingReleased).toBe(true); @@ -778,7 +778,7 @@ it("preserves managed shutdown when an adapter closes its source and returns no await expect( access.describe({ artifactRevisionId: "revision-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "application-shutting-down", }); expect(opens).toBe(1); @@ -804,7 +804,7 @@ it("preserves shutdown when proof rebuild has no candidate to try", async () => await expect( access.describe({ artifactRevisionId: "revision-describe" }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "application-shutting-down", }); await expect(shutdown).resolves.toBeUndefined(); @@ -835,7 +835,7 @@ it("keeps confirmation binding active through its final result-publication check await expect( access.confirmObservedFileSelection("selection-confirm"), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-location-revoked", }); expect(committed).toBe(true); @@ -867,10 +867,10 @@ it("keeps confirmation shutdown primary when source cleanup and reporting fail", await expect( access.confirmObservedFileSelection("selection-confirm"), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "application-shutting-down", }); - expect(cleanupPrimary).toMatchObject({ + expect(cleanupPrimary).toMatchObject({ code: "application-shutting-down", }); expect(bindingReleased).toBe(false); @@ -1098,7 +1098,7 @@ it("cancels confirmation proof I/O and drains its one operation reservation befo }, async build(_source, cancellation) { announceStarted(); - await new Promise((_resolve, reject) => { + return await new Promise((_resolve, reject) => { cancellation.onCancel(() => reject(new Error("cancelled"))); }); }, @@ -1110,7 +1110,7 @@ it("cancels confirmation proof I/O and drains its one operation reservation befo await started; await host.close(); - await expect(confirmation).rejects.toMatchObject({ + await expect(confirmation).rejects.toMatchObject({ code: "application-shutting-down", }); expect(sourceClosed).toBe(true); @@ -1205,7 +1205,7 @@ it("keeps first-frame integrity as primary while reporting every source and mani expectedBlobDigest: digest, interval: { startInclusive: 0, endInclusive: 3 }, }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-integrity-failed", }); expect(cleanup).toEqual(["media-source", "proof-manifest"]); @@ -1319,7 +1319,7 @@ it("fails before returning a lease when its observed root is invalidated after f expectedBlobDigest: digest, interval: { startInclusive: 0, endInclusive: 3 }, }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-location-revoked", }); expect(sourceClosed).toBe(1); @@ -1432,11 +1432,11 @@ it("cleans an observed source when its root is invalidated before candidate owne expectedBlobDigest: digest, interval: { startInclusive: 0, endInclusive: 3 }, }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-location-revoked", }); expect(sourceCloseAttempts).toBe(1); - expect(cleanupPrimary).toMatchObject({ + expect(cleanupPrimary).toMatchObject({ code: "artifact-location-revoked", }); expect(manifestClosed).toBe(1); @@ -1606,7 +1606,7 @@ it("rejects every interval for an empty Blob before opening its proof or candida expectedBlobDigest: digest, interval: { startInclusive: 0, endInclusive: -1 }, }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-integrity-failed", }); expect(proofReads).toBe(0); diff --git a/packages/application/test/asset-refresh.test.ts b/packages/application/test/asset-refresh.test.ts index 2e7adc5..63ae2df 100644 --- a/packages/application/test/asset-refresh.test.ts +++ b/packages/application/test/asset-refresh.test.ts @@ -17,7 +17,7 @@ import { InMemoryObservedRootCleanupReporter, InMemoryObservedRootRegistrationStore, } from "@digital/test-support"; -import { describe, expect, it, vi } from "vitest"; +import { assert, describe, expect, it, vi } from "vitest"; import { AssetRefreshWorkflow, @@ -28,6 +28,7 @@ import { ObservedRootUnavailable, ObservedRootRegistrations, attachAssetRefreshAutomation, + emptyInformationRequirement, prerequisiteUnavailableFailure, recognitionMaterialBoundaryIntersectsScope, sha256, @@ -36,6 +37,7 @@ import { type AssetLibraryPersistence, type LibraryScanDirectoryRecord, type AutomationCommands, + type AutomationDisabledReason, type AutomationQueries, type ChangeImpactCommands, type ChangeImpactPreview, @@ -46,6 +48,10 @@ import { type InformationRequirement, type FilesystemObjectIdentityContract, type RecognitionAcceptancePersistence, + type RecognitionChangeImpactPersistence, + type SourceCollector, + type SourceInterpreter, + type Recognizer, type RecognitionReevaluationPersistence, type RecoverySourceRefreshRunCommands, type SourceRefreshRunPersistence, @@ -65,11 +71,14 @@ type RefreshFixturePersistence = WorkflowExecutionCommands & AssetRefreshReuseQueries & RecognitionAcceptancePersistence & RecognitionReevaluationPersistence & + RecognitionChangeImpactPersistence & MetadataRefreshPersistence & ChangeImpactQueries & ChangeImpactCommands & AutomationQueries & - AutomationCommands; + AutomationCommands & { + listRunningAutomationExecutions(): Promise; + }; function bindSourceMetadata( metadata: SourceInterpretationBatch["metadata"], @@ -248,15 +257,25 @@ async function refreshFixtureObservedScope( relativeScope: ".", sourceIdentity: directedSourceIdentity, sourceInput: { address: directedSourceAddress }, - informationRequirement: input.informationRequirement, - onExecutionStarted: input.onExecutionStarted, - onProgress: input.onProgress, + ...(input.informationRequirement === undefined + ? {} + : { informationRequirement: input.informationRequirement }), + ...(input.onExecutionStarted === undefined + ? {} + : { onExecutionStarted: input.onExecutionStarted }), + ...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }), }); } -type FixtureSelection = Parameters< - AssetRefreshWorkflow["refreshMetadata"] ->[0]["selection"]; +type FixtureSelection = Omit< + Parameters[0]["selection"], + "filesystemScopes" +> & { + readonly filesystemScopes: readonly { + readonly root: string; + readonly explanations: readonly { readonly path: readonly string[] }[]; + }[]; +}; function fixtureRegisteredScopeFromSelection( selection: FixtureSelection, @@ -704,6 +723,7 @@ function refreshFixture(options?: { ); if (matches.length !== 1) return false; const scope = matches[0]; + assert.isDefined(scope); return ( scope.state === "terminal" && (scope.outcome === "failed" || scope.outcome === "stale") && @@ -713,6 +733,9 @@ function refreshFixture(options?: { }); }; const persistence: RefreshFixturePersistence = { + async requestLibraryScanCancellation() { + throw new Error("本夹具不应取消资产库扫描"); + }, async claimResumeExecution({ executionId, token, now, expiresAt }) { resumeClaimAttempts += 1; const inspection = persisted.get(executionId); @@ -1012,6 +1035,9 @@ function refreshFixture(options?: { persisted.set(execution.id, { ...inspection, execution }); changePreviews.set(preview.id, preview); }, + async finishExecutionWithRecognitionScopeAmendmentPreview() { + throw new Error("本夹具不应生成识别范围修订预览"); + }, async acceptRecognition(input) { recognitionAcceptanceCalls += 1; if (options?.acceptanceBarrier !== undefined && !acceptanceBarrierUsed) { @@ -1170,6 +1196,12 @@ function refreshFixture(options?: { async listExecutions() { return [...persisted.values()].map((inspection) => inspection.execution); }, + async inspectRawSourceRecord() { + throw new Error("本夹具不应读取原始来源记录"); + }, + async listUnrecognizedLibraryScanObservations() { + throw new Error("本夹具不应读取资产库未识别观察"); + }, async listRunningExecutions() { return [...persisted.values()] .map((inspection) => inspection.execution) @@ -1463,12 +1495,13 @@ function refreshFixture(options?: { accepted[index] = projected; currentRecognitionHeadExecutionByAsset.set(next.entity.id, execution.id); const inspection = persisted.get(execution.id)!; + assert.isDefined(execution.workItems); persisted.set(execution.id, { ...inspection, execution, proposal: next.proposal, accepted: projected, - workItemInspections: execution.workItems?.map((item) => + workItemInspections: execution.workItems.map((item) => item.acceptedAssetId === next.entity.id ? { workItem: item, proposal: next.proposal, accepted: projected } : { @@ -1529,6 +1562,7 @@ function refreshFixture(options?: { metadata: [ ...accepted[0].metadata, { + target: { kind: "entity", entityId: accepted[0].entity.id }, property: "user.note", sourceSlot: "user.note", value: "并发用户事实", @@ -1543,7 +1577,23 @@ function refreshFixture(options?: { } return "complete-current-projection"; }, + async applySourceAssociationChangePreview() { + throw new Error("本夹具不应应用来源关联修订"); + }, + async applyRecognitionRetractionPreview() { + throw new Error("本夹具不应应用识别撤回"); + }, + async applyRecognitionScopeAmendmentPreview() { + throw new Error("本夹具不应应用识别范围修订"); + }, + async saveArtifactRoleDecisionPreview() { + throw new Error("本夹具不应保存制品角色决定"); + }, + async applyArtifactRoleDecisionPreview() { + throw new Error("本夹具不应应用制品角色决定"); + }, async applyChangeImpactPreview({ preview, plan }) { + assert.isDefined(preview.proposal); const stored = changePreviews.get(preview.id); if (stored === undefined || stored.status !== "awaiting-confirmation") return "stale"; @@ -1554,22 +1604,25 @@ function refreshFixture(options?: { ); for (const [executionId, inspection] of persisted) { if (inspection.accepted?.entity.id !== preview.assetId) continue; + const { + recognitionSourceAssociation: _recognitionSourceAssociation, + ...current + } = inspection.accepted; persisted.set(executionId, { ...inspection, proposal: preview.proposal, accepted: { - ...inspection.accepted, + ...current, proposal: preview.proposal, sourceAssociations: preview.projected.sourceAssociations.map( (association, index) => ({ id: `preview-association-${index}`, subjectId: preview.assetId, - proposalId: preview.proposal.id, ...association, }), ), ...(plan.recognitionSourceAssociation === undefined - ? { recognitionSourceAssociation: undefined } + ? {} : { recognitionSourceAssociation: plan.recognitionSourceAssociation, @@ -1593,21 +1646,24 @@ function refreshFixture(options?: { ({ entity }) => entity.id === preview.assetId, ); const acceptedValue = accepted[acceptedIndex]; - if (acceptedValue !== undefined) + if (acceptedValue !== undefined) { + const { + recognitionSourceAssociation: _recognitionSourceAssociation, + ...current + } = acceptedValue; accepted[acceptedIndex] = { - ...acceptedValue, + ...current, proposal: preview.proposal, recognitionDecision: plan.successorDecision, sourceAssociations: preview.projected.sourceAssociations.map( (association, index) => ({ id: `preview-association-${index}`, subjectId: preview.assetId, - proposalId: preview.proposal.id, ...association, }), ), ...(plan.recognitionSourceAssociation === undefined - ? { recognitionSourceAssociation: undefined } + ? {} : { recognitionSourceAssociation: plan.recognitionSourceAssociation, }), @@ -1623,6 +1679,7 @@ function refreshFixture(options?: { artifactRevisions: preview.projected.artifactRevisions, artifactAssociations: preview.projected.artifactAssociations, }; + } return "applied"; }, async rejectChangeImpactPreview({ previewId }) { @@ -1897,7 +1954,10 @@ function refreshFixture(options?: { complete: options?.incompleteSnapshot !== true, excluded: [], observedAt: "2026-07-29T00:00:00.000Z", - files: options?.observedFiles ?? [ + files: options?.observedFiles?.map((file) => ({ + ...file, + modifiedAt: "2026-07-29T00:00:00.000Z", + })) ?? [ { relativePath: "track.flac", size: 12, @@ -2263,7 +2323,7 @@ function refreshFixture(options?: { ? [] : ["1"], }, - canCollect: (evidence) => + canCollect: (evidence: Parameters[0]) => evidence.sourceIdentity !== "supplement" && evidence.sourceIdentity !== "failing-supplement" && evidence.sourceIdentity !== "surviving-supplement", @@ -2335,7 +2395,7 @@ function refreshFixture(options?: { }, } : {}), - async collect(evidence) { + async collect(evidence: Parameters[0]) { if (options?.requireCollectorCoordinateBeforeInvocation === true) { const current = [...persisted.values()].at(-1)?.execution; if ( @@ -2486,13 +2546,18 @@ function refreshFixture(options?: { kind: "source-collector", interpretationContractVersions: ["1"], }, - canCollect: (evidence) => + canCollect: ( + evidence: Parameters[0], + ) => evidence.sourceIdentity === "supplement" || evidence.sourceIdentity === "failing-supplement" || evidence.sourceIdentity === "surviving-supplement", coverageScopeIds: () => ["supplement.complete"], - async collect(evidence) { + async collect( + evidence: Parameters[0], + ) { const sourceIdentity = evidence.sourceIdentity; + assert.isDefined(sourceIdentity); enrichmentCollectionCalls.set( sourceIdentity, (enrichmentCollectionCalls.get(sourceIdentity) ?? 0) + 1, @@ -2621,7 +2686,9 @@ function refreshFixture(options?: { ? {} : { interpretationContractVersions: ["1"] }), }, - canCollect(evidence) { + canCollect( + evidence: Parameters[0], + ) { if (!options.dynamicRecoveryCollectorState!.enabled) return false; if ( @@ -2724,7 +2791,9 @@ function refreshFixture(options?: { ); }, }, - canInterpret: ({ evidence }) => + canInterpret: ({ + evidence, + }: Parameters[0]) => evidence.every( (item) => item.sourceIdentity !== "supplement" && @@ -3017,9 +3086,13 @@ function refreshFixture(options?: { capabilityVersion: "1", contractVersion: "1", }, - canInterpret: ({ evidence }) => + canInterpret: ({ + evidence, + }: Parameters[0]) => evidence.some((item) => item.sourceIdentity === "supplement"), - interpret: ({ sourceRecords }) => [ + interpret: ({ + sourceRecords, + }: Parameters[0]) => [ { id: "supplement-interpretation", producer: { @@ -3069,7 +3142,9 @@ function refreshFixture(options?: { capabilityVersion: "1", contractVersion: "1", }, - canInterpret: ({ evidence }) => + canInterpret: ({ + evidence, + }: Parameters[0]) => evidence.some( (item) => item.sourceIdentity === "failing-supplement", ), @@ -3083,11 +3158,15 @@ function refreshFixture(options?: { capabilityVersion: "1", contractVersion: "1", }, - canInterpret: ({ evidence }) => + canInterpret: ({ + evidence, + }: Parameters[0]) => evidence.some( (item) => item.sourceIdentity === "surviving-supplement", ), - interpret: ({ sourceRecords }) => [ + interpret: ({ + sourceRecords, + }: Parameters[0]) => [ { id: "surviving-supplement-interpretation", producer: { @@ -3128,17 +3207,15 @@ function refreshFixture(options?: { return options?.recognizerCoordinateState?.contractVersion ?? "1"; }, }, - canRecognize: (input) => + canRecognize: (input: Parameters[0]) => input.basis.kind === (options?.recognitionBasis ?? "source-interpretation"), recognize: (input) => { const evidence = input.evidence; const interpretation = - "interpretation" in input - ? input.interpretation - : input.basis.kind === "source-interpretation" - ? input.basis.interpretation - : undefined; + input.basis.kind === "source-interpretation" + ? input.basis.interpretation + : undefined; if (options?.recognizerFailureState?.enabled === true) throw new Error("recognizer exploded"); const forgery = options?.recognizerProposalForgery; @@ -3268,13 +3345,17 @@ function refreshFixture(options?: { capabilityVersion: "1", contractVersion: "1", }, - canRecognize: (input) => + canRecognize: ( + input: Parameters[0], + ) => input.basis.kind === "source-interpretation" && (options?.competingProposals === true || options?.ambiguousSourceAddresses?.includes( input.basis.interpretation.sourceAddress, ) === true), - recognize: ({ evidence }) => ({ + recognize: ({ + evidence, + }: Parameters[0]) => ({ id: "competing-proposal", ruleResults: [ { @@ -3403,6 +3484,7 @@ function refreshFixture(options?: { : {}), managedStore: { async stage(content) { + assert.isDefined(content.role); return { candidateId: content.candidateId, sourceRecordId: content.sourceRecordId, @@ -3492,8 +3574,12 @@ function refreshFixture(options?: { role: "fanbox.attachment.file", acquisitionReference: "opaque-provider-reference", mediaType: "application/octet-stream", - selectionEvidence: - options.publicCandidateSelectionEvidence, + ...(options.publicCandidateSelectionEvidence === undefined + ? {} + : { + selectionEvidence: + options.publicCandidateSelectionEvidence, + }), }, interpretationSourceRecordIds: ["record"], sourceRecords: [], @@ -4044,11 +4130,21 @@ describe("Asset Refresh acceptance", () => { }); const inspection = subject.persisted.get(proposed.execution.id)!; const stored = inspection.proposal!; - const workItem = { - ...inspection.execution.workItems![0], - outcome: undefined, - reason: undefined, - }; + const firstWorkItem = inspection.execution.workItems?.[0]; + assert.isDefined(firstWorkItem); + const { + outcome: _itemOutcome, + reason: _itemReason, + ...workItem + } = firstWorkItem; + const { + outcome: _outcome, + reason: _reason, + ...runningExecution + } = inspection.execution; + const workflowInput = inspection.execution.workflowInput; + assert.isDefined(workflowInput); + assert(workflowInput.version === "asset-refresh-input/1"); if (stored.basis.kind !== "source-interpretation") throw new Error("测试提案缺少来源解释依据"); const malformedInterpretation = { ...stored.basis.interpretation }; @@ -4062,15 +4158,13 @@ describe("Asset Refresh acceptance", () => { subject.persisted.set(proposed.execution.id, { ...inspection, execution: { - ...inspection.execution, + ...runningExecution, workflowInput: { - ...inspection.execution.workflowInput!, + ...workflowInput, acceptTrusted: true, }, status: "running", stage: "recognition", - outcome: undefined, - reason: undefined, workItems: [workItem], }, proposal: { @@ -4219,6 +4313,7 @@ describe("Asset Refresh acceptance", () => { // 价值保留:同一资产、同一识别决定被就地刷新,观察版本推进(新记录替换旧记录), // 既没有新建身份/决定,也没有重复资产。 expect(subject.accepted).toHaveLength(1); + assert.isDefined(subject.accepted[0]); expect(subject.accepted[0].entity.id).toBe(firstEntityId); expect(subject.accepted[0].recognitionDecision.id).toBe(firstDecisionId); expect(subject.accepted[0]).not.toBe(first); @@ -4255,6 +4350,7 @@ describe("Asset Refresh acceptance", () => { recognitionBasis: "observed-evidence", }); await scanFixtureObservedScope(subject.workflow, { acceptTrusted: true }); + assert.isDefined(subject.accepted[0]); const assetId = subject.accepted[0].entity.id; const refreshed = await subject.workflow.refreshMetadata({ @@ -4387,7 +4483,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId, - acceptTrusted: true, }); // 执行聚合原因承载在 execution 上,具体越权字段原因现在落在 workItems[]: @@ -4454,7 +4549,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId, - acceptTrusted: true, }); // 初次采集已耐久保存,识别器尚未参与;恢复时仍须拒绝其冒用受信规则的结果。 expect(resumed.execution).toMatchObject({ @@ -4601,6 +4695,7 @@ describe("Asset Refresh acceptance", () => { }); failure.enabled = true; + assert.isDefined(subject.accepted[0]); const result = await refreshFixtureMetadata( subject.workflow, subject.accepted[0].entity.id, @@ -4810,6 +4905,7 @@ describe("Asset Refresh acceptance", () => { informationRequirement: primaryCoverRequirement, }); + assert.isDefined(subject.accepted[0]); const result = await refreshFixtureMetadata( subject.workflow, subject.accepted[0].entity.id, @@ -4900,6 +4996,7 @@ describe("Asset Refresh acceptance", () => { }, }); const accepted = subject.accepted[0]; + assert.isDefined(accepted); const localMetadata = accepted.metadata.find( (item) => item.sourceSlot === "format.title", ); @@ -5203,6 +5300,7 @@ describe("Asset Refresh acceptance", () => { if (result.execution.outcome !== "accepted") throw new Error(result.execution.reason ?? "local part was not accepted"); const accepted = subject.accepted[0]; + assert.isDefined(accepted); const local = accepted.metadata.find( (item) => item.sourceSlot === "format.title", ); @@ -5259,11 +5357,21 @@ describe("Asset Refresh acceptance", () => { }); const inspection = subject.persisted.get(proposed.execution.id)!; const stored = inspection.proposal!; - const workItem = { - ...inspection.execution.workItems![0], - outcome: undefined, - reason: undefined, - }; + const firstWorkItem = inspection.execution.workItems?.[0]; + assert.isDefined(firstWorkItem); + const { + outcome: _itemOutcome, + reason: _itemReason, + ...workItem + } = firstWorkItem; + const { + outcome: _outcome, + reason: _reason, + ...runningExecution + } = inspection.execution; + const workflowInput = inspection.execution.workflowInput; + assert.isDefined(workflowInput); + assert(workflowInput.version === "asset-refresh-input/1"); const withoutProposedParent = { ...stored, observedArtifactRevisions: [], @@ -5271,15 +5379,13 @@ describe("Asset Refresh acceptance", () => { subject.persisted.set(proposed.execution.id, { ...inspection, execution: { - ...inspection.execution, + ...runningExecution, workflowInput: { - ...inspection.execution.workflowInput!, + ...workflowInput, acceptTrusted: true, }, status: "running", stage: "recognition", - outcome: undefined, - reason: undefined, workItems: [workItem], }, proposal: withoutProposedParent, @@ -5350,12 +5456,12 @@ describe("Asset Refresh acceptance", () => { ], }; expect(validateSourceInterpretationBatch(batch)).toBe(batch); + const firstSourceArtifact = batch.sourceArtifacts[0]; + assert.isDefined(firstSourceArtifact); expect(() => validateSourceInterpretationBatch({ ...batch, - sourceArtifacts: [ - { ...batch.sourceArtifacts[0], subjectId: "injected" }, - ], + sourceArtifacts: [{ ...firstSourceArtifact, subjectId: "injected" }], }), ).toThrow("target must be a sourced resolved Entity"); }); @@ -5724,11 +5830,12 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); const persisted = subject.persisted.get(initial.execution.id)!; + assert.isDefined(persisted.execution.workItems); subject.persisted.set(initial.execution.id, { ...persisted, execution: { ...persisted.execution, - workItems: persisted.execution.workItems?.map((item) => { + workItems: persisted.execution.workItems.map((item) => { const { reuseCompatibility: _reuseCompatibility, ...legacy } = item; return legacy; }), @@ -5859,6 +5966,7 @@ describe("Asset Refresh acceptance", () => { directory: "/observed/work", acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const firstAssetId = subject.accepted[0].entity.id; const firstAssociationId = subject.accepted[0].recognitionSourceAssociation!.id; @@ -5905,6 +6013,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(current); expect(reevaluated.execution.workItems).toEqual([ expect.objectContaining({ relativePaths: ["track.flac"], @@ -6152,9 +6261,9 @@ describe("Asset Refresh acceptance", () => { ).rejects.toThrow("interrupt after first boundary"); const collectionsBeforeResume = subject.collectionCalls(); + assert.isDefined(subject.executions[0]); const resumed = await subject.workflow.resume({ executionId: subject.executions[0].id, - acceptTrusted: true, }); expect(resumed.execution.outcome).toBe("accepted"); expect(subject.collectionCalls()).toBe(collectionsBeforeResume); @@ -6178,30 +6287,31 @@ describe("Asset Refresh acceptance", () => { }, }), ).rejects.toThrow("interrupt before the second itemized batch"); + assert.isDefined(subject.executions[0]); const executionId = subject.executions[0].id; const persisted = subject.persisted.get(executionId)!; + assert.isDefined(persisted.execution.workItems); subject.persisted.set(executionId, { ...persisted, execution: { ...persisted.execution, - workItems: persisted.execution.workItems?.map((item) => - item.key === "second-boundary" - ? { - ...item, - planningEvidence: item.planningEvidence.map((evidence) => ({ - ...evidence, - sourceIdentity: "supplement", - })), - } - : item, - ), + workItems: persisted.execution.workItems.map((item) => { + if (item.key !== "second-boundary") return item; + assert.isDefined(item.planningEvidence); + return { + ...item, + planningEvidence: item.planningEvidence.map((evidence) => ({ + ...evidence, + sourceIdentity: "supplement", + })), + }; + }), }, }); const collectionsBeforeResume = subject.collectionCalls(); const resumed = await subject.workflow.resume({ executionId, - acceptTrusted: true, }); expect(resumed.execution.workItems).toEqual([ @@ -6246,9 +6356,9 @@ describe("Asset Refresh acceptance", () => { dynamic.enabled = true; const collectionsBeforeResume = subject.collectionCalls(); + assert.isDefined(subject.executions[0]); const resumed = await subject.workflow.resume({ executionId: subject.executions[0].id, - acceptTrusted: true, }); expect(dynamic.canCollectCalls).toBe(1); @@ -6293,9 +6403,9 @@ describe("Asset Refresh acceptance", () => { dynamic.enabled = true; const collectionsBeforeResume = subject.collectionCalls(); + assert.isDefined(subject.executions[0]); const resumed = await subject.workflow.resume({ executionId: subject.executions[0].id, - acceptTrusted: true, }); expect( @@ -6400,6 +6510,7 @@ describe("Asset Refresh acceptance", () => { }), ).rejects.toBe(interruption); + assert.isDefined(subject.executions[0]); const durable = subject.persisted.get(subject.executions[0].id); expect(durable?.execution.status).toBe("running"); expect(durable?.execution.outcome).not.toBe("failed"); @@ -6469,6 +6580,8 @@ describe("Asset Refresh acceptance", () => { (association) => association.role === "asset.primary-cover", ), ); + assert.isDefined(subject.accepted[1]); + assert.isDefined(subject.accepted[0]); expect(covers).toEqual([ expect.objectContaining({ target: { @@ -6531,7 +6644,9 @@ describe("Asset Refresh acceptance", () => { >[number], ) => { if (coordinate === "sourceRecordId") { - const { sourceRecordId: _ignored, ...legacy } = checkpoint; + const legacy = { ...checkpoint }; + // 故意破坏已持久化检查点,验证恢复入口拒绝缺失必填坐标。 + Reflect.deleteProperty(legacy, "sourceRecordId"); return legacy; } if (coordinate === "subjectId") { @@ -6552,7 +6667,9 @@ describe("Asset Refresh acceptance", () => { >[number], ) => { if (coordinate === "sourceRecordId") { - const { sourceRecordId: _ignored, ...legacy } = checkpoint; + const legacy = { ...checkpoint }; + // 故意破坏已持久化检查点,验证恢复入口拒绝缺失必填坐标。 + Reflect.deleteProperty(legacy, "sourceRecordId"); return legacy; } if (coordinate === "subjectId") { @@ -6568,21 +6685,32 @@ describe("Asset Refresh acceptance", () => { blob: { ...checkpoint.blob, mediaType: "image/png" }, }; }; + assert.isDefined(execution.workItems); subject.persisted.set(executionId, { ...inspection!, execution: { ...execution, - workItems: execution.workItems?.map((item) => + workItems: execution.workItems.map((item) => stage === "source-artifacts-acquired" ? { ...item, - sourceArtifactAcquisitions: - item.sourceArtifactAcquisitions?.map(legacyAcquisition), + ...(item.sourceArtifactAcquisitions === undefined + ? {} + : { + sourceArtifactAcquisitions: + item.sourceArtifactAcquisitions.map( + legacyAcquisition, + ), + }), } : { ...item, - managedSourceArtifacts: - item.managedSourceArtifacts?.map(legacyManaged), + ...(item.managedSourceArtifacts === undefined + ? {} + : { + managedSourceArtifacts: + item.managedSourceArtifacts.map(legacyManaged), + }), }, ), }, @@ -6590,7 +6718,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId, - acceptTrusted: true, }); expect(resumed.execution.outcome).toBe("accepted"); // 托管记录失效时仍保留合法获取记录,只重存;获取记录失效时才重新获取。 @@ -6601,6 +6728,7 @@ describe("Asset Refresh acceptance", () => { stage === "source-artifacts-acquired" ? 1 : 2, ); expect(subject.accepted).toHaveLength(1); + assert.isDefined(subject.accepted[0]); expect(subject.accepted[0].artifactAssociations).toEqual( expect.arrayContaining([ expect.objectContaining({ role: "asset.primary-cover" }), @@ -6919,6 +7047,7 @@ describe("Asset Refresh acceptance", () => { expect(result.execution.outcome).toBe("accepted"); const accepted = subject.accepted[0]; + assert.isDefined(accepted); expect(accepted.artifacts).toHaveLength(2); const observed = accepted.locations.find( (location) => location.kind === "observed", @@ -6997,6 +7126,7 @@ describe("Asset Refresh acceptance", () => { expect(result.execution.outcome).toBe("accepted"); const accepted = subject.accepted[0]; + assert.isDefined(accepted); expect(accepted.artifacts).toHaveLength(1); expect(accepted.artifactAssociations).toHaveLength(1); expect(accepted.locations).toEqual( @@ -7154,7 +7284,7 @@ describe("Asset Refresh acceptance", () => { outcome: "failed", sourceRecordIds: ["record"], }); - expect(result.proposal).toBeUndefined(); + expect(result).not.toHaveProperty("proposal"); expect(subject.retained).toEqual(["record"]); expect(subject.accepted).toEqual([]); }); @@ -7400,6 +7530,7 @@ describe("Asset Refresh acceptance", () => { ]), ); expect(subject.accepted).toHaveLength(1); + assert.isDefined(subject.accepted[0]); expect( subject.accepted[0].artifactAssociations.some( (association) => association.role === "asset.primary-cover", @@ -7493,6 +7624,7 @@ describe("Asset Refresh acceptance", () => { }); expect(result.execution.outcome).toBe("accepted"); + assert.isDefined(subject.accepted[0]); expect(subject.accepted[0].artifactAssociations).toEqual( expect.arrayContaining([ expect.objectContaining({ role: "asset.primary-cover" }), @@ -7574,6 +7706,7 @@ describe("Asset Refresh acceptance", () => { status: "failed", }, ]); + assert.isDefined(subject.accepted[0]); expect( subject.accepted[0].artifactAssociations.filter( (item) => item.role === "asset.primary-cover", @@ -7633,6 +7766,7 @@ describe("Asset Refresh acceptance", () => { status: "failed", }, ]); + assert.isDefined(subject.accepted[0]); expect( subject.accepted[0].artifactAssociations.filter( (item) => item.role === "asset.primary-cover", @@ -7676,6 +7810,7 @@ describe("Asset Refresh acceptance", () => { expect(resumed.execution.outcome).toBe("accepted"); expect(subject.accepted).toHaveLength(1); + assert.isDefined(subject.accepted[0]); expect(subject.accepted[0].entity.id).toBe(plannedAssetId); }); @@ -7726,6 +7861,7 @@ describe("Asset Refresh acceptance", () => { }); expect(subject.acquisitionCalls()).toBe(2); if (necessity === "optional") { + assert.isDefined(subject.accepted[0]); expect(subject.accepted[0].artifactAssociations).toEqual( expect.arrayContaining([ expect.objectContaining({ role: "asset.primary-cover" }), @@ -7811,6 +7947,7 @@ describe("Asset Refresh acceptance", () => { expect(subject.acquisitionCalls()).toBe(2); expect(subject.accepted).toHaveLength(1); + assert.isDefined(subject.accepted[0]); expect( subject.accepted[0].artifactAssociations.filter( (association) => association.role === "asset.primary-cover", @@ -8014,6 +8151,7 @@ describe("Asset Refresh acceptance", () => { const episodeB = subject.accepted[1]; const observerCallsBeforeRefresh = subject.observerCalls(); + assert.isDefined(episodeB); const refreshed = await subject.workflow.refreshMetadata({ selection: { version: "asset-refresh-selection/1", @@ -8042,6 +8180,7 @@ describe("Asset Refresh acceptance", () => { }); const [accepted] = subject.accepted; + assert.isDefined(accepted); const refreshed = await subject.workflow.refreshMetadata({ selection: { version: "asset-refresh-selection/1", @@ -8106,7 +8245,9 @@ describe("Asset Refresh acceptance", () => { runId: string, withSibling = false, ) => { - const { outcome: _outcome, ...terminal } = accepted.execution; + const durable = subject.persisted.get(accepted.execution.id); + assert.isDefined(durable); + const { outcome: _outcome, ...terminal } = durable.execution; const execution: WorkflowExecution = { ...terminal, id: executionId, @@ -8180,6 +8321,7 @@ describe("Asset Refresh acceptance", () => { idempotencyKey: key, }; const before = await commands.inspectExecution(expected.id); + assert.isDefined(codes[0]); await expect( commands.commitSourceRefreshCollectionFailure({ ...input, @@ -8332,6 +8474,7 @@ describe("Asset Refresh acceptance", () => { }); const [accepted] = subject.accepted; + assert.isDefined(accepted); const refreshed = await subject.workflow.refreshMetadata({ selection: { version: "asset-refresh-selection/1", @@ -8363,6 +8506,7 @@ describe("Asset Refresh acceptance", () => { }); const [accepted] = subject.accepted; + assert.isDefined(accepted); const awaitingReview = await subject.workflow.refreshMetadata({ selection: { version: "asset-refresh-selection/1", @@ -8388,6 +8532,7 @@ describe("Asset Refresh acceptance", () => { changeSets: [{ disposition: "pending" }], }); + assert.isDefined(run!.changeSets[0]); await expect( subject.workflow.reviewSourceRefreshChangeSet({ runId: run!.id, @@ -8492,14 +8637,16 @@ describe("Asset Refresh acceptance", () => { }); const run = awaitingReview.sourceRefreshRuns?.[0]; const durable = subject.persisted.get(awaitingReview.execution.id)!; + assert.isDefined(durable.execution.workflowInput); subject.persisted.set(awaitingReview.execution.id, { ...durable, execution: { ...durable.execution, - workflowInput: { - ...durable.execution.workflowInput!, - version: "asset-refresh-input/999" as "asset-refresh-input/1", - }, + workflowInput: Object.defineProperty( + { ...durable.execution.workflowInput }, + "version", + { value: "asset-refresh-input/999", enumerable: true }, + ), }, }); @@ -8672,6 +8819,7 @@ describe("Asset Refresh acceptance", () => { candidate.enabled = true; candidate.fails = true; + assert.isDefined(run!.changeSets[0]); await expect( subject.workflow.reviewSourceRefreshChangeSet({ runId: run!.id, @@ -8694,7 +8842,6 @@ describe("Asset Refresh acceptance", () => { await expect( subject.workflow.resume({ executionId: awaitingReview.execution.id, - acceptTrusted: true, }), ).rejects.toThrow("只有未完成的刷新记录可以继续"); }); @@ -8710,6 +8857,8 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); const [episodeA, episodeB] = subject.accepted; + assert.isDefined(episodeA); + assert.isDefined(episodeB); const executionCountBeforeRefresh = ( await subject.workflow.listInspections() ).length; @@ -8763,6 +8912,7 @@ describe("Asset Refresh acceptance", () => { const [accepted] = subject.accepted; const before = (await subject.workflow.listInspections()).length; + assert.isDefined(accepted); const refreshed = await subject.workflow.refreshMetadata({ selection: { version: "asset-refresh-selection/1", @@ -8896,6 +9046,7 @@ describe("Asset Refresh acceptance", () => { }), ).rejects.toBe(interruption); + assert.isDefined(subject.executions[0]); const durable = subject.persisted.get(subject.executions[0].id)!; expect( durable.execution.sourceEnrichments?.some( @@ -8935,7 +9086,6 @@ describe("Asset Refresh acceptance", () => { await expect( subject.workflow.resume({ executionId: failed.execution.id, - acceptTrusted: true, }), ).rejects.toThrow("只有未完成的刷新记录可以继续"); }); @@ -8995,7 +9145,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId: pending!.execution.id, - acceptTrusted: true, }); expect(subject.enrichmentCollectionCalls("failing-supplement")).toBe(1); @@ -9074,7 +9223,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId: pending!.execution.id, - acceptTrusted: true, }); expect(subject.enrichmentCollectionCalls("failing-supplement")).toBe(1); @@ -9210,7 +9358,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId: executionId!, - acceptTrusted: true, }); expect(subject.collectionCalls()).toBe(collectionCalls); @@ -9269,7 +9416,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId: executionId!, - acceptTrusted: true, }); expect(subject.collectionCalls()).toBe(collectionCalls); @@ -9316,6 +9462,7 @@ describe("Asset Refresh acceptance", () => { "simulated process interruption before identity replacement", ); const previous = subject.accepted[0]; + assert.isDefined(previous); const replacement = { ...previous, recognitionSourceAssociation: { @@ -9341,7 +9488,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId: executionId!, - acceptTrusted: true, }); expect(resumed.execution.outcome).toBe("partially-succeeded"); @@ -9411,7 +9557,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId: executionId!, - acceptTrusted: true, }); expect(subject.collectionCalls()).toBe(collectionCalls); @@ -9471,7 +9616,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId: executionId!, - acceptTrusted: true, }); expect(subject.collectionCalls()).toBe(collectionCalls); @@ -9578,6 +9722,7 @@ describe("Asset Refresh acceptance", () => { ), ).toBe(true); + assert.isDefined(subject.accepted[0]); const evidence = await subject.workflow.inspectSubjectConflictEvidence( subject.accepted[0].entity.id, { @@ -9613,6 +9758,7 @@ describe("Asset Refresh acceptance", () => { }); const [accepted] = subject.accepted; + assert.isDefined(accepted); const refreshed = await subject.workflow.refreshMetadata({ selection: { version: "asset-refresh-selection/1", @@ -9680,6 +9826,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); const episodeB = subject.accepted[1]; + assert.isDefined(episodeB); partialCollectionFailureState.addresses.add( episodeB.recognitionSourceAssociation!.nativeAddress, ); @@ -9746,6 +9893,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, informationRequirement: primaryCoverRequirement, }); + assert.isDefined(subject.accepted[0]); const refreshed = await refreshFixtureMetadata( subject.workflow, subject.accepted[0].entity.id, @@ -9777,6 +9925,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const refreshed = await refreshFixtureMetadata( subject.workflow, subject.accepted[0].entity.id, @@ -9794,6 +9943,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const refreshed = await refreshFixtureMetadata( subject.workflow, subject.accepted[0].entity.id, @@ -9815,6 +9965,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const refreshed = await refreshFixtureMetadata( subject.workflow, subject.accepted[0].entity.id, @@ -9845,6 +9996,7 @@ describe("Asset Refresh acceptance", () => { expect(subject.accepted).toHaveLength(1); const stored = [...subject.changePreviews.values()][0]; const current = subject.accepted[0]; + assert.isDefined(current); const oldCoverId = current.artifactAssociations.find( (association) => association.role === "asset.primary-cover", )?.artifactId; @@ -9862,7 +10014,8 @@ describe("Asset Refresh acceptance", () => { metadata: [{ property: "asset.title", value: "Other Work" }], }, }); - expect(stored.projected.primaryCoverArtifactId).toBeUndefined(); + assert.isDefined(stored); + expect(stored.projected).not.toHaveProperty("primaryCoverArtifactId"); expect( stored.projected.artifactAssociations.filter( (association) => association.role === "asset.primary-cover", @@ -9900,6 +10053,7 @@ describe("Asset Refresh acceptance", () => { }), ).resolves.toMatchObject({ status: "applied" }); const applied = subject.accepted[0]; + assert.isDefined(applied); const appliedCoverId = applied.artifactAssociations.find( ({ role }) => role === "asset.primary-cover", )?.artifactId; @@ -10005,6 +10159,7 @@ describe("Asset Refresh acceptance", () => { directory: "/observed/work", acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const prepared = await subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, sourceIdentity: "source", @@ -10043,6 +10198,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); const current = subject.accepted[0]; + assert.isDefined(current); const preview = await subject.workflow.prepareRecognitionRetraction({ assetId: current.entity.id, predecessorDecisionId: current.recognitionDecision.id, @@ -10071,6 +10227,7 @@ describe("Asset Refresh acceptance", () => { }); const current = subject.accepted[0]; const staleArtifact = { id: "stale-source-artifact" }; + assert.isDefined(current); const staleAssociation = { id: "stale-source-artifact-association", // ArtifactAssociation 现要求 target: DescriptionTarget,不再是 subjectId @@ -10145,6 +10302,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const prepared = await subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, sourceIdentity: "source", @@ -10234,6 +10392,7 @@ describe("Asset Refresh acceptance", () => { directory: "/observed/work", acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const prepared = await subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, sourceIdentity: "source", @@ -10252,6 +10411,7 @@ describe("Asset Refresh acceptance", () => { expect(generic?.execution).not.toHaveProperty("sourceAssociationDecision"); expect(JSON.stringify(generic)).not.toContain("interpretation"); + assert.isDefined(prepared.candidates[1]); const selected = await subject.workflow.selectSourceAssociationCandidate({ executionId: prepared.executionId, candidateReference: prepared.candidates[1].reference, @@ -10288,6 +10448,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const prepared = await subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, sourceIdentity: "source", @@ -10322,6 +10483,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); await expect( subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, @@ -10351,6 +10513,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); await expect( subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, @@ -10370,6 +10533,7 @@ describe("Asset Refresh acceptance", () => { directory: "/observed/work", acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const first = await subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, sourceIdentity: "source", @@ -10401,6 +10565,7 @@ describe("Asset Refresh acceptance", () => { const callsBefore = subject.collectionCalls(); const shutdown = new Error("host is closing"); + assert.isDefined(subject.accepted[0]); await expect( subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, @@ -10439,6 +10604,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); armed = true; + assert.isDefined(subject.accepted[0]); const preparing = subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, sourceIdentity: "source", @@ -10496,6 +10662,7 @@ describe("Asset Refresh acceptance", () => { acceptTrusted: true, }); + assert.isDefined(subject.accepted[0]); const result = await subject.workflow.prepareConflictedEstablishment({ assetId: subject.accepted[0].entity.id, sourceIdentity: "source", @@ -10916,8 +11083,12 @@ describe("Asset Refresh acceptance", () => { }, }, automationCommands: { - async setAutomationIntentEnabled(id, enabled, reason) { - disabled.push({ id, reason }); + async setAutomationIntentEnabled( + id: string, + enabled: boolean, + reason?: AutomationDisabledReason, + ) { + disabled.push({ id, ...(reason === undefined ? {} : { reason }) }); (intent as { enabled: boolean }).enabled = enabled; }, async releaseAutomationTrigger() {}, @@ -11000,7 +11171,11 @@ describe("Asset Refresh acceptance", () => { }, }, automationCommands: { - async setAutomationIntentEnabled(_id, enabled, reason) { + async setAutomationIntentEnabled( + _id: string, + enabled: boolean, + reason?: AutomationDisabledReason, + ) { disabled.push(reason ?? ""); (intent as { enabled: boolean }).enabled = enabled; }, @@ -11404,7 +11579,9 @@ describe("Asset Refresh acceptance", () => { root, }; }, - async closeBoundScope(scope) { + async closeBoundScope( + scope: Parameters[0], + ) { await filesystem.closeScope(scope); }, }, @@ -11650,14 +11827,17 @@ describe("Asset Refresh acceptance", () => { ).rejects.toThrow("simulated process interruption"); const durable = subject.persisted.get(executionId)!; + const { + requestedSelection: _requestedSelection, + resolvedSelection: _resolvedSelection, + ...legacyExecution + } = durable.execution; subject.persisted.set(executionId, { ...durable, execution: { - ...durable.execution, + ...legacyExecution, // 这是旧版持久解码器可为原 `{ root }` 选择生成的唯一结构:保留历史目录证据, // 但不提供可执行文件系统范围。 - requestedSelection: undefined, - resolvedSelection: undefined, }, }); @@ -11690,7 +11870,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId, - acceptTrusted: true, }); expect(resumed.execution).toMatchObject({ id: executionId, @@ -11732,12 +11911,12 @@ describe("Asset Refresh acceptance", () => { }), ).rejects.toThrow("freeze ordinary recovery checkpoint"); const durable = subject.persisted.get(executionId)!; + const { workItems: _workItems, ...legacyExecution } = durable.execution; subject.persisted.set(executionId, { ...durable, execution: { - ...durable.execution, + ...legacyExecution, stage: "recognition", - workItems: undefined, sourceRecordIds: [], dispositions: [ ...durable.execution.dispositions.filter( @@ -11824,7 +12003,6 @@ describe("Asset Refresh acceptance", () => { const resumed = await subject.workflow.resume({ executionId, - acceptTrusted: true, }); expect(resumed.execution.outcome).toBe("failed"); @@ -11853,14 +12031,17 @@ describe("Asset Refresh acceptance", () => { const durable = subject.persisted.get(initial.execution.id)!; // 这是接受识别与开始可选候选规划之间的精确持久接受状态。新进程必须以终态记录 // 候选项失败,而不是让本次执行保持可复用。 + const { + outcome: _outcome, + reason: _reason, + ...runningExecution + } = durable.execution; subject.persisted.set(initial.execution.id, { ...durable, execution: { - ...durable.execution, + ...runningExecution, status: "running", stage: "source-artifacts-stored", - outcome: undefined, - reason: undefined, }, }); candidate.enabled = true; @@ -11868,7 +12049,6 @@ describe("Asset Refresh acceptance", () => { const failed = await subject.workflow.resume({ executionId: initial.execution.id, - acceptTrusted: true, }); expect(failed.execution).toMatchObject({ @@ -11884,7 +12064,6 @@ describe("Asset Refresh acceptance", () => { await expect( subject.workflow.resume({ executionId: initial.execution.id, - acceptTrusted: true, }), ).rejects.toThrow("只有未完成的刷新记录可以继续"); }); @@ -11946,7 +12125,7 @@ describe("Asset Refresh acceptance", () => { ).rejects.toThrow("simulated process interruption"); await expect( - subject.workflow.resume({ executionId, acceptTrusted: true }), + subject.workflow.resume({ executionId }), ).resolves.toMatchObject({ execution: { status: "completed", outcome: "proposal" }, }); @@ -11986,14 +12165,16 @@ describe("Asset Refresh acceptance", () => { "simulated crash valid-input-version", ); const corrupt = subject.persisted.get("unknown-input-version")!; + assert.isDefined(corrupt.execution.workflowInput); subject.persisted.set("unknown-input-version", { ...corrupt, execution: { ...corrupt.execution, - workflowInput: { - ...corrupt.execution.workflowInput!, - version: "asset-refresh-input/999" as "asset-refresh-input/1", - }, + workflowInput: Object.defineProperty( + { ...corrupt.execution.workflowInput }, + "version", + { value: "asset-refresh-input/999", enumerable: true }, + ), }, }); @@ -12055,12 +12236,11 @@ describe("Asset Refresh acceptance", () => { const winner = subject.workflow.resume({ executionId, - acceptTrusted: true, }); await firstEntered; - await expect( - subject.workflow.resume({ executionId, acceptTrusted: true }), - ).rejects.toThrow("刷新记录正在由另一个进程继续"); + await expect(subject.workflow.resume({ executionId })).rejects.toThrow( + "刷新记录正在由另一个进程继续", + ); expect(subject.acquisitionCalls()).toBe(1); release(); await expect(winner).resolves.toMatchObject({ @@ -12176,16 +12356,15 @@ describe("Asset Refresh acceptance", () => { const winner = subject.workflow.resume({ executionId, - acceptTrusted: true, }); await firstEntered; now = new Date("2026-07-29T00:00:20.000Z"); heartbeat?.(); await Promise.resolve(); now = new Date("2026-07-29T00:00:35.000Z"); - await expect( - subject.workflow.resume({ executionId, acceptTrusted: true }), - ).rejects.toThrow("刷新记录正在由另一个进程继续"); + await expect(subject.workflow.resume({ executionId })).rejects.toThrow( + "刷新记录正在由另一个进程继续", + ); release(); await expect(winner).resolves.toMatchObject({ execution: { outcome: "accepted" }, @@ -12310,7 +12489,6 @@ describe("Asset Refresh acceptance", () => { await expect( subject.workflow.resume({ executionId: failed.execution.id, - acceptTrusted: true, }), ).rejects.toThrow("只有未完成的刷新记录可以继续"); }); @@ -12676,6 +12854,7 @@ describe("Automation host", () => { return undefined; }, async triggerDueAutomation(current) { + assert.isDefined(current); calls.push(`due:${current.toISOString()}`); return []; }, @@ -12751,7 +12930,7 @@ describe("Automation host", () => { receivedLifecycle = lifecycle !== undefined; entered(); await barrier; - lifecycle?.onCheckpoint(); + lifecycle?.onCheckpoint?.(); continuedAfterCheckpoint = true; return []; }, @@ -12838,7 +13017,7 @@ describe("Automation host", () => { async continueRunningExecution(_executionId, lifecycle) { entered(); await barrier; - lifecycle?.onCheckpoint(); + lifecycle?.onCheckpoint?.(); continuedAfterCheckpoint = true; return undefined; }, diff --git a/packages/application/test/information-fulfillment.test.ts b/packages/application/test/information-fulfillment.test.ts index ad7127e..6f84fe9 100644 --- a/packages/application/test/information-fulfillment.test.ts +++ b/packages/application/test/information-fulfillment.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { InformationFulfillment, type InformationRequirement, + type InformationSubjectFacts, } from "../src/index.ts"; const titleRequirement: InformationRequirement = { @@ -17,7 +18,7 @@ describe("Information Fulfillment", () => { const starts: unknown[] = []; const fulfillment = new InformationFulfillment({ async readSubjects({ subjectIds }) { - return subjectIds.flatMap((subjectId) => { + return subjectIds.flatMap((subjectId) => { if (subjectId === "available") { return [ { @@ -66,6 +67,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "source-a.refresh", + capabilityId: "source-a.refresh", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-refresh", interpretationContractVersions: ["1"], @@ -79,6 +83,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "source-b.refresh", + capabilityId: "source-b.refresh", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-refresh", interpretationContractVersions: ["1"], @@ -159,6 +166,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "permitted.enrichment", + capabilityId: "permitted.enrichment", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-enrichment", interpretationContractVersions: ["1"], @@ -172,6 +182,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "unrelated.enrichment", + capabilityId: "unrelated.enrichment", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-enrichment", interpretationContractVersions: ["1"], @@ -240,6 +253,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "source.refresh", + capabilityId: "source.refresh", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-refresh", interpretationContractVersions: ["1"], @@ -328,6 +344,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "source.refresh", + capabilityId: "source.refresh", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-refresh", interpretationContractVersions: ["1"], @@ -382,6 +401,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "source.refresh", + capabilityId: "source.refresh", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-refresh", interpretationContractVersions: ["1"], @@ -474,6 +496,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "source.artifact", + capabilityId: "source.artifact", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-artifact-acquirer", interpretationContractVersions: ["1"], @@ -552,6 +577,9 @@ describe("Information Fulfillment", () => { { declaration: { identity: "source.refresh", + capabilityId: "source.refresh", + capabilityVersion: "1", + contractVersion: "1", implementationVersion: "1", kind: "source-refresh", interpretationContractVersions: ["1"], diff --git a/packages/application/test/observed-root-execution.test.ts b/packages/application/test/observed-root-execution.test.ts index 23f20d8..552956a 100644 --- a/packages/application/test/observed-root-execution.test.ts +++ b/packages/application/test/observed-root-execution.test.ts @@ -25,13 +25,27 @@ describe("Observed Root Execution", () => { it("owns registered resolution, frozen selection, invocation, and descriptor cleanup", async () => { const events: string[] = []; const filesystem = new InMemoryObservedFilesystemCapability({ - onBind: () => events.push("bind"), - onClose: () => events.push("close"), + onBind: () => { + events.push("bind"); + }, + onClose: () => { + events.push("close"); + }, }); const registrations: Pick< ObservedRootRegistrations, - "resolveActiveScope" | "closeBoundScope" | "reportCleanupFailure" + | "resolveActiveScope" + | "rebindFrozenScope" + | "closeBoundScope" + | "reportCleanupFailure" + | "bindDirectoryOperation" > = { + async rebindFrozenScope() { + throw new Error("本用例不应重绑冻结范围"); + }, + bindDirectoryOperation() { + throw new Error("本用例不应绑定库目录操作"); + }, async resolveActiveScope({ registrationId, relativeScope }) { events.push("resolve"); const observedRoot = root(registrationId); @@ -89,8 +103,18 @@ describe("Observed Root Execution", () => { let closed = 0; const registrations: Pick< ObservedRootRegistrations, - "resolveActiveScope" | "closeBoundScope" | "reportCleanupFailure" + | "resolveActiveScope" + | "rebindFrozenScope" + | "closeBoundScope" + | "reportCleanupFailure" + | "bindDirectoryOperation" > = { + async rebindFrozenScope() { + throw new Error("本用例不应重绑冻结范围"); + }, + bindDirectoryOperation() { + throw new Error("本用例不应绑定库目录操作"); + }, async resolveActiveScope({ registrationId, relativeScope }) { const observedRoot = root(registrationId); return { @@ -131,8 +155,18 @@ describe("Observed Root Execution", () => { const filesystem = new InMemoryObservedFilesystemCapability(); const registrations: Pick< ObservedRootRegistrations, - "resolveActiveScope" | "closeBoundScope" | "reportCleanupFailure" + | "resolveActiveScope" + | "rebindFrozenScope" + | "closeBoundScope" + | "reportCleanupFailure" + | "bindDirectoryOperation" > = { + async rebindFrozenScope() { + throw new Error("本用例不应重绑冻结范围"); + }, + bindDirectoryOperation() { + throw new Error("本用例不应绑定库目录操作"); + }, async resolveActiveScope({ registrationId, relativeScope }) { const observedRoot = root(registrationId); const scope = await filesystem.bindScope({ @@ -235,7 +269,11 @@ describe("Observed Root Execution", () => { }, } as unknown as Pick< ObservedRootRegistrations, - "resolveActiveScope" | "closeBoundScope" | "reportCleanupFailure" + | "resolveActiveScope" + | "rebindFrozenScope" + | "closeBoundScope" + | "reportCleanupFailure" + | "bindDirectoryOperation" >; const execution = new ObservedRootExecution(registrations); @@ -312,7 +350,11 @@ describe("Observed Root Execution", () => { }, } as unknown as Pick< ObservedRootRegistrations, - "resolveActiveScope" | "closeBoundScope" | "reportCleanupFailure" + | "resolveActiveScope" + | "rebindFrozenScope" + | "closeBoundScope" + | "reportCleanupFailure" + | "bindDirectoryOperation" >; const execution = new ObservedRootExecution(registrations); @@ -331,8 +373,18 @@ describe("Observed Root Execution", () => { const filesystem = new InMemoryObservedFilesystemCapability(); const registrations: Pick< ObservedRootRegistrations, - "resolveActiveScope" | "closeBoundScope" | "reportCleanupFailure" + | "resolveActiveScope" + | "rebindFrozenScope" + | "closeBoundScope" + | "reportCleanupFailure" + | "bindDirectoryOperation" > = { + async rebindFrozenScope() { + throw new Error("本用例不应重绑冻结范围"); + }, + bindDirectoryOperation() { + throw new Error("本用例不应绑定库目录操作"); + }, async resolveActiveScope({ registrationId, relativeScope }) { const observedRoot = root(registrationId); return { diff --git a/packages/application/test/observed-root-registrations.test.ts b/packages/application/test/observed-root-registrations.test.ts index 9ce6ba0..13b1e98 100644 --- a/packages/application/test/observed-root-registrations.test.ts +++ b/packages/application/test/observed-root-registrations.test.ts @@ -2,12 +2,13 @@ import { InMemoryObservedFilesystemCapability, InMemoryObservedRootRegistrationStore, } from "@digital/test-support"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { ObservedRootConfigurationConflict, ObservedRootRegistrations, type ObservedRootRegistration, + type ExecutionHost, } from "../src/index.ts"; const localLibraryId = "library-local"; @@ -43,12 +44,10 @@ function registrationsFor( readonly reportCleanupReportingFailure?: ( value: unknown, ) => void | Promise; - readonly operationSupervisor?: { - invalidateObservedRoot(input: { - readonly registrationId: string; - readonly keepVersion?: number; - }): void; - }; + readonly operationSupervisor?: Pick< + ExecutionHost, + "invalidateObservedRoot" | "invalidateLibraryDirectory" + >; readonly store?: InMemoryObservedRootRegistrationStore; } = {}, ): { @@ -108,6 +107,9 @@ describe("Observed Root Registrations", () => { new InMemoryObservedFilesystemCapability(), { operationSupervisor: { + invalidateLibraryDirectory() { + throw new Error("撤销登记不应失效库目录"); + }, invalidateObservedRoot(input) { invalidated.push(input); }, @@ -548,6 +550,7 @@ describe("Observed Root Registrations", () => { expect(before.directoryAuthorityVersion).toBe(1); const configVersion = store.libraryLedger.read(localLibraryId)?.configVersion; + assert.isDefined(configVersion); const receipt = store.authorization.saveLibrary({ libraryId: localLibraryId, expectedConfigVersion: configVersion, @@ -700,11 +703,12 @@ describe("Observed Root Registrations", () => { }, ); const current = store.libraryLedger.read(localLibraryId); + assert.isDefined(current); expect( store.authorization.saveLibrary({ libraryId: localLibraryId, - expectedConfigVersion: (current?.configVersion ?? 0) + 5, + expectedConfigVersion: (current.configVersion ?? 0) + 5, directories: [ { observedRootId: "registered-root", @@ -727,10 +731,11 @@ describe("Observed Root Registrations", () => { observedRootId: "registered-root", }); const removed = store.libraryLedger.read(localLibraryId); + assert.isDefined(removed); expect( store.authorization.saveLibrary({ libraryId: localLibraryId, - expectedConfigVersion: current?.configVersion, + expectedConfigVersion: current.configVersion, directories: [ { observedRootId: "retained-root", symlinkMode: "follow-file-links" }, ], @@ -755,15 +760,16 @@ describe("Observed Root Registrations", () => { new InMemoryObservedFilesystemCapability(), ); const current = store.libraryLedger.read(localLibraryId); + assert.isDefined(current); expect( store.authorization.saveLibrary({ libraryId: localLibraryId, - expectedConfigVersion: current?.configVersion, + expectedConfigVersion: current.configVersion, directories: [], }), ).toEqual({ - configVersion: (current?.configVersion ?? 0) + 1, + configVersion: (current.configVersion ?? 0) + 1, invalidations: [ { assetLibraryId: localLibraryId, observedRootId: "registered-root" }, ], @@ -801,10 +807,11 @@ describe("Observed Root Registrations", () => { }); // 普通保存不能代替点名重连,也不能让原绑定范围沿旧授权重新取得访问。 const removed = store.libraryLedger.read(localLibraryId); + assert.isDefined(removed); expect(() => store.authorization.saveLibrary({ libraryId: localLibraryId, - expectedConfigVersion: removed?.configVersion, + expectedConfigVersion: removed.configVersion, directories: [ { observedRootId: "registered-root", diff --git a/packages/application/test/playback-plan.test.ts b/packages/application/test/playback-plan.test.ts index fefb37a..deacc7c 100644 --- a/packages/application/test/playback-plan.test.ts +++ b/packages/application/test/playback-plan.test.ts @@ -1,10 +1,9 @@ import { createHash } from "node:crypto"; -import { expect, it, vi } from "vitest"; +import { assert, expect, it, vi } from "vitest"; import { ApplicationArtifactByteAccess, - ArtifactByteAccessError, ExecutionHost, planDirectPlayback, type ArtifactByteSource, @@ -268,7 +267,7 @@ it("拒绝超过单次 probe 预算并关闭 describe 短租约", async () => { }, {}, ), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-integrity-failed", }); expect(closes).toBe(1); @@ -370,7 +369,7 @@ it("第五个不同的 1MiB proof frame 在打开前 fail closed", async () => { }, {}, ), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-integrity-failed", }); expect(opens).toBe(5); @@ -449,7 +448,9 @@ it("缓存失败窗口、关闭其短租约且不把重试放大为第二次 pro sourceOpens += 1; if (sourceOpens === 2) { const changed = bytes.slice(); - changed[0] ^= 0xff; + const firstByte = changed[0]; + assert.isDefined(firstByte); + changed[0] = firstByte ^ 0xff; source = byteSource(changed, () => (sourceCloses += 1)); } return { source, suggestedFilename: "track.bin" }; @@ -460,7 +461,7 @@ it("缓存失败窗口、关闭其短租约且不把重试放大为第二次 pro for (let attempt = 0; attempt < 2; attempt += 1) await expect( input.bytes.read({ startInclusive: 0, endInclusive: 3 }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-integrity-failed", }); return undefined; @@ -500,7 +501,7 @@ it("窗口读取仍拒绝检查器并发 read", async () => { const first = input.bytes.read({ startInclusive: 0, endInclusive: 3 }); await expect( input.bytes.read({ startInclusive: 4, endInclusive: 7 }), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "byte-read-in-progress", }); await first; @@ -546,7 +547,9 @@ it("初始观察根读取失败后释放绑定并回退到另一观察根", asyn }); if (registrationId === "root-a" && rootAOpens === 2) { const changed = bytes.slice(); - changed[0] ^= 0xff; + const firstByte = changed[0]; + assert.isDefined(firstByte); + changed[0] = firstByte ^ 0xff; source = byteSource(changed, () => (closes += 1)); } return { @@ -603,7 +606,9 @@ it("观察源关闭失败时保留其绑定且不把下一根资源交给计划" rootAOpens += 1; if (rootAOpens === 2) { const changed = bytes.slice(); - changed[0] ^= 0xff; + const firstByte = changed[0]; + assert.isDefined(firstByte); + changed[0] = firstByte ^ 0xff; source = { ...byteSource(changed, () => undefined), async close() { @@ -640,7 +645,7 @@ it("观察源关闭失败时保留其绑定且不把下一根资源交给计划" }, {}, ), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: "artifact-integrity-failed", }); expect(rootBReturned).toBe(false); @@ -737,7 +742,7 @@ it.each(["revoke", "shutdown"] as const)( }, {}, ), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ code: event === "revoke" ? "artifact-location-revoked" diff --git a/packages/application/test/source-artifact-work.test.ts b/packages/application/test/source-artifact-work.test.ts index 462a3d2..c439b75 100644 --- a/packages/application/test/source-artifact-work.test.ts +++ b/packages/application/test/source-artifact-work.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { CheckpointedSourceArtifactWork, @@ -27,6 +27,7 @@ describe("checkpointed source-artifact work", () => { throw new Error("checkpointed storage should be used"); }, async stage(content) { + assert.isDefined(content.role); return { candidateId: content.candidateId, sourceRecordId: content.sourceRecordId, @@ -59,6 +60,7 @@ describe("checkpointed source-artifact work", () => { }; const proposal = { id: "proposal", + boundaries: [], basis: { kind: "source-interpretation" as const, interpretation: { @@ -149,6 +151,7 @@ describe("checkpointed source-artifact work", () => { }; const proposal = { id: "proposal", + boundaries: [], basis: { kind: "source-interpretation" as const, interpretation: { @@ -186,6 +189,7 @@ describe("checkpointed source-artifact work", () => { throw new Error("not used"); }, async stage(content) { + assert.isDefined(content.role); return { candidateId: content.candidateId, sourceRecordId: content.sourceRecordId, diff --git a/packages/application/test/tsconfig.json b/packages/application/test/tsconfig.json deleted file mode 100644 index e516b7f..0000000 --- a/packages/application/test/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["**/*.ts"] -} diff --git a/packages/application/tsconfig.json b/packages/application/tsconfig.json index 5720c8a..5c1a48e 100644 --- a/packages/application/tsconfig.json +++ b/packages/application/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts", "type-test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "type-test/**/*.ts"] } diff --git a/packages/application/turbo.json b/packages/application/turbo.json index c9a0ad1..718a9a8 100644 --- a/packages/application/turbo.json +++ b/packages/application/turbo.json @@ -1,4 +1,64 @@ { "extends": ["//"], - "tags": ["application"] + "tags": ["application"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } } diff --git a/packages/cli-delivery/package.json b/packages/cli-delivery/package.json index 92379e8..002f5b9 100644 --- a/packages/cli-delivery/package.json +++ b/packages/cli-delivery/package.json @@ -22,7 +22,8 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "test": "vitest run --reporter=verbose --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/cli-delivery", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/packages/cli-delivery/turbo.json b/packages/cli-delivery/turbo.json index 492de3a..e2cb8b3 100644 --- a/packages/cli-delivery/turbo.json +++ b/packages/cli-delivery/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["delivery"] } +{ + "extends": ["//"], + "tags": ["delivery"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/composition/package.json b/packages/composition/package.json index 627d5b9..4ad39a3 100644 --- a/packages/composition/package.json +++ b/packages/composition/package.json @@ -26,9 +26,11 @@ "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules .", "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "test": "vitest run --reporter=verbose", + "test": "vitest run --exclude '**/product-operation-lease.test.ts' --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/composition", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo", + "test:process": "vitest run test/product-operation-lease.test.ts --reporter=verbose" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/packages/composition/test/bangumi-source-connection-concurrency.test.ts b/packages/composition/test/bangumi-source-connection-concurrency.test.ts index b696d9e..481d1c6 100644 --- a/packages/composition/test/bangumi-source-connection-concurrency.test.ts +++ b/packages/composition/test/bangumi-source-connection-concurrency.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { ProductBangumiSourceConnection } from "@digital/bangumi"; import { ProductSourceConnectionPrivateState } from "@digital/product-data"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { assert, afterEach, describe, expect, it, vi } from "vitest"; const temporaryRoots: string[] = []; const environment = { @@ -36,7 +36,8 @@ describe("Bangumi OAuth refresh across product processes", () => { let monotonicNow = 1_000; let authorizationRequests = 0; const request = vi.fn(async (_input: string, init?: RequestInit) => { - expect(new URLSearchParams(init?.body).get("grant_type")).toBe( + assert(typeof init?.body === "string"); + expect(new URLSearchParams(init.body).get("grant_type")).toBe( "authorization_code", ); authorizationRequests += 1; @@ -151,7 +152,8 @@ describe("Bangumi OAuth refresh across product processes", () => { }, ); const request = vi.fn(async (_input: string, init?: RequestInit) => { - const parameters = new URLSearchParams(init?.body); + assert(typeof init?.body === "string"); + const parameters = new URLSearchParams(init.body); if (parameters.get("grant_type") === "authorization_code") { return jsonResponse({ access_token: "initial-access", @@ -243,7 +245,8 @@ describe("Bangumi OAuth refresh across product processes", () => { finishFailedRequest = resolve; }); const request = vi.fn(async (_input: string, init?: RequestInit) => { - const parameters = new URLSearchParams(init?.body); + assert(typeof init?.body === "string"); + const parameters = new URLSearchParams(init.body); if (parameters.get("grant_type") === "authorization_code") { return jsonResponse({ access_token: "initial-access", @@ -311,7 +314,8 @@ describe("Bangumi OAuth refresh across product processes", () => { let currentTime = new Date("2026-08-12T00:00:00.000Z"); let refreshRequests = 0; const request = vi.fn(async (_input: string, init?: RequestInit) => { - const parameters = new URLSearchParams(init?.body); + assert(typeof init?.body === "string"); + const parameters = new URLSearchParams(init.body); if (parameters.get("grant_type") === "authorization_code") { return jsonResponse({ access_token: "initial-access", @@ -363,7 +367,8 @@ describe("Bangumi OAuth refresh across product processes", () => { let authorizationRequests = 0; let refreshRequests = 0; const request = vi.fn(async (_input: string, init?: RequestInit) => { - const parameters = new URLSearchParams(init?.body); + assert(typeof init?.body === "string"); + const parameters = new URLSearchParams(init.body); if (parameters.get("grant_type") === "refresh_token") { refreshRequests += 1; return await oldRefresh; @@ -443,7 +448,8 @@ describe("Bangumi OAuth refresh across product processes", () => { let authorizationRequests = 0; let refreshRequests = 0; const request = vi.fn(async (_input: string, init?: RequestInit) => { - const parameters = new URLSearchParams(init?.body); + assert(typeof init?.body === "string"); + const parameters = new URLSearchParams(init.body); if (parameters.get("grant_type") === "refresh_token") { refreshRequests += 1; return await oldRefresh; diff --git a/packages/composition/test/bangumi-source-connection-crash-safety.test.ts b/packages/composition/test/bangumi-source-connection-crash-safety.test.ts index 6b3374a..9888254 100644 --- a/packages/composition/test/bangumi-source-connection-crash-safety.test.ts +++ b/packages/composition/test/bangumi-source-connection-crash-safety.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { ProductBangumiSourceConnection } from "@digital/bangumi"; import { ProductSourceConnectionPrivateState } from "@digital/product-data"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { assert, afterEach, describe, expect, it, vi } from "vitest"; const temporaryRoots: string[] = []; const environment = { @@ -31,7 +31,8 @@ describe("Bangumi OAuth immutable private lease namespaces", () => { let authorizationRequests = 0; let refreshRequests = 0; const request = vi.fn(async (_input: string, init?: RequestInit) => { - const parameters = new URLSearchParams(init?.body); + assert(typeof init?.body === "string"); + const parameters = new URLSearchParams(init.body); if (parameters.get("grant_type") === "authorization_code") { authorizationRequests += 1; return response({ @@ -137,7 +138,8 @@ describe("Bangumi OAuth immutable private lease namespaces", () => { let currentTime = new Date("2026-08-12T00:00:00.000Z"); let refreshRequests = 0; const request = vi.fn(async (_input: string, init?: RequestInit) => { - const parameters = new URLSearchParams(init?.body); + assert(typeof init?.body === "string"); + const parameters = new URLSearchParams(init.body); if (parameters.get("grant_type") === "authorization_code") { return response({ access_token: "initial-access", @@ -191,7 +193,8 @@ describe("Bangumi OAuth immutable private lease namespaces", () => { productDataDirectory, now: () => currentTime, request: async (_input, init) => { - const grantType = new URLSearchParams(init?.body).get("grant_type"); + assert(typeof init?.body === "string"); + const grantType = new URLSearchParams(init.body).get("grant_type"); return response( grantType === "authorization_code" ? { diff --git a/packages/composition/test/drizzle-recognition-adoption.test.ts b/packages/composition/test/drizzle-recognition-adoption.test.ts index 3a2465b..94c38e9 100644 --- a/packages/composition/test/drizzle-recognition-adoption.test.ts +++ b/packages/composition/test/drizzle-recognition-adoption.test.ts @@ -39,7 +39,7 @@ import { TmdbSourceInterpreter, TmdbStrictFilmRecognizer, } from "@digital/tmdb"; -import { afterAll, afterEach, expect, it, vi } from "vitest"; +import { assert, afterAll, afterEach, expect, it, vi } from "vitest"; import { runProduct } from "../src/index.ts"; import { composeAssetRefresh } from "../src/runtime.ts"; @@ -130,7 +130,7 @@ function instrumentedObservedRootRegistrations( ["RJ01668691", "/observed/RJ01668691"], ["same-title-a", "/observed/same-title-a"], ["same-title-b", "/observed/same-title-b"], - ]; + ] as const; const store = new InMemoryObservedRootRegistrationStore( directories.map(([id, logicalDirectory]) => @@ -473,6 +473,8 @@ it("reopens and enumerates an observed-evidence recognition without a source ass policyVersion: "local-policy/1", capabilityCoordinates: ["epub-inspector@1/epub/3"], }; + assert.isDefined(observed.execution.workflowInput); + assert.isDefined(observed.execution.resolvedSelection); const running = { ...runningExecution(), id: "local-execution", @@ -497,8 +499,7 @@ it("reopens and enumerates an observed-evidence recognition without a source ass { key: "local-book", relativePaths: ["book.epub"], - stage: "completed", - status: "completed", + sourceRecordIds: [], outcome: "accepted", proposalId: proposal.id, acceptedAssetId: accepted.entity.id, @@ -622,6 +623,9 @@ it("reopens and enumerates an observed-evidence recognition without a source ass evidence: [nextEvidence], })), }; + assert.isDefined(nextObservation.execution.snapshot); + assert.isDefined(nextObservation.execution.workflowInput); + assert.isDefined(nextObservation.execution.resolvedSelection); const reevaluation = { ...runningExecution(), id: "local-reevaluation", @@ -900,6 +904,7 @@ it("reopens and enumerates an observed-evidence recognition without a source ass const successorDecision = { id: "local-decision-3", proposalId: replacementProposal.id, + boundaries: replacementProposal.boundaries, assetId: accepted.entity.id, acceptedRuleResults: replacementProposal.ruleResults, }; @@ -1071,6 +1076,7 @@ it("uses only Drizzle queries while a real OMDb source result advances one persi interpretation: candidate.interpretation, }); + assert.isDefined(staged.changeSets[0]); await expect( catalogue.applySourceRefreshChangeSet({ runId: run.id, @@ -1133,7 +1139,6 @@ it("uses only Drizzle writes through the public Metadata Refresh persistence por subjectId: "omdb-film", sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: "omdb-initial-proposal", }, coverageIds: [candidate.record.coverage.id], }); @@ -1150,7 +1155,6 @@ it("uses only Drizzle writes through the public Metadata Refresh persistence por subjectId: "omdb-film", sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: "omdb-initial-proposal", }, sourceRecords: [candidate.record], interpretation: candidate.interpretation, @@ -1191,7 +1195,6 @@ it("uses only Drizzle writes through the public Metadata Refresh persistence por subjectId: "omdb-film", sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: "omdb-initial-proposal", }, coverageIds: [foreignRecord.record.coverage.id], }); @@ -1207,7 +1210,6 @@ it("uses only Drizzle writes through the public Metadata Refresh persistence por subjectId: "omdb-film", sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: "omdb-initial-proposal", }, sourceRecords: [foreignRecord.record], interpretation: foreignRecord.interpretation, @@ -1280,6 +1282,7 @@ it("preflights every Metadata Refresh batch failure before SQLite can write", as const factsBefore = adoptionFactCounts(root); rejectAllMetadataRefreshDml(); + assert.isDefined(heads[0]); const scenarios: readonly { readonly name: string; readonly sourceRecords: readonly SourceRecord[]; @@ -1733,6 +1736,7 @@ it("uses shipped TMDB collection and interpretation while OMDb enrichment establ rejectDirectDatabaseWrites(); + assert.isDefined(baseline.accepted.recognitionSourceAssociation); await expect( catalogue.applySourceEnrichment({ ...sourceEnrichmentAuthority( @@ -1756,6 +1760,7 @@ it("uses shipped TMDB collection and interpretation while OMDb enrichment establ }), ).resolves.toBeUndefined(); + assert.isDefined(baseline.accepted.recognitionSourceAssociation); await expect( catalogue.findAcceptedForSubjects(["tmdb-film"]), ).resolves.toEqual([ @@ -1892,7 +1897,7 @@ it("uses shipped TMDB collection and interpretation while OMDb enrichment establ ...partialSource.record, coverage: { ...partialSource.record.coverage, - completeness: "partial" as const, + completeness: "incomplete" as const, }, }, }; @@ -1920,6 +1925,7 @@ it("uses shipped TMDB collection and interpretation while OMDb enrichment establ candidateSourceRecordId: partial.record.id, interpretation: partial.interpretation, }); + assert.isDefined(partialStaged.changeSets[0]); await expect( catalogue.applySourceRefreshChangeSet({ runId: partialRun.id, @@ -1974,11 +1980,13 @@ it("uses shipped TMDB collection and interpretation while OMDb enrichment establ expect.objectContaining({ kind: "retracted" }), ]), }); + assert.isDefined(completeStaged.changeSets[0]); await catalogue.reviewSourceRefreshChangeSet({ runId: completeRun.id, changeSetId: completeStaged.changeSets[0].id, disposition: "approved", }); + assert.isDefined(completeStaged.changeSets[0]); await expect( catalogue.applySourceRefreshChangeSet({ runId: completeRun.id, @@ -2114,6 +2122,7 @@ it("reuses an exact durable enrichment association and rejects a replay before f await retainSourceRecords(catalogue, pending.id, [source.record]); rejectDirectDatabaseWrites(); + assert.isDefined(baseline.accepted.recognitionSourceAssociation); const input = { ...sourceEnrichmentAuthority( association.id, @@ -2160,6 +2169,7 @@ it("reuses an exact durable enrichment association and rejects a replay before f await catalogue.createExecution(laterPending); await retainSourceRecords(catalogue, laterPending.id, [laterSource.record]); const factsBeforeLineageConflict = adoptionFactCounts(root); + assert.isDefined(baseline.accepted.recognitionSourceAssociation); await expect( catalogue.applySourceEnrichment({ ...sourceEnrichmentAuthority( @@ -2262,6 +2272,7 @@ it("rejects an enrichment identity conflict when another current subject already const factsBefore = adoptionFactCounts(root); rejectAllMetadataRefreshDml(); + assert.isDefined(target.accepted.recognitionSourceAssociation); await expect( catalogue.applySourceEnrichment({ ...sourceEnrichmentAuthority( @@ -2342,10 +2353,11 @@ it("preflights an enrichment batch before creating its optional association", as sourceAssociation: association, stage: "association-interpreted", }); + assert.isDefined(pendingBase.sourceEnrichments); const pending: WorkflowExecution = { ...pendingBase, sourceRecordIds: [first.record.id, second.record.id], - sourceEnrichments: pendingBase.sourceEnrichments?.map((checkpoint) => ({ + sourceEnrichments: pendingBase.sourceEnrichments.map((checkpoint) => ({ ...checkpoint, interpretation, sourceRecordIds: [first.record.id, second.record.id], @@ -2358,10 +2370,11 @@ it("preflights an enrichment batch before creating its optional association", as sourceAssociation: association, stage: "applied", }); + assert.isDefined(appliedBase.sourceEnrichments); const applied: WorkflowExecution = { ...appliedBase, sourceRecordIds: [first.record.id, second.record.id], - sourceEnrichments: appliedBase.sourceEnrichments?.map((checkpoint) => ({ + sourceEnrichments: appliedBase.sourceEnrichments.map((checkpoint) => ({ ...checkpoint, interpretation, sourceRecordIds: [first.record.id, second.record.id], @@ -2375,6 +2388,7 @@ it("preflights an enrichment batch before creating its optional association", as const factsBefore = adoptionFactCounts(root); rejectAllMetadataRefreshDml(); + assert.isDefined(baseline.accepted.recognitionSourceAssociation); await expect( catalogue.applySourceEnrichment({ ...sourceEnrichmentAuthority( @@ -3409,11 +3423,13 @@ it("keeps a terminal Change Impact Preview checkpoint inside Drizzle without dir }, baselines: [], current: { - sourceAssociation: { - sourceIdentity: "dlsite", - nativeAddress: - "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", - }, + sourceAssociations: [ + { + sourceIdentity: "dlsite", + nativeAddress: + "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", + }, + ], metadata: [], entities: [], entityMentions: [], @@ -3426,10 +3442,12 @@ it("keeps a terminal Change Impact Preview checkpoint inside Drizzle without dir artifactAssociations: [], }, projected: { - sourceAssociation: { - sourceIdentity: "dlsite", - nativeAddress: "https://www.dlsite.com/maniax/work/projected", - }, + sourceAssociations: [ + { + sourceIdentity: "dlsite", + nativeAddress: "https://www.dlsite.com/maniax/work/projected", + }, + ], metadata: [], entities: [], entityMentions: [], @@ -3494,6 +3512,7 @@ it("reuses the accepted DLsite-shaped result on a guarded second scan without se expect(first.execution.outcome).toBe("accepted"); const factsBefore = adoptionFactCounts(root); const executionsBefore = executionAndSourceRecordCounts(root); + assert.isDefined(executionsBefore); rejectDirectDatabaseWrites(); const second = await scanInstrumentedObservedScope(workflow, { @@ -3541,7 +3560,9 @@ it("reevaluates a changed ordinary DLsite evidence-array observation without rew }); expect(first.execution.outcome).toBe("accepted"); const initialFacts = adoptionFactCounts(root); + assert.isDefined(initialFacts); const initialExecutions = executionAndSourceRecordCounts(root); + assert.isDefined(initialExecutions); const initialIdentity = recognitionIdentityFacts(root); const initialCoverFacts = primaryCoverFacts(root); expect(initialCoverFacts).toHaveLength(1); @@ -3637,6 +3658,7 @@ it("reevaluates a changed ordinary DLsite evidence-array observation without rew const factsAfterReevaluation = adoptionFactCounts(root); const executionsAfterReevaluation = executionAndSourceRecordCounts(root); + assert.isDefined(executionsAfterReevaluation); const third = await scanInstrumentedObservedScope(workflow, { ...input, executionId: "instrumented-observation-v2-repeat", @@ -4195,7 +4217,7 @@ it("keeps equal title, managed path, and Blob as independent identity facts", as function adoptionFactCounts(root: string) { const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); try { - return database + const row = database .prepare( `SELECT (SELECT count(*) FROM recognition_proposals) AS recognitionProposals, @@ -4222,6 +4244,20 @@ function adoptionFactCounts(root: string) { (SELECT count(*) FROM entity_resolution_decisions) AS entityResolutionDecisions`, ) .get(); + assert.isDefined(row); + assert(typeof row.artifacts === "number"); + assert(typeof row.locations === "number"); + assert(typeof row.artifactAssociations === "number"); + assert(typeof row.artifactRevisions === "number"); + assert(typeof row.blobs === "number"); + return { + ...row, + artifacts: row.artifacts, + locations: row.locations, + artifactAssociations: row.artifactAssociations, + artifactRevisions: row.artifactRevisions, + blobs: row.blobs, + }; } finally { database.close(); } @@ -4312,13 +4348,21 @@ function emptyAdoptionFactCounts() { function executionAndSourceRecordCounts(root: string) { const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); try { - return database + const row = database .prepare( `SELECT (SELECT count(*) FROM workflow_executions) AS executions, (SELECT count(*) FROM source_records) AS sourceRecords`, ) .get(); + assert.isDefined(row); + assert(typeof row.executions === "number"); + assert(typeof row.sourceRecords === "number"); + return { + ...row, + executions: row.executions, + sourceRecords: row.sourceRecords, + }; } finally { database.close(); } @@ -4372,13 +4416,23 @@ function primaryCoverFacts(root: string) { WHERE association.role = 'asset.primary-cover' ORDER BY association.id`, ) - .all() as readonly { - readonly associationId: string; - readonly artifactId: string; - readonly locationId: string; - readonly revisionId: string; - readonly blobDigest: string; - }[]; + .all() + .map( + ({ associationId, artifactId, locationId, revisionId, blobDigest }) => { + assert(typeof associationId === "string"); + assert(typeof artifactId === "string"); + assert(typeof locationId === "string"); + assert(typeof revisionId === "string"); + assert(typeof blobDigest === "string"); + return { + associationId, + artifactId, + locationId, + revisionId, + blobDigest, + }; + }, + ); } finally { database.close(); } @@ -4646,6 +4700,7 @@ function instrumentedRecognitionWorkflow( ], managedStore: { async stage(content) { + assert.isDefined(content.role); const stagedReference = `staged:${content.idempotencyKey}`; stagedBytes.set(stagedReference, content.bytes); return { @@ -4654,7 +4709,7 @@ function instrumentedRecognitionWorkflow( stagedReference, digest: sha256(content.bytes), mediaType: content.mediaType, - ...(content.role === undefined ? {} : { role: content.role }), + role: content.role, ...(content.acquisitionReference === undefined ? {} : { acquisitionReference: content.acquisitionReference }), @@ -4831,7 +4886,7 @@ function compileWithNoMetadataRefreshDml( try { return compile(); } catch (error) { - const violation = state.violation; + const violation = noMetadataRefreshDmlAuthorizerState(database).violation; if (violation !== undefined) { throw new Error( `Metadata Refresh preflight emitted ${String(violation)}: ${source}`, @@ -4934,7 +4989,7 @@ function compileWithDirectWriteAuthorizer( try { return compile(); } catch (error) { - const violation = state.violation; + const violation = directWriteAuthorizerState(database).violation; if (violation !== undefined) { const origin = violation.compilation.firstNonTestFrame ?? "unknown caller"; @@ -5002,6 +5057,11 @@ function firstNonTestFrameIsDrizzle(frame: string | undefined): boolean { function runningExecution(): WorkflowExecution { return { id: "dlsite-execution", + compatibility: { + workflowVersion: "asset-refresh/2", + policyVersion: "instrumented-dlsite-policy/1", + capabilityCoordinates: [], + }, selection: { directory: "/observed/RJ01668691" }, snapshot: { id: "dlsite-snapshot", @@ -5198,9 +5258,7 @@ function omdbExecution(id: string): WorkflowExecution { }; } -function omdbAcceptedRecognition( - source: OmdbSourceResult, -): AcceptedRecognition { +function omdbAcceptedRecognition(source: OmdbSourceResult) { const proposal = { id: "omdb-initial-proposal", boundaries: fixtureMaterialBoundaries(["omdb:imdb:tt1234567"]), @@ -5228,7 +5286,6 @@ function omdbAcceptedRecognition( subjectId: "omdb-film", sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: proposal.id, }, sourceAssociations: [ { @@ -5236,7 +5293,6 @@ function omdbAcceptedRecognition( subjectId: "omdb-film", sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: proposal.id, }, ], recognitionDecision: { @@ -5255,7 +5311,7 @@ function omdbAcceptedRecognition( ...assertion, target: { kind: "entity" as const, entityId: "omdb-film" }, })), - }; + } satisfies AcceptedRecognition; } function omdbAcceptedRecognitionForAsset(input: { @@ -5265,7 +5321,7 @@ function omdbAcceptedRecognitionForAsset(input: { readonly proposalId: string; readonly decisionId: string; readonly secondary?: { readonly id: string; readonly label: string }; -}): AcceptedRecognition { +}) { const secondary = input.secondary === undefined ? [] @@ -5324,7 +5380,6 @@ function omdbAcceptedRecognitionForAsset(input: { subjectId: input.assetId, sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: proposal.id, }, sourceAssociations: [ { @@ -5332,7 +5387,6 @@ function omdbAcceptedRecognitionForAsset(input: { subjectId: input.assetId, sourceIdentity: "omdb", nativeAddress: "omdb:imdb:tt1234567", - proposalId: proposal.id, }, ], recognitionDecision: { @@ -5355,7 +5409,7 @@ function omdbAcceptedRecognitionForAsset(input: { }, })), ...(secondary.length === 0 ? {} : { entities: secondary }), - }; + } satisfies AcceptedRecognition; } async function tmdbAcceptedRecognitionFromShippedAdapter(): Promise<{ @@ -5446,7 +5500,6 @@ async function tmdbAcceptedRecognitionFromShippedAdapter(): Promise<{ subjectId: "tmdb-film", sourceIdentity: interpretation.sourceIdentity, nativeAddress: interpretation.sourceAddress, - proposalId: proposal.id, }, sourceAssociations: [ { @@ -5454,7 +5507,6 @@ async function tmdbAcceptedRecognitionFromShippedAdapter(): Promise<{ subjectId: "tmdb-film", sourceIdentity: interpretation.sourceIdentity, nativeAddress: interpretation.sourceAddress, - proposalId: proposal.id, }, ], recognitionDecision: { @@ -5564,7 +5616,6 @@ function tmdbAcceptedRecognition(): { subjectId: "tmdb-film", sourceIdentity: record.sourceIdentity, nativeAddress: record.nativeAddress, - proposalId: proposal.id, }, sourceAssociations: [ { @@ -5572,7 +5623,6 @@ function tmdbAcceptedRecognition(): { subjectId: "tmdb-film", sourceIdentity: record.sourceIdentity, nativeAddress: record.nativeAddress, - proposalId: proposal.id, }, ], recognitionDecision: { @@ -5605,11 +5655,11 @@ function omdbEnrichmentExecution(input: { readonly subjectId: string; readonly sourceIdentity: string; readonly nativeAddress: string; - readonly proposalId: string; }; readonly stage: "association-interpreted" | "applied"; }): WorkflowExecution { const key = "source-enrichment:tmdb-film:omdb.source-interpreter"; + assert.isDefined(input.baseline.recognitionSourceAssociation); return { ...omdbExecution(input.id), stage: "source-records-retained", @@ -5650,7 +5700,7 @@ function omdbEnrichmentExecution(input: { evidence: omdbEvidence, fulfilledArtifactRoles: [], stage: input.stage, - sourceAssociation: input.recognitionSourceAssociation, + sourceAssociation: input.sourceAssociation, interpretation: input.source.interpretation, sourceRecordIds: [input.source.record.id], sourceArtifacts: input.source.interpretation.sourceArtifacts, @@ -5665,6 +5715,7 @@ function omdbCandidateEnrichmentCheckpoint( SourceEnrichmentCheckpoint, { readonly stage: "candidate-interpreted" } > { + assert.isDefined(baseline.recognitionSourceAssociation); return { key: "source-enrichment:tmdb-film:omdb.source-interpreter", subjectId: baseline.entity.id, @@ -5699,6 +5750,8 @@ function omdbManagedArtifactExecution(input: { subjectId: "omdb-film", sourceRecordId: input.sourceRecordId, }); + assert.isDefined(artifact.locations[0]); + assert.isDefined(artifact.blobs[0]); return { ...omdbExecution(input.id), stage: "source-artifacts-stored", @@ -5811,7 +5864,7 @@ async function createOmdbSourceRefreshRun(input: { }); } -function acceptedRecognition(): AcceptedRecognition { +function acceptedRecognition() { const interpretation = { id: "dlsite-interpretation", producer: { @@ -5889,7 +5942,6 @@ function acceptedRecognition(): AcceptedRecognition { sourceIdentity: "dlsite", nativeAddress: "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", - proposalId: proposal.id, }, sourceAssociations: [ { @@ -5898,7 +5950,6 @@ function acceptedRecognition(): AcceptedRecognition { sourceIdentity: "dlsite", nativeAddress: "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", - proposalId: proposal.id, }, ], recognitionDecision: { @@ -5967,10 +6018,10 @@ function acceptedRecognition(): AcceptedRecognition { sourceRecordIds: ["dlsite-record"], }, ], - }; + } satisfies AcceptedRecognition; } -function continuityConflictRecognition(): AcceptedRecognition { +function continuityConflictRecognition() { const original = acceptedRecognition(); const sourceRecordId = "dlsite-continuity-record"; const proposal = { @@ -6007,7 +6058,6 @@ function continuityConflictRecognition(): AcceptedRecognition { sourceIdentity: "dlsite", nativeAddress: "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", - proposalId: proposal.id, }, sourceAssociations: [ { @@ -6016,7 +6066,6 @@ function continuityConflictRecognition(): AcceptedRecognition { sourceIdentity: "dlsite", nativeAddress: "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", - proposalId: proposal.id, }, ], recognitionDecision: { @@ -6066,10 +6115,10 @@ function continuityConflictRecognition(): AcceptedRecognition { entityMentions: [], entityResolutionDecisions: [], relationships: [], - }; + } satisfies AcceptedRecognition; } -function independentlyAcceptedRecognition(): AcceptedRecognition { +function independentlyAcceptedRecognition() { const alternate = continuityConflictRecognition(); const sourceRecordId = "dlsite-independent-record"; const sourceAddress = @@ -6102,7 +6151,6 @@ function independentlyAcceptedRecognition(): AcceptedRecognition { subjectId: "dlsite-independent-work", sourceIdentity: "dlsite", nativeAddress: sourceAddress, - proposalId: proposal.id, }, sourceAssociations: [ { @@ -6110,7 +6158,6 @@ function independentlyAcceptedRecognition(): AcceptedRecognition { subjectId: "dlsite-independent-work", sourceIdentity: "dlsite", nativeAddress: sourceAddress, - proposalId: proposal.id, }, ], recognitionDecision: { @@ -6148,7 +6195,7 @@ function independentlyAcceptedRecognition(): AcceptedRecognition { ...assertion, target: { kind: "entity" as const, entityId: "dlsite-independent-work" }, })), - }; + } satisfies AcceptedRecognition; } /** 已有内存授权账本对应的只读库配置,不把注册当授权。 */ diff --git a/packages/composition/test/entity-model-migration.test.ts b/packages/composition/test/entity-model-migration.test.ts index 4a76683..8aee605 100644 --- a/packages/composition/test/entity-model-migration.test.ts +++ b/packages/composition/test/entity-model-migration.test.ts @@ -10,7 +10,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { SqliteCatalogue } from "@digital/sqlite"; -import { expect, it } from "vitest"; +import { assert, expect, it } from "vitest"; import { runProduct } from "../src/index.ts"; import { prepareWithFormalMigration } from "./prepared-catalogue.ts"; @@ -40,7 +40,7 @@ type CatalogueRelationshipView = { readonly entityId: string; readonly role: string; }[]; - readonly qualifiers: readonly unknown[]; + readonly qualifiers?: readonly unknown[] | undefined; readonly sources: readonly { readonly sourceIdentity: string; readonly nativeAddress: string; @@ -84,7 +84,9 @@ type EntityE02Manifest = { readonly audioEntity: AudioEntitySnapshot; readonly afterAudioRefresh: AudioEntitySnapshot; readonly unidentifiedAsset: { readonly id: string }; - readonly unidentifiedAssetMetadata: readonly unknown[]; + readonly unidentifiedAssetMetadata: readonly (FrozenMetadataFact & { + readonly contributingAssertions: readonly unknown[]; + })[]; readonly enrichment: { readonly executionId: string; readonly sourceEnrichments: readonly unknown[]; @@ -347,7 +349,8 @@ it("新装配的产品数据不引导人物节点,交互状态仍按稳定用 const accepted = await workflow.confirmLocalContent({ executionId: review.executionId, }); - const [location] = accepted.content.locations as [{ id: string }]; + const [location] = accepted.content.locations; + assert.isDefined(location); const selectionId = await application.observedFileSelectionForLocation( location.id, ); @@ -470,9 +473,9 @@ it("人物与活动身份硬迁移后公开读取保全身份、资料、关系 sourceSlot: expected.sourceSlot, subjectId: manifest.unidentifiedAsset.id, selectedAssertion: expected.selectedAssertion, - contributingAssertions: expect.arrayContaining( - expected.contributingAssertions, - ), + contributingAssertions: expect.arrayContaining([ + ...expected.contributingAssertions, + ]), }); expect( unidentifiedMetadata.find(({ property }) => property === "book.isbn-13"), @@ -864,6 +867,8 @@ it("迁移后的普通实体经正式公开操作贯通预演、合并、合并 }, ], }); + assert.isDefined(manualPreview.changes[0]); + assert(manualPreview.changes[0].kind === "create"); expect( manualPreview.changes[0].relationship!.participants.find( ({ role }) => role === "illustration", diff --git a/packages/composition/test/epub-recognition.test.ts b/packages/composition/test/epub-recognition.test.ts index b2d4771..5b274e4 100644 --- a/packages/composition/test/epub-recognition.test.ts +++ b/packages/composition/test/epub-recognition.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterAll, expect, it, vi } from "vitest"; +import { assert, afterAll, expect, it, vi } from "vitest"; import { composeAssetRefresh } from "../src/runtime.ts"; import { @@ -158,6 +158,7 @@ it("普通组合资产刷新保持 exact ISBN 的官方来源依据", async () = ]), ); const sourceRelationship = contributorFacts!.relationships[0]; + assert.isDefined(sourceRelationship); expect(sourceRelationship.sources).toEqual([ expect.objectContaining({ sourceIdentity: "nbinet" }), ]); diff --git a/packages/composition/test/information-fulfillment.test.ts b/packages/composition/test/information-fulfillment.test.ts index 70b1c12..82ce6ef 100644 --- a/packages/composition/test/information-fulfillment.test.ts +++ b/packages/composition/test/information-fulfillment.test.ts @@ -9,7 +9,7 @@ import { } from "@digital/application"; import { dlsiteInformationFulfillmentCapabilities } from "@digital/dlsite"; import { SqliteCatalogue } from "@digital/sqlite"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { assert, afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { composeAssetRefresh, @@ -113,7 +113,7 @@ describe("information fulfillment composition", () => { const workflowClose = vi.fn(() => drain); const persistence = { close: vi.fn() }; const workflow = composeOwnedAssetRefresh( - { close: workflowClose } as never, + { close: workflowClose }, persistence, ); const closing = workflow.close(); @@ -133,7 +133,7 @@ describe("information fulfillment composition", () => { const workflowClose = vi.fn(() => Promise.reject(terminalizationFailure)); const persistence = { close: vi.fn() }; const workflow = composeOwnedAssetRefresh( - { close: workflowClose } as never, + { close: workflowClose }, persistence, ); @@ -153,7 +153,7 @@ describe("information fulfillment composition", () => { async close() { order.push("workflow"); }, - } as never, + }, { close() { order.push("persistence"); @@ -593,6 +593,7 @@ describe("information fulfillment composition", () => { replacement.workflow, executionId!, ); + assert.isDefined(recovered); expect(recovered.execution.reason).toBeUndefined(); expect(recovered.execution).toMatchObject({ status: "completed", @@ -671,7 +672,9 @@ describe("information fulfillment composition", () => { intervalMilliseconds: 60_000, acceptTrusted: true, }); - const parent = automated.initial?.execution; + assert.isDefined(automated.initial); + assert(automated.initial.status === "started"); + const parent = automated.initial.execution; const subjects = parent === undefined ? [] @@ -823,6 +826,7 @@ describe("information fulfillment composition", () => { metadataOnly.subjects[0]!.inputs[0]!.executionReferences![0]! .workflowExecutionId, ); + assert.isDefined(unchanged); expect( unchanged.sourceRefreshRuns!.every((run) => run.scopes.every((scope) => scope.outcome === "unchanged"), @@ -1579,9 +1583,11 @@ describe("information fulfillment composition", () => { (address) => address === "https://img.dlsite.jp/primary.jpg", ), ).toHaveLength(1); + assert.isDefined(automatic.initial); + assert(automatic.initial.status === "started"); expect( await workflow.acceptedSubjectsOfLibraryScan( - automatic.initial!.execution.execution.id, + automatic.initial.execution.execution.id, ), ).toEqual([expect.objectContaining({ subjectId })]); expect(await workflow.listInspections()).toHaveLength(executionCount + 2); diff --git a/packages/composition/test/local-intake.test.ts b/packages/composition/test/local-intake.test.ts index e28dcc7..d5eea47 100644 --- a/packages/composition/test/local-intake.test.ts +++ b/packages/composition/test/local-intake.test.ts @@ -50,8 +50,16 @@ it("后台轮询不接管本次启动后仍在等待来源的人工检查", asyn const media = await mkdtemp(path.join(tmpdir(), "digital-live-scan-")); await mkdir(path.join(media, "RJ01668691")); await writeFile(path.join(media, "RJ01668691", "notes.txt"), "音声附带材料"); - const release = Promise.withResolvers(); - const entered = Promise.withResolvers(); + let releaseResolve!: () => void; + const releasePromise = new Promise((resolve) => { + releaseResolve = resolve; + }); + const release = { promise: releasePromise, resolve: releaseResolve }; + let enteredResolve!: () => void; + const enteredPromise = new Promise((resolve) => { + enteredResolve = resolve; + }); + const entered = { promise: enteredPromise, resolve: enteredResolve }; vi.stubGlobal("fetch", async (address: string) => { entered.resolve(); await release.promise; @@ -111,7 +119,9 @@ it("后台轮询不接管本次启动后仍在等待来源的人工检查", asyn }); release.resolve(); expect((await scanning).execution.outcome).toBe("accepted"); - expect(await application.workflow.listCurrentCatalogue()).toHaveLength(1); + expect( + (await application.workflow.listCurrentCataloguePage()).items, + ).toHaveLength(1); } finally { release.resolve(); await scan; diff --git a/packages/composition/test/local-material-confirmation.test.ts b/packages/composition/test/local-material-confirmation.test.ts index 5ead1a6..c9250c4 100644 --- a/packages/composition/test/local-material-confirmation.test.ts +++ b/packages/composition/test/local-material-confirmation.test.ts @@ -23,7 +23,7 @@ import { type WorkflowExecution, } from "@digital/application"; import { SqliteCatalogue } from "@digital/sqlite"; -import { afterAll, expect, it, vi } from "vitest"; +import { assert, afterAll, expect, it, vi } from "vitest"; import { composeAssetRefresh } from "../src/runtime.ts"; import { @@ -83,7 +83,9 @@ it("等大小同修改时间的内容变更重扫不伪造来源溯源且保留 informationRequirement: coverRequirement, }); expect(first.itemResults).toHaveLength(1); + assert.isDefined(first.itemResults![0]); const assetId = first.itemResults![0].accepted!.entity.id; + assert.isDefined(first.itemResults![0]); expect( first.itemResults![0].accepted!.artifactAssociations.some( (association) => @@ -105,16 +107,19 @@ it("等大小同修改时间的内容变更重扫不伪造来源溯源且保留 }); expect(second.itemResults).toHaveLength(1); + assert.isDefined(second.itemResults![0]); expect(second.itemResults![0].accepted!.entity.id).toBe(assetId); expect( second.execution.outcome, JSON.stringify(second.execution.dispositions), ).toBe("accepted"); + assert.isDefined(second.itemResults![0]); expect( second.itemResults![0].accepted!.metadata.find( (assertion) => assertion.property === "pdf.document-author", )?.value, ).toBe("Two"); + assert.isDefined(second.itemResults![0]); expect( second.itemResults![0].accepted!.artifactAssociations.some( (association) => @@ -152,6 +157,7 @@ it("等大小同修改时间的内容变更重扫不伪造来源溯源且保留 ), ).toBe(true); // PDF 格式事实属于精确载体修订,不属于资产实体;普通读取必须在新修订目标下读取它。 + assert.isDefined(second.itemResults![0]); const revision = second.itemResults![0].accepted!.metadata.find( (assertion) => assertion.property === "pdf.document-author", )!.target; @@ -1039,6 +1045,7 @@ it("真实旧封面历史的载体经实际字节确认后,错误实体封面 const history = await workflow.inspect( String(closure.workflow_executions![0]!.id), ); + assert.isDefined(history); expect(history.execution.id).toBe(closure.workflow_executions![0]!.id); // 当前公共角色排除错误封面;原历史关联仍保留且受完整性检查,而非被硬删除。 const reopenedDatabase = new DatabaseSync( diff --git a/packages/composition/test/pdf-recognition.test.ts b/packages/composition/test/pdf-recognition.test.ts index e774f8f..c0cefa3 100644 --- a/packages/composition/test/pdf-recognition.test.ts +++ b/packages/composition/test/pdf-recognition.test.ts @@ -13,7 +13,7 @@ import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { SqliteCatalogue } from "@digital/sqlite"; -import { afterAll, expect, it, vi } from "vitest"; +import { assert, afterAll, expect, it, vi } from "vitest"; import { composeAssetRefresh } from "../src/runtime.ts"; import { @@ -86,7 +86,9 @@ it("带有效 ISBN 的 PDF 自动接受为本地出版物但不伪造官方来 try { const persisted = await catalogue.inspectExecution(result.execution.id); expect(persisted?.workItemInspections).toHaveLength(1); + assert.isDefined(persisted!.workItemInspections![0]); expect(persisted!.workItemInspections![0].accepted).not.toBeUndefined(); + assert.isDefined(persisted!.workItemInspections![0]); expect( persisted!.workItemInspections![0].proposal!.ruleResults, ).toMatchObject([ @@ -335,6 +337,7 @@ it("从上级检查 RJ 目录时台本与音轨属于同一音声,重扫不另 // 原断言先做子范围重扫再数目录;现断言把「一个音声只有一个主体」在重扫之前也验证一次, // 这样重扫的固定反例(见下方 D3)不会挡住其余业务断言。 expect(await workflow.listCurrentCatalogue()).toHaveLength(1); + assert.isDefined(accepted[0]); const refreshed = await workflow.refreshMetadata({ selection: { version: "asset-refresh-selection/1", @@ -732,11 +735,13 @@ it("运行时目录包上的识别取代预演不会伪造包内文件位置与 FROM observed_material_acceptances a INNER JOIN locations l ON l.id = a.location_id ORDER BY l.address, a.acceptance_kind`, ) - .all() as readonly { - kind: string; - reference: string; - address: string; - }[], + .all() + .map(({ kind, reference, address }) => { + assert(typeof kind === "string"); + assert(typeof reference === "string"); + assert(typeof address === "string"); + return { kind, reference, address }; + }), boundaries: database .prepare( `SELECT b.material_kind AS kind, b.derivation_kind AS derivation, @@ -744,11 +749,13 @@ it("运行时目录包上的识别取代预演不会伪造包内文件位置与 FROM observed_material_boundaries b INNER JOIN locations l ON l.id = b.location_id ORDER BY l.address`, ) - .all() as readonly { - kind: string; - derivation: string; - address: string; - }[], + .all() + .map(({ kind, derivation, address }) => { + assert(typeof kind === "string"); + assert(typeof derivation === "string"); + assert(typeof address === "string"); + return { kind, derivation, address }; + }), supportCount: ( database .prepare( @@ -1057,7 +1064,9 @@ it("通过真实产品把另一观察根的完整普通文件和 PDF 关联到 // 现断言:现行当前目录项就是生效主体读取(id + descriptions + locations + // sourceAssociations + relationships);「准备阶段不改当前事实」改由整份主体读取逐项相等 // 保护,「确认阶段恰好新增三条材料关联」由公共操作返回的 associationIds 保护。 + assert.isDefined(beforeCatalogue[0]); const targetId = beforeCatalogue[0].id; + assert.isDefined(beforeCatalogue[0]); const originalSources = beforeCatalogue[0].sourceAssociations; const materialRegistration = await workflow.observedRootRegistrations.create({ @@ -1120,6 +1129,7 @@ it("通过真实产品把另一观察根的完整普通文件和 PDF 关联到 // 原断言:准备阶段当前目录项的关联数量不变。 // 现断言:同一保护用现行主体读取整体相等表达——准备只留下待确认检查点,不改动任何 // 当前事实(含来源关联、描述、位置与关系)。 + assert.isDefined(beforeCatalogue[0]); expect(beforeCatalogue[0].locations).not.toHaveLength(0); expect(await workflow.listCurrentCatalogue()).toEqual(beforeCatalogue); @@ -1144,13 +1154,16 @@ it("通过真实产品把另一观察根的完整普通文件和 PDF 关联到 .status, ).toBe("accepted"); const [current] = await workflow.listCurrentCatalogue(); + assert.isDefined(current); expect(current.id).toBe(targetId); expect(current.sourceAssociations).toEqual(originalSources); // 原断言:metadata 逐项不变、当前关联数量恰好「原值 + 3」。 // 现断言:来源解释不被材料关联改动,由 sourceAssociations/descriptions/relationships // 逐项相等保护;「恰好新增三条材料关联」由公共确认操作返回的 associationIds 精确表达, // 材料成为该主体的当前位置则由下面逐项出现与共享/独立产物的别名判定保护。 + assert.isDefined(beforeCatalogue[0]); expect(current.descriptions).toEqual(beforeCatalogue[0].descriptions); + assert.isDefined(beforeCatalogue[0]); expect(current.relationships).toEqual(beforeCatalogue[0].relationships); expect(confirmed.associationIds ?? []).toHaveLength(3); const deliveredNames = new Set( @@ -1308,6 +1321,7 @@ it("确认期硬链接 alias 拆分使观察材料关联过期且不产生部分 executionId: initialReview.executionId, }); const before = (await workflow.listCurrentCatalogue())[0]; + assert.isDefined(before); const prepared = await workflow.prepareObservedMaterialAssociation({ targetAssetId: accepted.content.entity.id, registrationId: root.id, @@ -1367,7 +1381,9 @@ it("观察材料等待确认在重开后保持,确认恢复续行且畸形检 }); expect(initial.execution.outcome).toBe("accepted"); // 现行当前目录项即生效主体读取:主体标识在 id 上。 - const targetId = (await workflow.listCurrentCatalogue())[0].id; + const [target] = await workflow.listCurrentCatalogue(); + assert.isDefined(target); + const targetId = target.id; await writeFile(path.join(observed, "appendix.txt"), "recovery material"); const prepared = await workflow.prepareObservedMaterialAssociation({ targetAssetId: targetId, diff --git a/packages/composition/test/product-operation-lease.test.ts b/packages/composition/test/product-operation-lease.test.ts index bc1f96a..e0e6958 100644 --- a/packages/composition/test/product-operation-lease.test.ts +++ b/packages/composition/test/product-operation-lease.test.ts @@ -1,10 +1,10 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { ProductDataOperations } from "@digital/product-data"; -import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { composeAssetRefresh, @@ -14,22 +14,6 @@ import { const temporaryRoots: string[] = []; -beforeAll(() => { - const built = spawnSync( - "pnpm", - ["--filter", "@digital/composition", "build"], - { - cwd: path.resolve(import.meta.dirname, "../../.."), - encoding: "utf8", - }, - ); - if (built.status !== 0) { - throw new Error( - `could not build the production lease child: ${built.stdout}${built.stderr}`, - ); - } -}); - afterEach(async () => { await Promise.all( temporaryRoots diff --git a/packages/composition/test/relationship-definitions.test.ts b/packages/composition/test/relationship-definitions.test.ts index 93bc855..8e7956e 100644 --- a/packages/composition/test/relationship-definitions.test.ts +++ b/packages/composition/test/relationship-definitions.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterAll, expect, it, vi } from "vitest"; +import { assert, afterAll, expect, it, vi } from "vitest"; import { runProduct } from "../src/index.ts"; import { @@ -275,6 +275,7 @@ it("预演拒绝不存在的参与对象,采用仍校验限定项而不信任 const preview = await product.result.previewRelationshipChange({ changes: [change], }); + assert.isDefined(change.relationship.participants[0]); await expect( product.result.previewRelationshipChange({ changes: [ diff --git a/packages/composition/test/relationship-entity-search.test.ts b/packages/composition/test/relationship-entity-search.test.ts index 4e6bbb5..d34fe1a 100644 --- a/packages/composition/test/relationship-entity-search.test.ts +++ b/packages/composition/test/relationship-entity-search.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterAll, expect, it, vi } from "vitest"; +import { assert, afterAll, expect, it, vi } from "vitest"; import { runProduct } from "../src/index.ts"; import { @@ -255,16 +255,20 @@ it("离线按声明明确选择角色标注关系,逐项拒绝未知、缺角 }), ).toBe("applied"); // 从任一参与者反查同一人工事实,依据为人工而非来源。 - const fromSeries = ( - await workflow.inspectCurrentCatalogueEntity(series.id) - ).relationships.find(({ type }) => type === membership.type)!; + const seriesEntity = await workflow.inspectCurrentCatalogueEntity( + series.id, + ); + assert.isDefined(seriesEntity); + const fromSeries = seriesEntity.relationships.find( + ({ type }) => type === membership.type, + )!; expect(fromSeries.sources).toEqual([]); expect(fromSeries.participants.map((p) => p.entityId).sort()).toEqual( [bookId, series.id].sort(), ); - expect( - (await workflow.inspectCurrentCatalogueEntity(bookId)).relationships, - ).toContainEqual(fromSeries); + const bookEntity = await workflow.inspectCurrentCatalogueEntity(bookId); + assert.isDefined(bookEntity); + expect(bookEntity.relationships).toContainEqual(fromSeries); // 未知定义:调用方自报的类型不能改变提供方含义。 await expect( @@ -385,10 +389,12 @@ it("离线按声明明确选择角色标注关系,逐项拒绝未知、缺角 // 重开后已采用的人工事实仍在,未知输入从未写入。 await product.close(); product = open(data); - expect( - (await product.result.inspectCurrentCatalogueEntity(bookId)) - .relationships, - ).toContainEqual(expect.objectContaining({ type: membership.type })); + const reopenedBookEntity = + await product.result.inspectCurrentCatalogueEntity(bookId); + assert.isDefined(reopenedBookEntity); + expect(reopenedBookEntity.relationships).toContainEqual( + expect.objectContaining({ type: membership.type }), + ); expect(network).not.toHaveBeenCalled(); } finally { await product.close(); diff --git a/packages/composition/test/source-connections.test.ts b/packages/composition/test/source-connections.test.ts index 096de77..1635d39 100644 --- a/packages/composition/test/source-connections.test.ts +++ b/packages/composition/test/source-connections.test.ts @@ -14,10 +14,11 @@ import path from "node:path"; import { assetRefreshSelectionVersion, type EffectiveSubjectRead, + type InformationRequirement, } from "@digital/application"; import { ProductBangumiSourceConnection } from "@digital/bangumi"; import { ProductSourceConnectionPrivateState } from "@digital/product-data"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { assert, afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { composeAssetRefresh } from "../src/runtime.ts"; import { @@ -162,6 +163,7 @@ describe("static source registration", () => { state: "awaiting-selection", candidates: [{}, {}], }); + assert.isDefined(reopened!.candidates[1]); const selected = await workflow.selectSourceAssociationCandidate({ executionId: decision.executionId, candidateReference: reopened!.candidates[1].reference, @@ -180,6 +182,7 @@ describe("static source registration", () => { ]), ); const persisted = await workflow.inspect(decision.executionId); + assert.isDefined(persisted); const secondDetailRecord = persisted.sourceRecords.find( (record) => record.nativeAddress === @@ -1838,14 +1841,16 @@ describe("product-managed film source connections", () => { if (triggered?.status !== "started") throw new Error("missing automatic execution"); await vi.waitFor(async () => { - expect( - (await workflow.inspect(triggered.execution.execution.id)).execution - .status, - ).not.toBe("running"); + const inspection = await workflow.inspect( + triggered.execution.execution.id, + ); + assert.isDefined(inspection); + expect(inspection.execution.status).not.toBe("running"); }); const completed = await workflow.inspect( triggered.execution.execution.id, ); + assert.isDefined(completed); const scan = await workflow.inspectAssetLibraryScan( completed.execution.id, ); @@ -1853,6 +1858,7 @@ describe("product-managed film source connections", () => { const child = await workflow.inspect( scan!.directories[0]!.childExecutionId!, ); + assert.isDefined(child); expect({ execution: child }).toMatchObject({ execution: { execution: { @@ -2499,7 +2505,7 @@ function jsonResponse(body: unknown) { async function scanStrictFilm( workflow: ReturnType, directory: string, - informationRequirement = { + informationRequirement: InformationRequirement = { metadataUsageContext: "asset.ordinary", metadata: [{ property: "asset.title", necessity: "required" as const }], artifactRoles: [], diff --git a/packages/composition/test/source-refresh-run.test.ts b/packages/composition/test/source-refresh-run.test.ts index 647c551..5f39dba 100644 --- a/packages/composition/test/source-refresh-run.test.ts +++ b/packages/composition/test/source-refresh-run.test.ts @@ -8,7 +8,7 @@ import type { SourceRecord, } from "@digital/domain"; import { SqliteCatalogue } from "@digital/sqlite"; -import { afterAll, afterEach, describe, expect, test } from "vitest"; +import { assert, afterAll, afterEach, describe, expect, test } from "vitest"; import { disposePreparedCatalogueTemplate, @@ -175,6 +175,7 @@ describe("Source Refresh Run SQLite authority", () => { ], }); + assert.isDefined(staged.changeSets[0]); const applied = await catalogue.applySourceRefreshChangeSet({ runId: "run-1", changeSetId: staged.changeSets[0].id, @@ -461,16 +462,15 @@ describe("Source Refresh Run SQLite authority", () => { "Source Refresh collection failure codes are malformed", ); } + const failureWithAdjacentData = { + capability: sourceCollectorProducer, + code: "transport-failure", + adjacent: "must-not-be-persisted", + }; await expect( reopened.commitSourceRefreshCollectionFailure({ ...input, - sourceFailureCodes: [ - { - capability: sourceCollectorProducer, - code: "transport-failure", - adjacent: "must-not-be-persisted", - }, - ], + sourceFailureCodes: [failureWithAdjacentData], resumeClaim: claim, }), ).rejects.toThrow("codes are malformed"); @@ -560,12 +560,14 @@ describe("Source Refresh Run SQLite authority", () => { ], }); + assert.isDefined(awaitingReview.changeSets[0]); const approved = await catalogue.reviewSourceRefreshChangeSet({ runId: "retraction-run", changeSetId: awaitingReview.changeSets[0].id, disposition: "approved", }); expect(approved.phase).toBe("applying"); + assert.isDefined(awaitingReview.changeSets[0]); await expect( catalogue.applySourceRefreshChangeSet({ runId: "retraction-run", @@ -647,17 +649,20 @@ describe("Source Refresh Run SQLite authority", () => { candidateSourceRecordId: newerCandidate.id, interpretation: interpretation(newerCandidate), }); + assert.isDefined(newer.changeSets[0]); await catalogue.applySourceRefreshChangeSet({ runId: "newer-run", changeSetId: newer.changeSets[0].id, }); + assert.isDefined(awaitingReview.changeSets[0]); const approved = await catalogue.reviewSourceRefreshChangeSet({ runId: "review-run", changeSetId: awaitingReview.changeSets[0].id, disposition: "approved", }); expect(approved.phase).toBe("applying"); + assert.isDefined(awaitingReview.changeSets[0]); await expect( catalogue.applySourceRefreshChangeSet({ runId: "review-run", @@ -730,6 +735,7 @@ describe("Source Refresh Run SQLite authority", () => { candidateSourceRecordId: updatedTitle.id, interpretation: interpretation(updatedTitle), }); + assert.isDefined(staged.changeSets[0]); await catalogue.applySourceRefreshChangeSet({ runId: "partial-run", changeSetId: staged.changeSets[0].id, @@ -785,7 +791,7 @@ function sourceRecord( title: string, options: { readonly coverageId?: string; - readonly completeness?: "complete" | "partial"; + readonly completeness?: "complete" | "incomplete"; } = {}, ): SourceRecord { return { @@ -835,6 +841,7 @@ function metadata( return { property, sourceSlot: `source.${property.slice("asset.".length)}`, + target: { kind: "entity", entityId: "asset-1" }, value, sourceRecordIds: [record.id], producer: sourceInterpreterProducer, @@ -920,7 +927,6 @@ function accepted( subjectId: "asset-1", sourceIdentity: "source", nativeAddress: "opaque:asset-1", - proposalId: proposal.id, }, sourceAssociations: [ { @@ -928,7 +934,6 @@ function accepted( subjectId: "asset-1", sourceIdentity: "source", nativeAddress: "opaque:asset-1", - proposalId: proposal.id, }, ], recognitionDecision: { diff --git a/packages/composition/test/sqlite-catalogue.test.ts b/packages/composition/test/sqlite-catalogue.test.ts index dbaec05..3b1e90a 100644 --- a/packages/composition/test/sqlite-catalogue.test.ts +++ b/packages/composition/test/sqlite-catalogue.test.ts @@ -25,6 +25,8 @@ import type { MetadataPolicyConfiguration, MetadataRefreshPersistence, ObservedRootRegistration, + PublicCatalogueEntityRelationship, + RelationshipChange, RecoverySourceRefreshRunCommands, SourceRefreshRunPersistence, WorkflowConfigurationCompatibility, @@ -46,6 +48,7 @@ import { DlsiteSourceInterpreter } from "@digital/dlsite"; import type { AcceptedRecognition, RecognitionMaterialBoundary, + RecognitionBasis, SourceAssociationChangeProposal, SourceAssociationDecision, SourceRecord, @@ -66,7 +69,7 @@ import { InMemoryObservedRootRegistrationStore, type InMemoryObservedFilesystemOptions, } from "@digital/test-support"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { assert, afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { runProduct } from "../src/index.ts"; import { @@ -79,6 +82,15 @@ import { preparedCatalogueRoot, } from "./prepared-catalogue.ts"; +function sourceInterpretation(basis: RecognitionBasis) { + if (basis.kind !== "source-interpretation") + throw new Error("夹具要求来源解释识别依据"); + return basis.interpretation; +} +function relationshipDraft(change: RelationshipChange) { + if (!("relationship" in change)) throw new Error("夹具要求建立或更正关系"); + return change.relationship; +} const cleanup: string[] = []; const execFileAsync = promisify(execFile); /** store-only zip:合成 EPUB 只需要未压缩条目即可通过真实格式提供方。 */ @@ -227,7 +239,7 @@ it("serializes real SQLite proof CAS without allowing a later completion to over blobDigest: digest, scheme: "artifact-frame-manifest/1" as const, byteLength: 3, - frameSize: 1_048_576 as const, + frameSize: 1048576 as const, frameCount: 1, manifestDigest: "b".repeat(64), }; @@ -331,7 +343,7 @@ it("confirms one current revision at multiple observed Locations without re-offe blobDigest: newDigest, scheme: "artifact-frame-manifest/1" as const, byteLength: 4, - frameSize: 1_048_576 as const, + frameSize: 1048576 as const, frameCount: 1, manifestDigest: "4".repeat(64), }; @@ -395,10 +407,8 @@ it("confirms one current revision at multiple observed Locations without re-offe ); const refreshDatabase = new DatabaseSync(path.join(root, "catalogue.sqlite")); refreshDatabase - .prepare( - `UPDATE current_file_observations - SET snapshot_id = ?, conditional_baseline = ? WHERE location_id = ?`, - ) + .prepare(`UPDATE current_file_observations + SET snapshot_id = ?, conditional_baseline = ? WHERE location_id = ?`) .run(refreshedSnapshotId, refreshedBaselineB, "location-confirmation-b"); refreshDatabase.close(); const refreshedSelectionB = await catalogue.observedFileSelectionForLocation( @@ -515,12 +525,10 @@ function seedAutomationOwnershipRows( try { for (const observedRootId of observedRootIds) database - .prepare( - `INSERT OR IGNORE INTO observed_root_registrations ( + .prepare(`INSERT OR IGNORE INTO observed_root_registrations ( id, display_name, container_path, canonical_path, status, config_version, read_only, symlink_mode, allowed_target_root_ids_json - ) VALUES (?, ?, ?, ?, 'active', 1, 1, 'do-not-follow', '[]')`, - ) + ) VALUES (?, ?, ?, ?, 'active', 1, 1, 'do-not-follow', '[]')`) .run( observedRootId, observedRootId, @@ -528,21 +536,17 @@ function seedAutomationOwnershipRows( `/${observedRootId}`, ); database - .prepare( - `INSERT OR IGNORE INTO asset_libraries ( + .prepare(`INSERT OR IGNORE INTO asset_libraries ( id, display_name, config_version, status, root_ids_json, recognition_profile_ids_json, source_identities_json - ) VALUES (?, '本地作品库', 1, 'active', ?, '["fixture-profile"]', '[]')`, - ) + ) VALUES (?, '本地作品库', 1, 'active', ?, '["fixture-profile"]', '[]')`) .run(libraryId, JSON.stringify(observedRootIds)); for (const observedRootId of observedRootIds) database - .prepare( - `INSERT OR IGNORE INTO library_directory_grants ( + .prepare(`INSERT OR IGNORE INTO library_directory_grants ( id, library_id, observed_root_id, status, authority_version, symlink_mode, allowed_target_grant_ids_json, awaiting_observation - ) VALUES (?, ?, ?, 'active', 1, 'do-not-follow', '[]', 0)`, - ) + ) VALUES (?, ?, ?, 'active', 1, 'do-not-follow', '[]', 0)`) .run(`grant:${libraryId}:${observedRootId}`, libraryId, observedRootId); } finally { database.close(); @@ -681,7 +685,7 @@ it("公开关系旅程预演、更正多方关系内序位、重开后保留并 { entityId: series.id, role: "series" }, { entityId: "asset", role: "member" }, { entityId: performer.id, role: "performer" }, - ], + ] as const, qualifiers: [{ key: "asset.membership.position", value: "2" }], }, }; @@ -730,7 +734,7 @@ it("公开关系旅程预演、更正多方关系内序位、重开后保留并 participants: [ ...fix.relationship.participants, { entityId: series.id, role: "series" }, - ], + ] as const, }, }, ], @@ -773,7 +777,7 @@ it("公开关系旅程预演、更正多方关系内序位、重开后保留并 participants: [ { entityId: series.id, role: "series" }, { entityId: "asset", role: "member" }, - ], + ] as const, }, }, ], @@ -791,11 +795,14 @@ it("公开关系旅程预演、更正多方关系内序位、重开后保留并 const current = await reopenedWorkflow.inspectCurrentCatalogueEntity( series.id, ); + assert.isDefined(current); + const firstRelationship = current.relationships[0]; + assert.isDefined(firstRelationship); const clear = await reopenedWorkflow.previewRelationshipChange({ changes: [ { kind: "clear", - targetRelationshipId: current!.relationships[0].id, + targetRelationshipId: firstRelationship.id, }, ], }); @@ -835,7 +842,7 @@ it("多事实按项更正只作用于选中事实,其他来源与定义事实 (await workflow.inspectCurrentCatalogueEntity(entityId))!.relationships .filter(({ type }) => type === "asset.membership") .toSorted((left, right) => position(left).localeCompare(position(right))); - const position = (relationship: Awaited>[number]) => + const position = (relationship: PublicCatalogueEntityRelationship) => relationship.qualifiers?.[0]?.value ?? ""; const positions = (relationships: Awaited>) => relationships.map(position); @@ -885,7 +892,7 @@ it("多事实按项更正只作用于选中事实,其他来源与定义事实 { entityId: assetId, role: "member" }, { entityId: "journey-series", role: "series" }, { entityId: "journey-performer", role: "performer" }, - ], + ] as const, qualifiers: [{ key: "asset.membership.position", value: "5" }], }, }; @@ -928,7 +935,7 @@ it("多事实按项更正只作用于选中事实,其他来源与定义事实 { entityId: assetId, role: "member" }, { entityId: "journey-series", role: "series" }, { entityId: "reevaluation-persona", role: "performer" }, - ], + ] as const, }, }, ], @@ -962,7 +969,7 @@ it("多事实按项更正只作用于选中事实,其他来源与定义事实 participants: [ { entityId: assetId, role: "member" }, { entityId: "journey-series", role: "series" }, - ], + ] as const, qualifiers: [{ key: "asset.membership.position", value: "8" }], }, }, @@ -1131,8 +1138,15 @@ const legacyFixture = { type LegacyRelationship = { id: string; - participants: { entityId: string; role: string }[]; - qualifiers?: { key: string; value: string }[]; + type: string; + participants: { + entityId: string; + role: string; + }[]; + qualifiers?: { + key: string; + value: string; + }[]; sources: unknown[]; }; @@ -1171,7 +1185,11 @@ const legacyFactsFor = ( const legacyShape = ( facts: readonly LegacyFact[], -): { parties: number; position: string; sources: number }[] => +): { + parties: number; + position: string; + sources: number; +}[] => facts.map(({ parties, position, sources }) => ({ parties, position, @@ -1189,18 +1207,18 @@ it("旧坐标决定经正式迁移后按被点名事实生效,公开反查、 .relationships as unknown as LegacyRelationship[], subject, ); + const selectedBeforeMigration = manifest.beforeMigration["journey-series"]; + assert.isDefined(selectedBeforeMigration); // 旧坐标按类型与参与者整体作用:整体选择只留下被点名事实,整体更正压掉同坐标全部来源事实。 - expect( - legacyFactsFor( - manifest.beforeMigration["journey-series"], - "journey-series", - ), - ).toEqual([ + expect(legacyFactsFor(selectedBeforeMigration, "journey-series")).toEqual([ { id: expect.any(String), parties: 2, position: "1", sources: 1 }, { id: expect.any(String), parties: 3, position: "1", sources: 1 }, ]); + const selectedBeforeMigration2 = + manifest.beforeMigration["journey-alternate"]; + assert.isDefined(selectedBeforeMigration2); const legacyAlternate = legacyFactsFor( - manifest.beforeMigration["journey-alternate"], + selectedBeforeMigration2, "journey-alternate", ); expect(legacyShape(legacyAlternate)).toEqual([ @@ -1345,7 +1363,10 @@ it("旧坐标决定经正式迁移后按被点名事实生效,公开反查、 type NamedLegacyManifest = { assetId: string; - continuation: { observationVersion: number; sourceVersion: number }; + continuation: { + observationVersion: number; + sourceVersion: number; + }; selectedManualRelationshipId: string | null; applied: { namedRelationshipId: string; @@ -1384,6 +1405,7 @@ it("具名旧更正经正式迁移只替换被点名的来源事实,兄弟事 const { manifest, root } = await openNamedLegacyFixture( "legacy-named-relationship-fix", ); + assert.isDefined(manifest); const catalogue = SqliteCatalogue.forProductData(root); const continuation = { ...legacyFixture.state, ...manifest.continuation }; const workflow = reevaluationWorkflow(catalogue, continuation); @@ -1424,7 +1446,11 @@ it("具名旧更正经正式迁移只替换被点名的来源事实,兄弟事 .id, ).toBe(oldAlternate.find(({ sources }) => sources === 0)!.id); expect( - migrated.some(({ id }) => id === manifest.applied[0].namedRelationshipId), + migrated.some(({ id }) => { + const firstApplied = manifest.applied[0]; + assert.isDefined(firstApplied); + return id === firstApplied.namedRelationshipId; + }), ).toBe(false); expect(legacyShape(await factsOf("journey-series"))).toEqual([ { parties: 2, position: "1", sources: 1 }, @@ -1522,6 +1548,7 @@ it("连续具名更正经正式迁移沿同一被点名目标推进,只让最 const { manifest, root } = await openNamedLegacyFixture( "legacy-named-relationship-fix-chain", ); + assert.isDefined(manifest); const catalogue = SqliteCatalogue.forProductData(root); const continuation = { ...legacyFixture.state, ...manifest.continuation }; const workflow = reevaluationWorkflow(catalogue, continuation); @@ -1558,14 +1585,24 @@ it("连续具名更正经正式迁移沿同一被点名目标推进,只让最 { parties: 3, position: "10", sources: 0 }, { parties: 3, position: "4", sources: 1 }, ]); + const selectedApplied = manifest.applied[1]; + assert.isDefined(selectedApplied); expect(migrated.find(({ sources }) => sources === 0)!.id).toBe( - manifest.applied[1].replacementRelationshipId, + selectedApplied.replacementRelationshipId, ); expect( - migrated.some(({ id }) => id === manifest.applied[0].namedRelationshipId), + migrated.some(({ id }) => { + const firstApplied2 = manifest.applied[0]; + assert.isDefined(firstApplied2); + return id === firstApplied2.namedRelationshipId; + }), ).toBe(false); expect( - migrated.some(({ id }) => id === manifest.applied[1].namedRelationshipId), + migrated.some(({ id }) => { + const selectedApplied2 = manifest.applied[1]; + assert.isDefined(selectedApplied2); + return id === selectedApplied2.namedRelationshipId; + }), ).toBe(false); expect(legacyShape(await factsOf("journey-series"))).toEqual([ { parties: 2, position: "1", sources: 1 }, @@ -1655,6 +1692,7 @@ it("旧具名选择指向人工替代事实时按同一被点名证据生效, const { manifest, root } = await openNamedLegacyFixture( "legacy-named-relationship-fix-select", ); + assert.isDefined(manifest); const catalogue = SqliteCatalogue.forProductData(root); const continuation = { ...legacyFixture.state, ...manifest.continuation }; const workflow = reevaluationWorkflow(catalogue, continuation); @@ -1674,7 +1712,11 @@ it("旧具名选择指向人工替代事实时按同一被点名证据生效, manifest.selectedManualRelationshipId!, ); expect( - current.some(({ id }) => id === manifest.applied[0].namedRelationshipId), + current.some(({ id }) => { + const firstApplied3 = manifest.applied[0]; + assert.isDefined(firstApplied3); + return id === firstApplied3.namedRelationshipId; + }), ).toBe(false); expect( ( @@ -1728,10 +1770,8 @@ it("具名旧更正点名缺失或跨目标事实令正式迁移失败闭锁", a path.join(missing.root, "catalogue.sqlite"), ); missingCorruption - .prepare( - `UPDATE relationship_decisions - SET relationship_ids_json = '["entity-relationship_missing"]'`, - ) + .prepare(`UPDATE relationship_decisions + SET relationship_ids_json = '["entity-relationship_missing"]'`) .run(); missingCorruption.close(); await expect(prepareWithFormalMigration(missing.root)).rejects.toThrow( @@ -1749,14 +1789,12 @@ it("具名旧更正点名缺失或跨目标事实令正式迁移失败闭锁", a path.join(crossed.root, "catalogue.sqlite"), ); const named = crossCorruption - .prepare( - `SELECT json_group_array(relationship_id) AS ids FROM ( + .prepare(`SELECT json_group_array(relationship_id) AS ids FROM ( SELECT relationship_id FROM relationship_lifecycles WHERE source_slot = 'journey.crew.tertiary' UNION SELECT relationship_id FROM relationship_lifecycles - WHERE source_slot = 'journey.alternate-pair')`, - ) + WHERE source_slot = 'journey.alternate-pair')`) .get()!; crossCorruption .prepare(`UPDATE relationship_decisions SET relationship_ids_json = ?`) @@ -1788,11 +1826,9 @@ it("损坏的旧决定引用令正式迁移失败闭锁并拒绝运行时准入" // 只精确破坏真实公开样例的一份副本:点名事实改成不存在的引用。 const corrupted = new DatabaseSync(path.join(root, "catalogue.sqlite")); corrupted - .prepare( - `UPDATE relationship_decisions + .prepare(`UPDATE relationship_decisions SET relationship_ids_json = '["entity-relationship_missing"]' - WHERE kind = 'select'`, - ) + WHERE kind = 'select'`) .run(); corrupted.close(); @@ -1835,15 +1871,19 @@ it("公开身份合并汇集关系、让新人工关系写入代表并能撤销" ["identity/a", "甲身份"], ["identity/b", "乙身份"], ["identity/third", "第三方"], - ].map( - async ([commitmentId, label]) => - await workflow.createLocalRelatedEntity({ - commitmentId, - label, - actorId: "user:甲", - }), - ), + ].map(async ([commitmentId, label]) => { + assert.isDefined(commitmentId); + assert.isDefined(label); + return await workflow.createLocalRelatedEntity({ + commitmentId: commitmentId, + label: label, + actorId: "user:甲", + }); + }), ); + assert.isDefined(a); + assert.isDefined(third); + assert.isDefined(b); const oldRelationship = { kind: "create" as const, definitionId: "fixture.identity-reference", @@ -1852,7 +1892,7 @@ it("公开身份合并汇集关系、让新人工关系写入代表并能撤销" participants: [ { entityId: b.id, role: "subject" }, { entityId: third.id, role: "context" }, - ], + ] as const, }, }; const oldPreview = await workflow.previewRelationshipChange({ @@ -1959,13 +1999,16 @@ it("公开身份合并汇集关系、让新人工关系写入代表并能撤销" participants: [ { entityId: b.id, role: "series" }, { entityId: "asset", role: "member" }, - ], + ] as const, qualifiers: [{ key: "asset.membership.position", value: "2" }], }, }, ], }); - expect(newManualPreview.changes[0].relationship!.participants[0]).toEqual({ + assert.isDefined(newManualPreview); + const firstChange = newManualPreview.changes[0]; + assert.isDefined(firstChange); + expect(relationshipDraft(firstChange)!.participants[0]).toEqual({ entityId: a.id, role: "series", }); @@ -2006,6 +2049,8 @@ it("公开身份合并汇集关系、让新人工关系写入代表并能撤销" }), ), ); + assert.isDefined(otherMerged); + assert.isDefined(otherRetained); const otherMerge = await reopenedWorkflow.previewEntityIdentityMerge({ retainedEntityId: otherRetained.id, mergedEntityId: otherMerged.id, @@ -2075,6 +2120,10 @@ it("指定撤销只解除预演绑定的合并,错误目标被明确拒绝且 }), ), ); + assert.isDefined(d); + assert.isDefined(c); + assert.isDefined(b); + assert.isDefined(a); // 普通本地身份本身即为当前相关实体,两个互不相干的合并可独立建立。 await expect( workflow.applyEntityIdentityMerge({ @@ -2110,12 +2159,14 @@ it("指定撤销只解除预演绑定的合并,错误目标被明确拒绝且 expect(locatedAb.mergeId).toBeDefined(); expect(locatedCd.mergeId).toBeDefined(); expect(locatedAb.mergeId).not.toBe(locatedCd.mergeId); + const mergeId2 = locatedCd.mergeId; + assert.isDefined(mergeId2); // 仅把 A/B 预演的撤销目标换成 C/D 的公开合并标识并以新坐标撤销,共同内核必须拒绝。 await expect( reopenedWorkflow.undoEntityIdentityMerge({ commitmentId: "target/undo-crossed", actorId: "user:乙", - preview: { ...locatedAb, mergeId: locatedCd.mergeId }, + preview: { ...locatedAb, mergeId: mergeId2 }, }), ).resolves.toBe("stale"); await expect( @@ -2166,6 +2217,8 @@ it("公开身份合并拒绝数字资产和会折叠坏的既有关系", async ( }), ), ); + assert.isDefined(b); + assert.isDefined(a); await expect( workflow.previewEntityIdentityMerge({ retainedEntityId: a.id, @@ -2182,7 +2235,7 @@ it("公开身份合并拒绝数字资产和会折叠坏的既有关系", async ( participants: [ { entityId: a.id, role: "subject" }, { entityId: b.id, role: "subject" }, - ], + ] as const, }, }, ], @@ -2219,6 +2272,10 @@ it("身份合并只让相关关系更正过期,并拒绝恢复会折叠的人 }), ), ); + assert.isDefined(d); + assert.isDefined(c); + assert.isDefined(b); + assert.isDefined(a); const unrelatedMerge = await workflow.previewEntityIdentityMerge({ retainedEntityId: a.id, mergedEntityId: b.id, @@ -2233,7 +2290,7 @@ it("身份合并只让相关关系更正过期,并拒绝恢复会折叠的人 participants: [ { entityId: c.id, role: "subject" }, { entityId: d.id, role: "context" }, - ], + ] as const, }, }, ], @@ -2260,6 +2317,8 @@ it("身份合并只让相关关系更正过期,并拒绝恢复会折叠的人 }), ), ); + assert.isDefined(right); + assert.isDefined(left); const unsafe = await workflow.previewRelationshipChange({ changes: [ { @@ -2270,7 +2329,7 @@ it("身份合并只让相关关系更正过期,并拒绝恢复会折叠的人 participants: [ { entityId: left.id, role: "subject" }, { entityId: right.id, role: "subject" }, - ], + ] as const, }, }, ], @@ -2329,7 +2388,7 @@ it("孤立身份合并按当前资料投影汇集值与冲突,并在撤销后 { property: "entity.alias", preferredSourceIdentities: [], - cardinality: "one", + cardinality: "one" as const, }, ], }, @@ -2436,6 +2495,8 @@ it("孤立身份合并按当前资料投影汇集值与冲突,并在撤销后 status: "effective" as const, }, }; + const firstPolicy = base.metadataPolicyConfiguration.policies[0]; + assert.isDefined(firstPolicy); const changedPolicyState = { ...alphaState, metadataPolicyConfiguration: { @@ -2443,12 +2504,12 @@ it("孤立身份合并按当前资料投影汇集值与冲突,并在撤销后 version: "isolated-identity-metadata/2", policies: [ { - ...base.metadataPolicyConfiguration.policies[0], + ...firstPolicy, rules: [ { property: "entity.alias", preferredSourceIdentities: [], - cardinality: "many", + cardinality: "many" as const, }, ], }, @@ -2574,6 +2635,8 @@ it("孤立身份合并按当前资料投影汇集值与冲突,并在撤销后 return current!; }), ); + assert.isDefined(fromBeta); + assert.isDefined(fromAlpha); expect(fromAlpha.metadata).toEqual(fromBeta.metadata); expect(fromAlpha.metadataConflicts).toEqual(fromBeta.metadataConflicts); expect(preview.metadata).toEqual( @@ -2820,7 +2883,7 @@ it("公开身份合并不改写来源刷新,并把随后人工关系留在代 participants: [ { entityId: "reevaluation-persona", role: "performer" }, { entityId: subject, role: "member" }, - ], + ] as const, }, }, ], @@ -2887,12 +2950,15 @@ it("公开身份合并不改写来源刷新,并把随后人工关系留在代 participants: [ { entityId: "reevaluation-persona", role: "performer" }, { entityId: subject, role: "member" }, - ], + ] as const, }, }, ], }); - expect(manual.changes[0].relationship!.participants[0]!.entityId).toBe( + assert.isDefined(manual); + const firstChange2 = manual.changes[0]; + assert.isDefined(firstChange2); + expect(relationshipDraft(firstChange2)!.participants[0]!.entityId).toBe( retained.id, ); await workflow.applyRelationshipChange({ @@ -2971,7 +3037,7 @@ it("公开交叉旅程保留自动关系、人工更正与指定身份合并的 participants: [ { entityId: "reevaluation-persona", role: "subject" }, { entityId: assetId, role: "context" }, - ], + ] as const, }, }, ], @@ -3016,12 +3082,15 @@ it("公开交叉旅程保留自动关系、人工更正与指定身份合并的 participants: [ { entityId: "reevaluation-persona", role: "series" }, { entityId: assetId, role: "member" }, - ], + ] as const, }, }, ], }); - expect(postMergeManual.changes[0].relationship!.participants[0]).toEqual({ + assert.isDefined(postMergeManual); + const firstChange3 = postMergeManual.changes[0]; + assert.isDefined(firstChange3); + expect(relationshipDraft(firstChange3)!.participants[0]).toEqual({ entityId: retained.id, role: "series", }); @@ -3115,6 +3184,11 @@ it("公开身份合并按根连接链,并让过期预演与同根环失败", a }), ), ); + assert.isDefined(c); + assert.isDefined(b); + assert.isDefined(a); + assert.isDefined(staleRight); + assert.isDefined(staleLeft); const stale = await workflow.previewEntityIdentityMerge({ retainedEntityId: staleLeft.id, mergedEntityId: staleRight.id, @@ -3213,13 +3287,16 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, body: "", }, ]; + assert.isDefined(dlsiteRecords); + const firstDlsiteRecord = dlsiteRecords[0]; + assert.isDefined(firstDlsiteRecord); const dlsite = new DlsiteSourceInterpreter().interpret({ evidence: [ { kind: "directed-source-address", explanation: "test", sourceIdentity: "dlsite", - sourceAddress: dlsiteRecords[0].nativeAddress, + sourceAddress: firstDlsiteRecord.nativeAddress, producer: { capabilityId: "dlsite.exact-directory", capabilityVersion: "1", @@ -3229,6 +3306,7 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, ], sourceRecords: dlsiteRecords, })[0]; + assert.isDefined(dlsite); const circle = dlsite.entities!.find( (entity) => entity.sourceKey === "RG123", )!; @@ -3239,19 +3317,26 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, (mention) => mention.sourceKey === "RG123", )!; const base = accepted(); + const firstSourceAssociation = base.sourceAssociations[0]; + assert.isDefined(firstSourceAssociation); + const firstDlsiteRecord2 = dlsiteRecords[0]; + assert.isDefined(firstDlsiteRecord2); + + const firstRelationship2 = base.relationships![0]; + assert.isDefined(firstRelationship2); const acceptedWithIsolated = { ...base, sourceAssociations: [ { - ...base.sourceAssociations[0], + ...firstSourceAssociation, sourceIdentity: "dlsite", - nativeAddress: dlsiteRecords[0].nativeAddress, + nativeAddress: firstDlsiteRecord2.nativeAddress, }, ], recognitionSourceAssociation: { ...base.recognitionSourceAssociation, sourceIdentity: "dlsite", - nativeAddress: dlsiteRecords[0].nativeAddress, + nativeAddress: firstDlsiteRecord2.nativeAddress, }, proposal: { ...base.proposal, @@ -3260,7 +3345,7 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, interpretation: { ...base.proposal.basis.interpretation, sourceIdentity: "dlsite", - sourceAddress: dlsiteRecords[0].nativeAddress, + sourceAddress: firstDlsiteRecord2.nativeAddress, sourceRecordIds: dlsiteRecords.map(({ id }) => id), entities: [circle, isolated], entityMentions: [circleMention], @@ -3277,7 +3362,7 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, ), relationships: [ { - ...base.relationships![0], + ...firstRelationship2, ...circleRelationship, id: "dlsite-circle-asset", participants: [ @@ -3289,7 +3374,7 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, ...(mentionIds === undefined ? {} : { mentionIds }), }), ), - ], + ] as const, }, ], }; @@ -3359,18 +3444,25 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, entityId: circle.id, }); const second = acceptedFor("asset-2", "record-asset-2"); + const firstDlsiteRecord5 = dlsiteRecords[0]; + assert.isDefined(firstDlsiteRecord5); + const secondRecord = { ...sourceRecordFor("asset-2"), sourceIdentity: "dlsite", - nativeAddress: dlsiteRecords[0].nativeAddress, - coverage: dlsiteRecords[0].coverage, + nativeAddress: firstDlsiteRecord5.nativeAddress, + coverage: firstDlsiteRecord5.coverage, }; const secondCircle = { ...circle, sourceRecordIds: [secondRecord.id] }; + const firstSourceAssociation2 = second.sourceAssociations[0]; + assert.isDefined(firstSourceAssociation2); + const firstRelationship3 = second.relationships![0]; + assert.isDefined(firstRelationship3); const secondAccepted = { ...second, sourceAssociations: [ { - ...second.sourceAssociations[0], + ...firstSourceAssociation2, sourceIdentity: "dlsite", nativeAddress: secondRecord.nativeAddress, }, @@ -3400,13 +3492,13 @@ it("公开从普通实体返回自身、具名邻接关系和已收录内容, entityResolutionDecisions: [], relationships: [ { - ...second.relationships![0], + ...firstRelationship3, ...circleRelationship, id: "dlsite-circle-asset-2", participants: [ { entityId: "asset-2", role: "asset" }, { entityId: circle.id, role: "circle" }, - ], + ] as const, sourceRecordIds: [secondRecord.id], }, ], @@ -3590,6 +3682,9 @@ function inMemoryObservedRootRegistrations( filesystemOptions?: InMemoryObservedFilesystemOptions, ): Pick< ObservedRootRegistrations, + | "get" + | "bindDirectoryOperation" + | "assertDirectoryOperationLive" | "resolveActiveScope" | "rebindFrozenScope" | "assertFrozenRootsCurrent" @@ -3692,7 +3787,9 @@ async function scanFixtureRegisteredObservedScope( input: Omit< Parameters[0], "registrationId" | "relativeScope" - > & { readonly directory: string }, + > & { + readonly directory: string; + }, ) { const { directory, ...request } = input; return await workflow.scanObservedScope({ @@ -3755,7 +3852,9 @@ async function scanAccepted( Awaited< ReturnType > extends infer inspection - ? inspection extends { accepted?: infer accepted } + ? inspection extends { + accepted?: infer accepted; + } ? accepted : never : never @@ -3769,7 +3868,9 @@ async function scanAccepted( ); if (accepted.length > 1) throw new Error("单主体夹具意外接受了多个独立工作项"); - return accepted[0]; + const firstAccepted2 = accepted[0]; + assert.isDefined(firstAccepted2); + return firstAccepted2; } /** 现行授权合同里的库目录链接意图(`packages/application/src/asset-libraries.ts:126-132`)。 */ @@ -3799,16 +3900,23 @@ type AssetLibrarySeedHost = { readonly configVersion: number; readonly rootIds: readonly string[]; }>; - readonly recognitionProfiles: readonly { readonly id: string }[]; - readonly sources: readonly { readonly id: string }[]; + directoryViews(id: string): Promise< + readonly { + readonly observedRootId: string; + readonly containerPath: string; + }[] + >; + readonly recognitionProfiles: readonly { + readonly id: string; + }[]; + readonly sources: readonly { + readonly id: string; + }[]; remove?(input: { readonly id: string; readonly expectedVersion: number; }): Promise; }; - readonly observedRootRegistrations: { - get(id: string): Promise<{ readonly containerPath: string } | undefined>; - }; }; /** @@ -3870,9 +3978,8 @@ async function seedAssetLibraryFor( const rootIdByPath = new Map(); // 持久化按编号排序返回 rootIds(`packages/sqlite/src/index.ts:12807-12809`),因此只能按 // 每个观察根自己的容器路径回填,绝不靠返回顺序猜测目录归属。 - for (const rootId of saved.rootIds) { - const registration = await workflow.observedRootRegistrations.get(rootId); - if (registration === undefined) continue; + for (const registration of await libraries.directoryViews(saved.id)) { + const rootId = registration.observedRootId; for (const directory of paths) if (path.resolve(directory) === registration.containerPath) rootIdByPath.set(directory, rootId); @@ -3896,7 +4003,10 @@ async function seedAssetLibraryFor( */ async function removeSeededAssetLibrary( workflow: AssetLibrarySeedHost, - coordinates: { readonly libraryId: string; readonly configVersion: number }, + coordinates: { + readonly libraryId: string; + readonly configVersion: number; + }, ): Promise { if (workflow.assetLibraries.remove === undefined) throw new Error("资产库夹具缺少移除入口,无法建立末引用撤销的合法前置"); @@ -3984,17 +4094,23 @@ function rewriteChangeImpactPreviewsSchema( .prepare( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'change_impact_previews'", ) - .get() as { readonly sql: string }; + .get() as { + readonly sql: string; + }; const indexes = database .prepare( "SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'change_impact_previews' AND sql IS NOT NULL ORDER BY name", ) - .all() as readonly { readonly sql: string }[]; + .all(); const rewritten = rewrite(table.sql); expect(rewritten).not.toBe(table.sql); database.exec("DROP TABLE change_impact_previews"); database.exec(rewritten); - for (const index of indexes) database.exec(index.sql); + for (const index of indexes) { + const sql = index.sql; + assert(typeof sql === "string"); + database.exec(sql); + } } type OldContractManifest = { @@ -4006,7 +4122,9 @@ type OldContractManifest = { readonly parentRootId: string; readonly childRootId: string; readonly duplicateAddress: string; - readonly observedLocations: readonly { readonly locationId: string }[]; + readonly observedLocations: readonly { + readonly locationId: string; + }[]; readonly media: readonly { readonly relativePath: string; readonly kind: "directory" | "file"; @@ -4227,7 +4345,7 @@ describe("SqliteCatalogue lifecycle", () => { assetLibraryId: library.libraryId, observedRootId: registration.id, relativeScope: ".", - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, acceptTrusted: true, runInitial: false, }); @@ -4363,7 +4481,7 @@ describe("SqliteCatalogue lifecycle", () => { assetLibraryId: library.libraryId, observedRootId: root.id, relativeScope: ".", - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, acceptTrusted: true, runInitial: false, }) @@ -4509,7 +4627,7 @@ describe("SqliteCatalogue lifecycle", () => { assetLibraryId: library.libraryId, observedRootId: root.id, relativeScope: ".", - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, acceptTrusted: true, runInitial: false, }); @@ -4543,18 +4661,16 @@ describe("SqliteCatalogue lifecycle", () => { const root = await preparedCatalogueRoot(); cleanup.push(root); const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); - const insertIntent = database.prepare( - `INSERT INTO automation_intents + const insertIntent = database.prepare(`INSERT INTO automation_intents (id, scope_json, enabled, disabled_reason, interval_milliseconds, workflow_input_version, policy_references_json, accept_trusted, run_initial) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); insertIntent.run( "malformed-scope", "not-json", 1, null, - 60_000, + 60000, "asset-refresh-input/1", JSON.stringify(["policy/1"]), 1, @@ -4569,7 +4685,7 @@ describe("SqliteCatalogue lifecycle", () => { }), 1, null, - 60_000, + 60000, "asset-refresh-input/1", "not-json", 1, @@ -4773,8 +4889,9 @@ describe("SqliteCatalogue lifecycle", () => { relativeScope: ".", acceptTrusted: false, }); - - expect(result.execution.snapshot.diagnostics).toBeUndefined(); + const snapshot2 = result.execution.snapshot; + assert.isDefined(snapshot2); + expect(snapshot2.diagnostics).toBeUndefined(); expect(result.execution.snapshot).toMatchObject({ complete: true, files: [{ relativePath: "first/second/evidence.bin" }], @@ -4973,7 +5090,9 @@ describe("SqliteCatalogue lifecycle", () => { const revisionIds = ( historyDatabase .prepare("SELECT id FROM artifact_revisions ORDER BY id") - .all() as { id: string }[] + .all() as { + id: string; + }[] ).map(({ id }) => id); expect(revisionIds).toEqual( expect.arrayContaining( @@ -5586,6 +5705,12 @@ describe("SqliteCatalogue lifecycle", () => { async triggerDueAutomation() { return []; }, + async continueRunningExecution() { + return undefined; + }, + async reconcileTerminalFulfillment() { + return []; + }, close() { closes += 1; }, @@ -5608,12 +5733,18 @@ describe("SqliteCatalogue lifecycle", () => { }); let closes = 0; const host = composeOwnedAutomationHost({ - async recoverRunningExecutions() { + async classifyRunningExecutions() { return []; }, async triggerDueAutomation() { return []; }, + async continueRunningExecution() { + return undefined; + }, + async reconcileTerminalFulfillment() { + return []; + }, close() { closes += 1; signalCloseStarted(); @@ -5786,7 +5917,9 @@ describe("SqliteCatalogue lifecycle", () => { ).resolves.toMatchObject({ state: "ready" }); const audit = database .prepare("SELECT audit_json FROM workflow_executions WHERE id = ?") - .get("execution") as { readonly audit_json: string }; + .get("execution") as { + readonly audit_json: string; + }; expect(JSON.parse(audit.audit_json)).toMatchObject({ version: "workflow-execution-audit/1", payload: { id: "execution", outcome: "accepted" }, @@ -5870,7 +6003,9 @@ describe("SqliteCatalogue lifecycle", () => { { start: (application) => application }, ); try { - const current = await product.result.workflow.listCurrentCatalogue(); + const current = await product.result.workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); // 同址重复目录包在正式迁移后仍是一个资产、两个观察位置,公开产品入口可读。 expect(current.map(({ id }) => id)).toEqual([ manifest.assets.duplicatePackage, @@ -5888,8 +6023,7 @@ describe("SqliteCatalogue lifecycle", () => { } finally { await product.close(); } - }, 60_000); - + }, 60000); it("keeps each migrated overlapping root decisive through public observation operations", async () => { const { productDataDirectory, manifest } = await migratedOldContractCatalogue( @@ -5905,7 +6039,10 @@ describe("SqliteCatalogue lifecycle", () => { .prepare( "SELECT address, modified_at AS modifiedAt FROM current_file_observations", ) - .all() as { address: string; modifiedAt: string }[]; + .all() as { + address: string; + modifiedAt: string; + }[]; originalObservationDatabase.close(); for (const observation of originalFileObservations) await utimes( @@ -5915,7 +6052,13 @@ describe("SqliteCatalogue lifecycle", () => { ); const workflow = composeAssetRefresh(productDataDirectory, {}); const currentIds = async () => - (await workflow.listCurrentCatalogue()).map(({ id }) => id).toSorted(); + ( + await workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items) + ) + .map(({ id }) => id) + .toSorted(); try { expect(await currentIds()).toEqual( Object.values(manifest.assets).toSorted(), @@ -5964,26 +6107,21 @@ describe("SqliteCatalogue lifecycle", () => { // 同一地址在公开扫描后仍是唯一有效 Location,恢复没有靠新建位置达成。 const bookLocations = database .prepare("SELECT id FROM locations WHERE address = ?") - .all(bookAddress) as readonly { id: string }[]; + .all(bookAddress); expect(bookLocations).toHaveLength(1); const [bookLocation] = bookLocations; if (bookLocation === undefined) throw new Error("缺少重叠根位置"); const bookLocationId = bookLocation.id; + assert(typeof bookLocationId === "string"); // 已撤销父根的旧正项被其冻结版本号隔离,永远不再计数;恢复只来自子根自己的新完整事件。 const rows = database - .prepare( - `SELECT s.observed_root_id AS rootId, + .prepare(`SELECT s.observed_root_id AS rootId, (r.config_version = s.observed_root_version AND r.status = 'active') AS live, s.snapshot_id AS snapshotId FROM current_observed_material_supports s INNER JOIN observed_root_registrations r ON r.id = s.observed_root_id - WHERE s.location_id = ? ORDER BY s.observed_root_id`, - ) - .all(bookLocationId) as readonly { - rootId: string; - live: number; - snapshotId: string; - }[]; + WHERE s.location_id = ? ORDER BY s.observed_root_id`) + .all(bookLocationId); expect(rows.map(({ rootId, live }) => ({ rootId, live }))).toEqual([ { rootId: manifest.parentRootId, live: 0 }, { rootId: manifest.childRootId, live: 1 }, @@ -5995,8 +6133,7 @@ describe("SqliteCatalogue lifecycle", () => { } finally { await workflow.close(); } - }, 60_000); - + }, 60000); it("把已迁移目录包内的新文件材料接受为真正新建的观察位置", async () => { const { productDataDirectory, manifest } = await migratedOldContractCatalogue( @@ -6017,38 +6154,32 @@ describe("SqliteCatalogue lifecycle", () => { try { return { boundaries: database - .prepare( - `SELECT l.address AS address, b.material_kind AS kind, + .prepare(`SELECT l.address AS address, b.material_kind AS kind, b.derivation_kind AS derivation FROM observed_material_boundaries b - INNER JOIN locations l ON l.id = b.location_id ORDER BY l.id`, - ) - .all() as readonly { - address: string; - kind: string; - derivation: string; - }[], + INNER JOIN locations l ON l.id = b.location_id ORDER BY l.id`) + .all(), acceptances: database - .prepare( - `SELECT a.acceptance_kind AS kind, l.address AS address + .prepare(`SELECT a.acceptance_kind AS kind, l.address AS address FROM observed_material_acceptances a - INNER JOIN locations l ON l.id = a.location_id ORDER BY l.id`, - ) - .all() as readonly { kind: string; address: string }[], + INNER JOIN locations l ON l.id = a.location_id ORDER BY l.id`) + .all(), supports: database - .prepare( - `SELECT l.address AS address, s.material_kind AS kind + .prepare(`SELECT l.address AS address, s.material_kind AS kind FROM current_observed_material_supports s - INNER JOIN locations l ON l.id = s.location_id ORDER BY l.id`, - ) - .all() as readonly { address: string; kind: string }[], + INNER JOIN locations l ON l.id = s.location_id ORDER BY l.id`) + .all(), }; } finally { database.close(); } }; try { - const before = (await workflow.listCurrentCatalogue())[0]; + const before = ( + await workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items) + )[0]; if (before === undefined) throw new Error("缺少已迁移当前资产"); expect(before.locations.map(({ address }) => address)).toEqual([ packageAddress, @@ -6056,7 +6187,13 @@ describe("SqliteCatalogue lifecycle", () => { ]); const initial = readShapeFacts(); expect( - initial.boundaries.map((row) => `${row.kind}:${row.derivation}`), + initial.boundaries.map((row) => { + const kind2 = row.kind; + assert(typeof kind2 === "string"); + const derivation2 = row.derivation; + assert(typeof derivation2 === "string"); + return `${kind2}:${derivation2}`; + }), ).toEqual([ "directory:legacy-kind-equivalent", "directory:legacy-kind-equivalent", @@ -6080,7 +6217,11 @@ describe("SqliteCatalogue lifecycle", () => { ).status, ).toBe("accepted"); - const after = (await workflow.listCurrentCatalogue())[0]; + const after = ( + await workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items) + )[0]; if (after === undefined) throw new Error("缺少确认后的当前资产"); // 新文件不是将目录包位置改写成 file,而是自己获得一个有效 Location。 expect(after.locations.map(({ address }) => address).toSorted()).toEqual([ @@ -6094,16 +6235,27 @@ describe("SqliteCatalogue lifecycle", () => { ]); expect( facts.boundaries - .map((row) => `${row.kind}:${row.derivation}`) + .map((row) => { + const kind3 = row.kind; + assert(typeof kind3 === "string"); + const derivation3 = row.derivation; + assert(typeof derivation3 === "string"); + return `${kind3}:${derivation3}`; + }) .toSorted(), ).toEqual([ "directory:legacy-kind-equivalent", "directory:legacy-kind-equivalent", "file:accepted-exact", ]); - expect(facts.boundaries.map(({ address }) => address).toSorted()).toEqual( - [packageAddress, packageAddress, fileAddress], - ); + expect( + facts.boundaries + .map(({ address }) => { + assert(typeof address === "string"); + return address; + }) + .toSorted(), + ).toEqual([packageAddress, packageAddress, fileAddress]); // 新位置的正项来自本次确认自己的完整事件,不借用目录包的迁移正项。 expect( facts.supports.filter((row) => row.address === fileAddress), @@ -6111,8 +6263,7 @@ describe("SqliteCatalogue lifecycle", () => { } finally { await workflow.close(); } - }, 60_000); - + }, 60000); it("受信扫描接受为每个确认材料写下自身边界、接受事件与正向支持", async () => { const data = await preparedCatalogueRoot(); const media = await mkdtemp( @@ -6151,14 +6302,10 @@ describe("SqliteCatalogue lifecycle", () => { ); try { const application = product.result; - const root = await application.workflow.observedRootRegistrations.create({ - displayName: "受信扫描", - containerPath: workDirectory, - readOnly: true, - symlinkMode: "do-not-follow", - allowedTargetRootIds: [], - }); - await seedAssetLibrary(application.workflow, data, workDirectory); + const root = { + id: (await seedAssetLibrary(application.workflow, data, workDirectory)) + .rootId, + }; const scan = await application.workflow.scanObservedScope({ registrationId: root.id, relativeScope: ".", @@ -6174,7 +6321,9 @@ describe("SqliteCatalogue lifecycle", () => { }), ]); - const current = await application.workflow.listCurrentCatalogue(); + const current = await application.workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); expect(current).toHaveLength(1); const observedAddresses = (current[0]?.locations ?? []) .filter((location) => location.kind === "observed") @@ -6187,8 +6336,7 @@ describe("SqliteCatalogue lifecycle", () => { }); try { const carriers = database - .prepare( - `SELECT l.address AS address, + .prepare(`SELECT l.address AS address, b.material_kind AS kind, b.derivation_kind AS derivation, (SELECT COUNT(*) FROM observed_material_acceptances a @@ -6197,8 +6345,7 @@ describe("SqliteCatalogue lifecycle", () => { WHERE s.location_id = b.location_id) AS supports FROM observed_material_boundaries b INNER JOIN locations l ON l.id = b.location_id - ORDER BY l.address`, - ) + ORDER BY l.address`) .all() as unknown as readonly { address: string; kind: string; @@ -6225,7 +6372,9 @@ describe("SqliteCatalogue lifecycle", () => { }), ).resolves.toMatchObject({ state: "ready" }); - const reopened = await application.workflow.listCurrentCatalogue(); + const reopened = await application.workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); expect(reopened.map(({ id }) => id)).toEqual([current[0]?.id]); } finally { await product.close(); @@ -6233,8 +6382,7 @@ describe("SqliteCatalogue lifecycle", () => { await rm(data, { recursive: true, force: true }); await rm(media, { recursive: true, force: true }); } - }, 60_000); - + }, 60000); it("公开 EPUB 作者更新后明确追加别名范围写下预演位置并在重开后保持决定支持", async () => { const data = await preparedCatalogueRoot(); const media = await mkdtemp( @@ -6257,24 +6405,20 @@ describe("SqliteCatalogue lifecycle", () => { try { return { previews: database - .prepare( - `SELECT status AS status, + .prepare(`SELECT status AS status, json_array_length(preview_json, '$.current.locations') AS currentCount, json_array_length(preview_json, '$.projected.locations') AS projectedCount - FROM change_impact_previews ORDER BY rowid`, - ) + FROM change_impact_previews ORDER BY rowid`) .all() as unknown as readonly { status: string; currentCount: number; projectedCount: number; }[], acceptances: database - .prepare( - `SELECT acceptance_kind AS kind, observed_reference AS reference, + .prepare(`SELECT acceptance_kind AS kind, observed_reference AS reference, location_id AS locationId, execution_id AS executionId, association_id AS associationId - FROM observed_material_acceptances ORDER BY rowid`, - ) + FROM observed_material_acceptances ORDER BY rowid`) .all() as unknown as readonly { kind: string; reference: string; @@ -6283,12 +6427,10 @@ describe("SqliteCatalogue lifecycle", () => { associationId: string; }[], boundaries: database - .prepare( - `SELECT b.location_id AS locationId, l.address AS address, + .prepare(`SELECT b.location_id AS locationId, l.address AS address, b.material_kind AS kind, b.derivation_kind AS derivation FROM observed_material_boundaries b - INNER JOIN locations l ON l.id = b.location_id ORDER BY b.rowid`, - ) + INNER JOIN locations l ON l.id = b.location_id ORDER BY b.rowid`) .all() as unknown as readonly { locationId: string; address: string; @@ -6296,12 +6438,10 @@ describe("SqliteCatalogue lifecycle", () => { derivation: string; }[], supports: database - .prepare( - `SELECT location_id AS locationId, material_kind AS kind, + .prepare(`SELECT location_id AS locationId, material_kind AS kind, snapshot_id AS snapshotId, observed_root_id AS rootId, observed_root_version AS rootVersion - FROM current_observed_material_supports ORDER BY rowid`, - ) + FROM current_observed_material_supports ORDER BY rowid`) .all() as unknown as readonly { locationId: string; kind: string; @@ -6313,7 +6453,9 @@ describe("SqliteCatalogue lifecycle", () => { .prepare( `SELECT COUNT(*) AS count FROM recognition_decision_supersessions`, ) - .get() as unknown as { count: number }, + .get() as unknown as { + count: number; + }, }; } finally { database.close(); @@ -6325,14 +6467,8 @@ describe("SqliteCatalogue lifecycle", () => { ); try { const workflow = product.result.workflow; - const registration = await workflow.observedRootRegistrations.create({ - displayName: "公开 EPUB 预演", - containerPath: media, - readOnly: true, - symlinkMode: "do-not-follow", - allowedTargetRootIds: [], - }); const library = await seedAssetLibrary(workflow, data, media); + const registration = { id: library.rootId }; const first = await workflow.scanObservedScope({ registrationId: registration.id, relativeScope: ".", @@ -6455,7 +6591,9 @@ describe("SqliteCatalogue lifecycle", () => { }); confirmedRevisions.set(locationId, confirmation.artifactRevisionId); } - const afterApply = await workflow.listCurrentCatalogue(); + const afterApply = await workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); for (const [locationId, revisionId] of confirmedRevisions) expect( afterApply[0]?.locations.find(({ id }) => id === locationId)?.content, @@ -6465,14 +6603,19 @@ describe("SqliteCatalogue lifecycle", () => { }); const observedLocationsOf = ( asset: (typeof afterApply)[number], - ): readonly { id: string; address: string }[] => + ): readonly { + id: string; + address: string; + }[] => (asset?.locations ?? []) .filter((location) => location.kind === "observed") .map(({ id, address }) => ({ id, address })) .toSorted((left, right) => left.id.localeCompare(right.id)); expect(afterApply.map(({ id }) => id)).toEqual([initialAssetId]); - expect(observedLocationsOf(afterApply[0])).toEqual( + const firstAfterApply = afterApply[0]; + assert.isDefined(firstAfterApply); + expect(observedLocationsOf(firstAfterApply)).toEqual( [ { id: initialLocationId, address: fileAddress }, { id: updatedLocationId, address: fileAddress }, @@ -6605,10 +6748,16 @@ describe("SqliteCatalogue lifecycle", () => { { productDataDirectory: data, environment: {} }, { start: (application) => application }, ); - const reopened = await product.result.workflow.listCurrentCatalogue(); + const reopened = await product.result.workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); expect(reopened.map(({ id }) => id)).toEqual([initialAssetId]); - expect(observedLocationsOf(reopened[0])).toEqual( - observedLocationsOf(afterApply[0]), + const firstReopened = reopened[0]; + assert.isDefined(firstReopened); + const firstAfterApply2 = afterApply[0]; + assert.isDefined(firstAfterApply2); + expect(observedLocationsOf(firstReopened)).toEqual( + observedLocationsOf(firstAfterApply2), ); await expect( SqliteCatalogue.inspectProductionReadiness({ @@ -6633,8 +6782,7 @@ describe("SqliteCatalogue lifecycle", () => { const borrow = new DatabaseSync(path.join(damaged, "catalogue.sqlite")); try { const foreign = borrow - .prepare( - `SELECT other.proposal_id AS proposal_id + .prepare(`SELECT other.proposal_id AS proposal_id FROM observed_material_acceptances AS other INNER JOIN recognition_proposals AS borrowed ON borrowed.id = other.proposal_id @@ -6642,18 +6790,19 @@ describe("SqliteCatalogue lifecycle", () => { AND borrowed.execution_id <> ( SELECT execution_id FROM observed_material_acceptances WHERE acceptance_kind = 'a-preview' LIMIT 1) - LIMIT 1`, - ) - .get() as { proposal_id: string } | undefined; + LIMIT 1`) + .get() as + | { + proposal_id: string; + } + | undefined; if (foreign === undefined) throw new Error( "fixture 缺少 a-preview 可借用的另一真实执行 proposal", ); borrow - .prepare( - `UPDATE observed_material_acceptances SET proposal_id = ? - WHERE acceptance_kind = 'a-preview'`, - ) + .prepare(`UPDATE observed_material_acceptances SET proposal_id = ? + WHERE acceptance_kind = 'a-preview'`) .run(foreign.proposal_id); } finally { borrow.close(); @@ -6679,8 +6828,7 @@ describe("SqliteCatalogue lifecycle", () => { await rm(data, { recursive: true, force: true }); await rm(media, { recursive: true, force: true }); } - }, 120_000); - + }, 120000); it("公开目录包换成同址合法 PDF 时新文件自得接受与支持且旧包边界历史保留", async () => { const data = await preparedCatalogueRoot(); const media = await mkdtemp( @@ -6729,14 +6877,14 @@ describe("SqliteCatalogue lifecycle", () => { .prepare( `SELECT status AS status FROM change_impact_previews ORDER BY rowid`, ) - .all() as unknown as readonly { status: string }[], + .all() as unknown as readonly { + status: string; + }[], acceptances: database - .prepare( - `SELECT a.acceptance_kind AS kind, a.observed_reference AS reference, + .prepare(`SELECT a.acceptance_kind AS kind, a.observed_reference AS reference, a.location_id AS locationId, l.address AS address FROM observed_material_acceptances a - INNER JOIN locations l ON l.id = a.location_id ORDER BY a.rowid`, - ) + INNER JOIN locations l ON l.id = a.location_id ORDER BY a.rowid`) .all() as unknown as readonly { kind: string; reference: string; @@ -6744,12 +6892,10 @@ describe("SqliteCatalogue lifecycle", () => { address: string; }[], boundaries: database - .prepare( - `SELECT b.location_id AS locationId, l.address AS address, + .prepare(`SELECT b.location_id AS locationId, l.address AS address, b.material_kind AS kind, b.derivation_kind AS derivation FROM observed_material_boundaries b - INNER JOIN locations l ON l.id = b.location_id ORDER BY b.rowid`, - ) + INNER JOIN locations l ON l.id = b.location_id ORDER BY b.rowid`) .all() as unknown as readonly { locationId: string; address: string; @@ -6757,12 +6903,10 @@ describe("SqliteCatalogue lifecycle", () => { derivation: string; }[], supports: database - .prepare( - `SELECT s.location_id AS locationId, s.material_kind AS kind, + .prepare(`SELECT s.location_id AS locationId, s.material_kind AS kind, l.address AS address FROM current_observed_material_supports s - INNER JOIN locations l ON l.id = s.location_id ORDER BY s.rowid`, - ) + INNER JOIN locations l ON l.id = s.location_id ORDER BY s.rowid`) .all() as unknown as readonly { locationId: string; kind: string; @@ -6779,14 +6923,9 @@ describe("SqliteCatalogue lifecycle", () => { ); try { const workflow = product.result.workflow; - const registration = await workflow.observedRootRegistrations.create({ - displayName: "公开包转文件", - containerPath: media, - readOnly: true, - symlinkMode: "do-not-follow", - allowedTargetRootIds: [], - }); - await seedAssetLibrary(workflow, data, media); + const registration = { + id: (await seedAssetLibrary(workflow, data, media)).rootId, + }; const directed = await workflow.directedRecognitionSourceInputObservedScope({ registrationId: registration.id, @@ -6812,7 +6951,9 @@ describe("SqliteCatalogue lifecycle", () => { if (fileAssetId === undefined) throw new Error("同址新文件缺少接受资产"); expect(fileAssetId).not.toBe(packageAssetId); - const current = await workflow.listCurrentCatalogue(); + const current = await workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); expect(current.map(({ id }) => id)).toEqual([fileAssetId]); const fileLocations = (current[0]?.locations ?? []).filter( (location) => location.kind === "observed", @@ -6839,9 +6980,14 @@ describe("SqliteCatalogue lifecycle", () => { .map(({ kind, derivation }) => `${kind}:${derivation}`) .toSorted(), ).toEqual(["directory:accepted-exact", "file:accepted-exact"]); - expect(facts.boundaries.map(({ address }) => address).toSorted()).toEqual( - [packageAddress, packageAddress], - ); + expect( + facts.boundaries + .map(({ address }) => { + assert(typeof address === "string"); + return address; + }) + .toSorted(), + ).toEqual([packageAddress, packageAddress]); // 只有新文件位置得到本次事件的正项,目录边界不在支持集合里。 expect(facts.supports).toHaveLength(1); expect(facts.supports[0]?.kind).toBe("file"); @@ -6856,8 +7002,9 @@ describe("SqliteCatalogue lifecycle", () => { { start: (application) => application }, ); try { - const reopenedCurrent = - await reopened.result.workflow.listCurrentCatalogue(); + const reopenedCurrent = await reopened.result.workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); expect(reopenedCurrent.map(({ id }) => id)).toEqual([fileAssetId]); await expect( SqliteCatalogue.inspectProductionReadiness({ @@ -6873,7 +7020,7 @@ describe("SqliteCatalogue lifecycle", () => { await rm(data, { recursive: true, force: true }); await rm(media, { recursive: true, force: true }); } - }, 120_000); + }, 120000); it("把已迁移同址形态错配保持在公开当前目录之外", async () => { const { productDataDirectory, manifest } = await migratedOldContractCatalogue( @@ -6889,14 +7036,19 @@ describe("SqliteCatalogue lifecycle", () => { .prepare( "SELECT snapshot_json AS snapshot FROM workflow_executions WHERE snapshot_json IS NOT NULL ORDER BY rowid DESC", ) - .all() as { snapshot: string }[]; + .all() as { + snapshot: string; + }[]; frozenDatabase.close(); const frozenRootSnapshot = frozenSnapshots .map( ({ snapshot }) => JSON.parse(snapshot) as { root: string; - files: { relativePath: string; modifiedAt: string }[]; + files: { + relativePath: string; + modifiedAt: string; + }[]; }, ) .find(({ root }) => root === path.join(manifest.stripRoot, "media")); @@ -6921,7 +7073,9 @@ describe("SqliteCatalogue lifecycle", () => { acceptTrusted: false, }); expect(scan.execution.status).toBe("completed"); - const current = await workflow.listCurrentCatalogue(); + const current = await workflow + .listCurrentCataloguePage({ limit: 100 }) + .then((page) => page.items); // 互换形态的两个地址在公开根完整扫描后仍然退出,只有形态与材料一致的 // 目录包与文件留在当前目录;同址热观察不会把 file 边界升成 directory 支持。 expect(current.map(({ id }) => id).toSorted()).toEqual( @@ -6938,26 +7092,18 @@ describe("SqliteCatalogue lifecycle", () => { ); try { const boundaries = database - .prepare( - `SELECT substr(l.address, instr(l.address, '/media')) AS address, + .prepare(`SELECT substr(l.address, instr(l.address, '/media')) AS address, b.material_kind AS kind, b.derivation_kind AS derivation FROM observed_material_boundaries b INNER JOIN locations l ON l.id = b.location_id - ORDER BY l.address, b.derivation_kind`, - ) - .all() as readonly { - address: string; - kind: string; - derivation: string; - }[]; + ORDER BY l.address, b.derivation_kind`) + .all(); const supportAddresses = database - .prepare( - `SELECT substr(l.address, instr(l.address, '/media')) AS address + .prepare(`SELECT substr(l.address, instr(l.address, '/media')) AS address FROM current_observed_material_supports s INNER JOIN locations l ON l.id = s.location_id - ORDER BY l.address`, - ) - .all() as readonly { address: string }[]; + ORDER BY l.address`) + .all(); // 热观察不会把 fileToDir 的边界升级成 directory,也不会让 pkgToFile 的 // 目录边界因同址新文件而复活:两个边界仍记录各自被接受时的形态。 expect( @@ -6967,10 +7113,12 @@ describe("SqliteCatalogue lifecycle", () => { address === "/media/fileToDir" || address === "/media/pkgToFile", ) - .map( - ({ address, kind, derivation }) => - `${address}:${kind}:${derivation}`, - ), + .map(({ address, kind, derivation }) => { + assert(typeof address === "string"); + assert(typeof kind === "string"); + assert(typeof derivation === "string"); + return `${address}:${kind}:${derivation}`; + }), ).toEqual([ "/media/fileToDir:file:legacy-exact", "/media/pkgToFile:directory:legacy-exact", @@ -6986,8 +7134,7 @@ describe("SqliteCatalogue lifecycle", () => { } finally { await workflow.close(); } - }, 60_000); - + }, 60000); it("pages each current accepted asset once without replaying Workflow History", async () => { const { root, catalogue } = await createCatalogueWithRoot(); for (let index = 0; index < 128; index += 1) { @@ -7034,10 +7181,11 @@ describe("SqliteCatalogue lifecycle", () => { const first = await reopened.listCurrentCataloguePage({ limit: 1 }); expect(first.items.map((item) => item.entity.id)).toEqual(["alpha"]); expect(first.nextCursor).toEqual(expect.any(String)); - + const nextCursor2 = first.nextCursor; + assert.isDefined(nextCursor2); const second = await reopened.listCurrentCataloguePage({ limit: 1, - cursor: first.nextCursor, + cursor: nextCursor2, }); expect(second.items.map((item) => item.entity.id)).toEqual(["beta"]); expect(second.nextCursor).toBeUndefined(); @@ -7061,7 +7209,9 @@ describe("SqliteCatalogue lifecycle", () => { // 先初始化惰性连接,之后只观察两次页面读取。 await catalogue.listCurrentCataloguePage({ limit: 1 }); const nativeDatabase = ( - catalogue as unknown as { readonly database: DatabaseSync } + catalogue as unknown as { + readonly database: DatabaseSync; + } ).database; let selectStatements = 0; nativeDatabase.setAuthorizer((actionCode) => { @@ -7105,14 +7255,18 @@ describe("SqliteCatalogue lifecycle", () => { const first = await catalogue.listCurrentCataloguePage({ limit: 2 }); expect(first.items.map((item) => item.entity.id)).toEqual(["a", "b"]); expect(first.nextCursor).toEqual(expect.any(String)); + const nextCursor3 = first.nextCursor; + assert.isDefined(nextCursor3); const second = await catalogue.listCurrentCataloguePage({ limit: 2, - cursor: first.nextCursor, + cursor: nextCursor3, }); expect(second.items.map((item) => item.entity.id)).toEqual(["c", "d"]); + const nextCursor4 = second.nextCursor; + assert.isDefined(nextCursor4); const third = await catalogue.listCurrentCataloguePage({ limit: 2, - cursor: second.nextCursor, + cursor: nextCursor4, }); expect(third.items.map((item) => item.entity.id)).toEqual(["e", "f"]); expect(third.nextCursor).toBeUndefined(); @@ -7218,7 +7372,7 @@ describe("SqliteCatalogue lifecycle", () => { participants: [ { entityId: assetId, role: "asset" }, { entityId: series.id, role: "series" }, - ], + ] as const, qualifiers: [{ key: "position", value: position }], }, }); @@ -7422,9 +7576,11 @@ describe("SqliteCatalogue lifecycle", () => { }, ]); expect(first.nextCursor).toEqual(expect.any(String)); + const nextCursor5 = first.nextCursor; + assert.isDefined(nextCursor5); const second = await reopened.listWorkflowHistoryPage({ limit: 3, - cursor: first.nextCursor, + cursor: nextCursor5, }); expect(second.items.map((item) => item.execution.id)).toEqual([ "history-124", @@ -7488,7 +7644,9 @@ describe("SqliteCatalogue lifecycle", () => { const reopened = SqliteCatalogue.forProductData(root); await reopened.listWorkflowHistoryPage({ limit: 2 }); const nativeDatabase = ( - reopened as unknown as { readonly database: DatabaseSync } + reopened as unknown as { + readonly database: DatabaseSync; + } ).database; let selectStatements = 0; nativeDatabase.setAuthorizer((actionCode) => { @@ -7589,7 +7747,7 @@ describe("SqliteCatalogue lifecycle", () => { relativeScope: ".", }, enabled: true, - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, workflowInputVersion: "asset-refresh-input/1", runInitial: false, policyReferences: ["policy/1"], @@ -7668,7 +7826,7 @@ describe("SqliteCatalogue lifecycle", () => { relativeScope: ".", }, enabled: true, - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, workflowInputVersion: "asset-refresh-input/1", runInitial: false, policyReferences: ["policy/1"], @@ -7720,7 +7878,7 @@ describe("SqliteCatalogue lifecycle", () => { relativeScope: ".", }, enabled: true, - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, workflowInputVersion: "asset-refresh-input/1", runInitial: false, policyReferences: ["policy/1"], @@ -7735,9 +7893,11 @@ describe("SqliteCatalogue lifecycle", () => { const first = await catalogue.listCurrentAttentionPage({ limit: 1 }); catalogue.close(); const reopened = SqliteCatalogue.forProductData(root); + const nextCursor6 = first.nextCursor; + assert.isDefined(nextCursor6); const second = await reopened.listCurrentAttentionPage({ limit: 1, - cursor: first.nextCursor, + cursor: nextCursor6, }); expect( [...first.items, ...second.items].map( @@ -7763,7 +7923,7 @@ describe("SqliteCatalogue lifecycle", () => { relativeScope: ".", }, enabled: true, - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, workflowInputVersion: "asset-refresh-input/1", runInitial: false, policyReferences: ["policy/1"], @@ -7808,7 +7968,9 @@ describe("SqliteCatalogue lifecycle", () => { "prerequisite-unavailable", ); const database = ( - catalogue as unknown as { readonly database: DatabaseSync } + catalogue as unknown as { + readonly database: DatabaseSync; + } ).database; expect( database @@ -7847,7 +8009,7 @@ describe("SqliteCatalogue lifecycle", () => { do { const next = await reopened.listCurrentAttentionPage({ limit: 1, - cursor, + ...(cursor === undefined ? {} : { cursor }), }); seen.push(...next.items.map((item) => item.id)); cursor = next.nextCursor; @@ -7928,10 +8090,13 @@ describe("SqliteCatalogue lifecycle", () => { do { const page = await catalogue.listCurrentAttentionPage({ limit: 1, - cursor, + ...(cursor === undefined ? {} : { cursor }), }); + assert.isDefined(page); expect(page.items).toHaveLength(1); - seen.push(page.items[0].id); + const firstItem = page.items[0]; + assert.isDefined(firstItem); + seen.push(firstItem.id); cursor = page.nextCursor; } while (cursor !== undefined); expect(seen).toEqual([ @@ -8103,7 +8268,9 @@ describe("SqliteCatalogue lifecycle", () => { const reopened = SqliteCatalogue.forProductData(root); await reopened.listCurrentAttentionPage({ limit: 1 }); const nativeDatabase = ( - reopened as unknown as { readonly database: DatabaseSync } + reopened as unknown as { + readonly database: DatabaseSync; + } ).database; let selectStatements = 0; nativeDatabase.setAuthorizer((actionCode) => { @@ -8141,7 +8308,9 @@ describe("SqliteCatalogue lifecycle", () => { }); await catalogue.listCurrentAttentionPage({ limit: 1 }); const nativeDatabase = ( - catalogue as unknown as { readonly database: DatabaseSync } + catalogue as unknown as { + readonly database: DatabaseSync; + } ).database; let selectStatements = 0; nativeDatabase.setAuthorizer((actionCode) => { @@ -8164,18 +8333,20 @@ describe("SqliteCatalogue lifecycle", () => { const workflowColumns = ( nativeDatabase .prepare("PRAGMA table_xinfo(workflow_executions)") - .all() as Array<{ readonly name: string; readonly hidden: number }> + .all() as Array<{ + readonly name: string; + readonly hidden: number; + }> ).filter(({ hidden }) => hidden === 0); const quotedWorkflowColumns = workflowColumns .map(({ name }) => quoteIdentifier(name)) .join(", "); - const cloneExecution = nativeDatabase.prepare( - `INSERT INTO workflow_executions (${quotedWorkflowColumns}) + const cloneExecution = + nativeDatabase.prepare(`INSERT INTO workflow_executions (${quotedWorkflowColumns}) SELECT ${workflowColumns .map(({ name }) => (name === "id" ? "?" : quoteIdentifier(name))) .join(", ")} - FROM workflow_executions WHERE id = ?`, - ); + FROM workflow_executions WHERE id = ?`); const insertPreview = nativeDatabase.prepare( "INSERT INTO change_impact_previews (id, execution_id, asset_id, preview_json) VALUES (?, ?, ?, ?)", ); @@ -8200,7 +8371,7 @@ describe("SqliteCatalogue lifecycle", () => { }; insertPersistedStale(0, 100); await measure(); - insertPersistedStale(100, 1_000); + insertPersistedStale(100, 1000); await measure(); expect(selectCounts).toHaveLength(3); expect(new Set(selectCounts).size).toBe(1); @@ -8216,17 +8387,17 @@ describe("SqliteCatalogue lifecycle", () => { ]); const previewAccess = ( nativeDatabase - .prepare( - `EXPLAIN QUERY PLAN + .prepare(`EXPLAIN QUERY PLAN SELECT change_impact_previews.id FROM change_impact_previews INNER JOIN workflow_executions ON workflow_executions.id = change_impact_previews.execution_id WHERE change_impact_previews.status = 'awaiting-confirmation' ORDER BY workflow_executions.rowid DESC, change_impact_previews.id ASC - LIMIT 2`, - ) - .all() as Array<{ readonly detail: string }> + LIMIT 2`) + .all() as Array<{ + readonly detail: string; + }> ) .map(({ detail }) => /^(SCAN|SEARCH) (\S+)(?: USING (?:COVERING )?INDEX (\S+))?/u.exec( @@ -8289,7 +8460,9 @@ describe("SqliteCatalogue lifecycle", () => { await expect(list({ limit })).rejects.toThrow(); } for (const cursor of ["not-a-cursor", "e30"]) { - await expect(list({ limit: 1, cursor })).rejects.toThrow(); + await expect( + list({ limit: 1, ...(cursor === undefined ? {} : { cursor }) }), + ).rejects.toThrow(); } } @@ -8561,6 +8734,8 @@ describe("SqliteCatalogue lifecycle", () => { "INSERT INTO change_impact_previews (id, execution_id, asset_id, preview_json) VALUES (?, ?, ?, ?)", ); for (const id of ["B-preview", "a-preview", "é-preview"]) { + const proposal2 = validPreview.proposal; + assert.isDefined(proposal2); insertPreview.run( id, "multi-recognition-review", @@ -8568,7 +8743,10 @@ describe("SqliteCatalogue lifecycle", () => { JSON.stringify({ ...validPreview, id, - proposal: { ...validPreview.proposal, id: `unrelated-${id}` }, + proposal: { + ...proposal2, + id: `unrelated-${id}`, + }, }), ); } @@ -8627,10 +8805,13 @@ describe("SqliteCatalogue lifecycle", () => { do { const page = await reopened.listCurrentAttentionPage({ limit: 1, - cursor, + ...(cursor === undefined ? {} : { cursor }), }); + assert.isDefined(page); expect(page.items).toHaveLength(1); - seen.push(page.items[0].id); + const firstItem2 = page.items[0]; + assert.isDefined(firstItem2); + seen.push(firstItem2.id); cursor = page.nextCursor; } while (cursor !== undefined); expect(seen).toEqual([ @@ -8744,10 +8925,8 @@ describe("SqliteCatalogue lifecycle", () => { try { expect( durableRecognitionHead - .prepare( - `SELECT recognition_source_association_id, recognition_decision_id - FROM recognition_heads WHERE subject_id = ?`, - ) + .prepare(`SELECT recognition_source_association_id, recognition_decision_id + FROM recognition_heads WHERE subject_id = ?`) .get("asset"), ).toEqual({ recognition_source_association_id: "association", @@ -8790,26 +8969,23 @@ describe("SqliteCatalogue lifecycle", () => { ["a-artifact-association", "a-artifact"], ] as const) { database - .prepare( - `INSERT INTO artifact_associations ( + .prepare(`INSERT INTO artifact_associations ( id, target_kind, target_entity_id, subject_id, artifact_id, role, source_record_ids_json - ) VALUES (?, 'entity', ?, ?, ?, ?, '[]')`, - ) + ) VALUES (?, 'entity', ?, ?, ?, ?, '[]')`) .run(id, "asset", "asset", artifactId, "asset.gallery"); } database - .prepare( - `INSERT INTO artifact_association_retractions ( + .prepare(`INSERT INTO artifact_association_retractions ( id, association_id, recognition_decision_id, proposal_id, source_record_ids_json - ) VALUES (?, ?, NULL, ?, '[]')`, - ) + ) VALUES (?, ?, NULL, ?, '[]')`) .run("retract-z-artifact", "z-artifact-association", "proposal"); } finally { database.close(); } const inspection = await catalogue.inspectExecution("execution"); + assert.isDefined(inspection); expect(inspection.accepted).toBeDefined(); const current = await catalogue.listCurrentCatalogue(); expect(current).toEqual([inspection.accepted]); @@ -9011,12 +9187,14 @@ describe("SqliteCatalogue lifecycle", () => { .prepare( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_executions'", ) - .get() as { readonly sql: string }; + .get() as { + readonly sql: string; + }; const indexes = database .prepare( "SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'workflow_executions' AND sql IS NOT NULL ORDER BY name", ) - .all() as readonly { readonly sql: string }[]; + .all(); const damagedSql = table.sql.replace( "DEFAULT 'scan-now'", "DEFAULT 'unexpected-intent'", @@ -9024,7 +9202,11 @@ describe("SqliteCatalogue lifecycle", () => { expect(damagedSql).not.toBe(table.sql); database.exec("DROP TABLE workflow_executions"); database.exec(damagedSql); - for (const index of indexes) database.exec(index.sql); + for (const index of indexes) { + const sql = index.sql; + assert(typeof sql === "string"); + database.exec(sql); + } }, }, { @@ -9274,8 +9456,7 @@ describe("SqliteCatalogue lifecycle", () => { const database = new DatabaseSync(prepared.databasePath); try { const textPrimaryKeys = database - .prepare( - `SELECT m.name AS table_name, p.name AS column_name, + .prepare(`SELECT m.name AS table_name, p.name AS column_name, p."notnull" AS not_null FROM sqlite_master m JOIN pragma_table_info(m.name) p @@ -9283,25 +9464,22 @@ describe("SqliteCatalogue lifecycle", () => { AND m.name NOT LIKE 'sqlite_%' AND upper(p.type) = 'TEXT' AND p.pk > 0 - ORDER BY m.name, p.pk`, - ) - .all() as readonly { - readonly table_name: string; - readonly column_name: string; - readonly not_null: number; - }[]; + ORDER BY m.name, p.pk`) + .all(); expect(textPrimaryKeys).not.toEqual([]); for (const key of textPrimaryKeys) { expect(key.not_null).toBe(1); - expect(() => - database + expect(() => { + const table_name2 = key.table_name; + assert(typeof table_name2 === "string"); + const column_name2 = key.column_name; + assert(typeof column_name2 === "string"); + return database .prepare( - `INSERT INTO ${quoteIdentifier(key.table_name)} (${quoteIdentifier( - key.column_name, - )}) VALUES (NULL)`, + `INSERT INTO ${quoteIdentifier(table_name2)} (${quoteIdentifier(column_name2)}) VALUES (NULL)`, ) - .run(), - ).toThrow(); + .run(); + }).toThrow(); } } finally { database.close(); @@ -9336,11 +9514,9 @@ describe("SqliteCatalogue lifecycle", () => { ) .run("decision", "proposal", "asset", "[]"); database - .prepare( - `INSERT INTO source_association_establishment_supports ( + .prepare(`INSERT INTO source_association_establishment_supports ( association_id, recognition_decision_id, source_association_decision_id - ) VALUES (?, ?, NULL)`, - ) + ) VALUES (?, ?, NULL)`) .run("association", "decision"); database .prepare( @@ -9472,7 +9648,7 @@ describe("SqliteCatalogue lifecycle", () => { ...base.artifactAssociations, { id: "related-observed-association", - target: { kind: "entity", entityId: related.id }, + target: { kind: "entity" as const, entityId: related.id }, artifactId: "related-observed-artifact", role: "asset.primary-cover", sourceRecordIds: [sourceRecord.id], @@ -9485,7 +9661,7 @@ describe("SqliteCatalogue lifecycle", () => { participants: [ { entityId: related.id, role: "asset" }, { entityId: related.id, role: "related" }, - ], + ] as const, type: "asset.contribution", sourceSlot: "source.related.relationship", sourceRecordIds: [sourceRecord.id], @@ -9513,7 +9689,7 @@ describe("SqliteCatalogue lifecycle", () => { ], artifactAssociations: [ expect.objectContaining({ - target: { kind: "entity", entityId: "asset" }, + target: { kind: "entity" as const, entityId: "asset" }, }), ], locations: [ @@ -9536,7 +9712,10 @@ describe("SqliteCatalogue lifecycle", () => { metadataUsageContext: "asset.ordinary", metadata: [ { property: "asset.title", necessity: "required" }, - { property: "asset.related-only-title", necessity: "optional" }, + { + property: "asset.related-only-title", + necessity: "optional" as const, + }, ], relationshipTypes: [ { type: "asset.contribution", necessity: "required" }, @@ -9645,7 +9824,7 @@ describe("SqliteCatalogue lifecycle", () => { associations: [ { id: `managed-association-${id}`, - target: { kind: "entity", entityId: "asset" }, + target: { kind: "entity" as const, entityId: "asset" }, artifactId: `managed-artifact-${id}`, role: "asset.primary-cover", sourceRecordIds: ["record"], @@ -9773,8 +9952,7 @@ describe("SqliteCatalogue lifecycle", () => { try { expect( ledger - .prepare( - `SELECT aa.id FROM artifact_associations aa + .prepare(`SELECT aa.id FROM artifact_associations aa WHERE aa.subject_id = ? AND aa.role = ? AND NOT EXISTS ( SELECT 1 FROM artifact_association_supersessions s @@ -9783,8 +9961,7 @@ describe("SqliteCatalogue lifecycle", () => { AND NOT EXISTS ( SELECT 1 FROM artifact_association_retractions r WHERE r.association_id = aa.id - )`, - ) + )`) .all("asset", "asset.primary-cover"), ).toEqual([{ id: "managed-association-old" }]); expect( @@ -10296,7 +10473,9 @@ describe("SqliteCatalogue lifecycle", () => { const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); const durable = database .prepare("SELECT audit_json FROM workflow_executions WHERE id = ?") - .get(executionId) as { readonly audit_json: string }; + .get(executionId) as { + readonly audit_json: string; + }; const audit = JSON.parse(durable.audit_json) as { readonly payload: Record; }; @@ -10797,6 +10976,11 @@ describe("SqliteCatalogue lifecycle", () => { function execution(): WorkflowExecution { return { id: "execution", + compatibility: { + workflowVersion: "asset-refresh/2", + policyVersion: "composition-fixture/1", + capabilityCoordinates: [], + }, selection: { directory: "/observed" }, snapshot: { id: "snapshot", @@ -10913,11 +11097,9 @@ function insertCurrentSourceAssociation( ) .run(input.id, input.subjectId, input.sourceIdentity, input.nativeAddress); database - .prepare( - `INSERT INTO source_association_change_proposals ( + .prepare(`INSERT INTO source_association_change_proposals ( id, subject_id, kind, payload_json, proposed_by_json, evidence_json - ) VALUES (?, ?, ?, ?, ?, ?)`, - ) + ) VALUES (?, ?, ?, ?, ?, ?)`) .run( proposal.id, proposal.subjectId, @@ -10927,11 +11109,9 @@ function insertCurrentSourceAssociation( JSON.stringify(proposal.evidence), ); database - .prepare( - `INSERT INTO source_association_decisions ( + .prepare(`INSERT INTO source_association_decisions ( id, proposal_id, subject_id, decided_by_kind, decided_by_coordinate - ) VALUES (?, ?, ?, ?, ?)`, - ) + ) VALUES (?, ?, ?, ?, ?)`) .run( decision.id, decision.proposalId, @@ -10942,11 +11122,9 @@ function insertCurrentSourceAssociation( : decision.decidedBy.policyCoordinate, ); database - .prepare( - `INSERT INTO source_association_establishment_supports ( + .prepare(`INSERT INTO source_association_establishment_supports ( association_id, recognition_decision_id, source_association_decision_id - ) VALUES (?, NULL, ?)`, - ) + ) VALUES (?, NULL, ?)`) .run(input.id, decision.id); database .prepare( @@ -10975,7 +11153,7 @@ function fixtureMaterialBoundaries( ]; } -function accepted(): AcceptedRecognition { +function accepted() { const interpretation = { id: "interpretation", producer: { @@ -11055,7 +11233,6 @@ function accepted(): AcceptedRecognition { subjectId: "asset", sourceIdentity: "source", nativeAddress: "https://source.example/work", - proposalId: "proposal", }, sourceAssociations: [ { @@ -11063,7 +11240,6 @@ function accepted(): AcceptedRecognition { subjectId: "asset", sourceIdentity: "source", nativeAddress: "https://source.example/work", - proposalId: "proposal", }, ], recognitionDecision: { @@ -11089,7 +11265,7 @@ function accepted(): AcceptedRecognition { artifactAssociations: [ { id: "artifact-association", - target: { kind: "entity", entityId: "asset" }, + target: { kind: "entity" as const, entityId: "asset" }, artifactId: "artifact", role: "asset.primary-cover", }, @@ -11116,14 +11292,14 @@ function accepted(): AcceptedRecognition { participants: [ { entityId: "asset", role: "asset" }, { entityId: "persona", role: "voice" }, - ], + ] as const, type: "asset.contribution", sourceSlot: "source.credit.voice", sourceRecordIds: ["record"], qualifiers: [{ key: "credit-note", value: "official" }], }, ], - }; + } satisfies AcceptedRecognition; } function acceptedExecution(): WorkflowExecution { @@ -11172,10 +11348,7 @@ function sourceRecordFor(assetId: string): SourceRecord { }; } -function acceptedFor( - assetId: string, - sourceRecordId: string, -): AcceptedRecognition { +function acceptedFor(assetId: string, sourceRecordId: string) { const base = accepted(); const proposalId = `proposal-${assetId}`; const associationId = `association-${assetId}`; @@ -11232,7 +11405,6 @@ function acceptedFor( id: associationId, subjectId: assetId, nativeAddress: `https://source.example/work/${assetId}`, - proposalId, }, sourceAssociations: [ { @@ -11240,7 +11412,6 @@ function acceptedFor( id: associationId, subjectId: assetId, nativeAddress: `https://source.example/work/${assetId}`, - proposalId, }, ], recognitionDecision: { @@ -11304,7 +11475,7 @@ function acceptedFor( })), sourceRecordIds: [sourceRecordId], })), - }; + } satisfies AcceptedRecognition; } /** @@ -11392,11 +11563,15 @@ function attentionPreview(executionId: string): ChangeImpactPreview { function changeImpactApplyInput( preview: ChangeImpactPreview, ): Parameters[0] { + const proposal3 = preview.proposal; + assert.isDefined(proposal3); + const successorDecision = { id: `recognition-decision-${preview.id}`, - proposalId: preview.proposal.id, + proposalId: proposal3.id, assetId: preview.assetId, - acceptedRuleResults: preview.proposal.ruleResults, + acceptedRuleResults: proposal3.ruleResults, + boundaries: proposal3.boundaries, }; const projectedAssociation = preview.projected.recognitionSourceAssociation; return { @@ -11419,7 +11594,6 @@ function changeImpactApplyInput( subjectId: preview.assetId, sourceIdentity: projectedAssociation.sourceIdentity, nativeAddress: projectedAssociation.nativeAddress, - proposalId: preview.proposal.id, }, }), }, @@ -11434,10 +11608,12 @@ async function saveCurrentAttentionPreview( readonly accepted: AcceptedRecognition; }, ): Promise { + const recognitionSourceAssociation2 = + input.accepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation2); const completeBaseline = await catalogue.freezeChangeImpactBaseline({ assetId: input.accepted.entity.id, - recognitionSourceAssociationId: - input.accepted.recognitionSourceAssociation.id, + recognitionSourceAssociationId: recognitionSourceAssociation2.id, recognitionDecisionId: input.accepted.recognitionDecision.id, }); await catalogue.createExecution({ @@ -11446,6 +11622,9 @@ async function saveCurrentAttentionPreview( stage: "completed", outcome: "proposal", }); + const recognitionSourceAssociation3 = + input.accepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation3); await catalogue.saveChangeImpactPreview({ ...attentionPreview(input.executionId), id: input.previewId, @@ -11453,7 +11632,7 @@ async function saveCurrentAttentionPreview( baselines: [ { key: "source-association", - value: input.accepted.recognitionSourceAssociation.id, + value: recognitionSourceAssociation3.id, }, { key: "recognition-decision", @@ -11461,8 +11640,8 @@ async function saveCurrentAttentionPreview( }, { key: "source-records", - value: input.accepted.proposal.basis.interpretation.sourceRecordIds - .toSorted() + value: sourceInterpretation(input.accepted.proposal.basis) + .sourceRecordIds.toSorted() .join(","), }, { key: "complete-current-projection", value: completeBaseline }, @@ -11499,7 +11678,9 @@ async function recoveryWorkflow( "readCurrentInformationSubjectFacts" >; readonly collectorCapabilityVersion?: string; - readonly collectionCalls?: { count: number }; + readonly collectionCalls?: { + count: number; + }; /** * 领域运行业务权威的测试接缝,可模拟应用层检查与取消写入之间的恢复所有者交接。 */ @@ -11706,47 +11887,52 @@ async function recoveryWorkflow( { capability: capability("recovery.interpreter"), canInterpret: ({ sourceRecords }) => sourceRecords.length === 1, - interpret: ({ sourceRecords }) => [ - { - id: "recovery-interpretation", - producer: capability("recovery.interpreter"), - sourceRecordIds: [sourceRecords[0].id], - sourceIdentity: "recovery-source", - sourceAddress: "opaque:recovery-work", - metadata: [ - { - property: "asset.title", - sourceSlot: "recovery.title", - value: "Recovery work", - sourceRecordIds: [sourceRecords[0].id], - }, - ], - sourceArtifacts: [ - { - id: "recovery-cover-candidate", - sourceRecordId: sourceRecords[0].id, - role: "asset.primary-cover", - acquisitionReference: "opaque:recovery-cover", - mediaType: "image/jpeg", - }, - ], - }, - ], - }, - ], - recognizers: [ - { - capability: capability("recovery.recognizer"), - canRecognize: ({ basis }) => basis.kind === "source-interpretation", - recognize: ({ evidence }) => ({ - id: options?.uniqueProposalIds - ? `recovery-proposal-${++proposalSequence}` - : "recovery-proposal", - ruleResults: [ + interpret: ({ sourceRecords }) => { + const firstSourceRecord = sourceRecords[0]; + assert.isDefined(firstSourceRecord); + + return [ { - capabilityId: "recovery.recognizer", - capabilityVersion: "1", - ruleId: "exact", + id: "recovery-interpretation", + producer: capability("recovery.interpreter"), + sourceRecordIds: [firstSourceRecord.id], + sourceIdentity: "recovery-source", + sourceAddress: "opaque:recovery-work", + metadata: [ + { + property: "asset.title", + sourceSlot: "recovery.title", + value: "Recovery work", + sourceRecordIds: [firstSourceRecord.id], + }, + ], + sourceArtifacts: [ + { + id: "recovery-cover-candidate", + sourceRecordId: firstSourceRecord.id, + role: "asset.primary-cover", + acquisitionReference: "opaque:recovery-cover", + mediaType: "image/jpeg", + }, + ], + }, + ]; + }, + }, + ], + recognizers: [ + { + capability: capability("recovery.recognizer"), + canRecognize: ({ basis }) => basis.kind === "source-interpretation", + recognize: ({ evidence }) => ({ + id: options?.uniqueProposalIds + ? `recovery-proposal-${++proposalSequence}` + : "recovery-proposal", + ruleResults: [ + { + capabilityId: "recovery.recognizer", + capabilityVersion: "1", + ruleId: "exact", ruleVersion: "1", outcome: "satisfied", explanation: "exact", @@ -11778,10 +11964,12 @@ async function recoveryWorkflow( ], managedStore: { async stage(content) { + const role2 = content.role; + assert.isDefined(role2); return { candidateId: content.candidateId, sourceRecordId: content.sourceRecordId, - role: content.role, + role: role2, stagedReference: `staged:${content.idempotencyKey}`, digest: sha256(content.bytes), mediaType: content.mediaType, @@ -11880,7 +12068,10 @@ async function recoveryWorkflow( async function createInterruptedRecoverySourceRefreshRun( catalogue: SqliteCatalogue, phase: "acquiring" | "applying" | "reviewing", -): Promise<{ readonly executionId: string; readonly runId: string }> { +): Promise<{ + readonly executionId: string; + readonly runId: string; +}> { const seedId = `recovery-run-seed:${phase}`; const seed = { ...execution(), id: seedId }; const seedAccepted = sourceRunSeedAccepted(); @@ -11967,7 +12158,7 @@ async function createInterruptedRecoverySourceRefreshRun( } /** 一条精确稳定的来源事实使应用夹具无需撤回。 */ -function sourceRunSeedAccepted(): AcceptedRecognition { +function sourceRunSeedAccepted() { const base = accepted(); const interpretation = { ...base.proposal.basis.interpretation, @@ -11991,11 +12182,8 @@ function sourceRunSeedAccepted(): AcceptedRecognition { proposal, recognitionSourceAssociation: { ...base.recognitionSourceAssociation!, - proposalId: proposal.id, }, - sourceAssociations: [ - { ...base.recognitionSourceAssociation!, proposalId: proposal.id }, - ], + sourceAssociations: [{ ...base.recognitionSourceAssociation! }], recognitionDecision: { ...base.recognitionDecision, proposalId: proposal.id, @@ -12013,7 +12201,7 @@ function sourceRunSeedAccepted(): AcceptedRecognition { entityMentions: [], entityResolutionDecisions: [], relationships: [], - }; + } satisfies AcceptedRecognition; } /** 供公开来源丰富恢复路径使用的纯重启夹具。 */ @@ -12051,10 +12239,10 @@ function enrichmentCheckpointRecoveryWorkflow( evidence.sourceIdentity === "surviving-recovery-source", coverageScopeIds: () => ["enrichment-recovery.complete"], async collect(evidence) { - calls.set( - evidence.sourceIdentity, - (calls.get(evidence.sourceIdentity) ?? 0) + 1, - ); + const sourceIdentity2 = evidence.sourceIdentity; + assert.isDefined(sourceIdentity2); + + calls.set(sourceIdentity2, (calls.get(sourceIdentity2) ?? 0) + 1); if (evidence.sourceIdentity === "failed-recovery-source") throw new Error("failed enrichment must not be recollected"); return { @@ -12160,7 +12348,7 @@ function reevaluationWorkflow( omitCover: boolean; explicitPlan?: boolean; sourceRefreshAuthority?: boolean; - collectionBarrier?: () => Promise; + collectionBarrier?: (() => Promise) | undefined; partialSourceRefreshFailure?: boolean; partialSourceRefreshRetry?: boolean; frozenCoverageIds?: string[][]; @@ -12169,7 +12357,7 @@ function reevaluationWorkflow( observedDigest?: string; /** 仅时间戳变化的对象仍可能再次观察到相同字节。 */ stableObservedByteLength?: boolean; - sourceResolvedSubject?: "root" | "alpha" | "beta"; + sourceResolvedSubject?: "root" | "alpha" | "beta" | undefined; sourceSubjectDisplayName?: string; sourceSubjectAlias?: string; omitSourceSubjectRelationship?: boolean; @@ -12257,27 +12445,31 @@ function reevaluationWorkflow( : [ { capability: capability("reevaluation.format-inspector"), - inspect: ({ observedArtifactReference }) => [ - { - kind: "source-address" as const, - sourceIdentity: "reevaluation-source", - sourceAddress: - state.sourceAddress ?? "opaque:reevaluation-work", - explanation: "same source identity from observed format", - producer: capability("reevaluation.format-inspector"), - inspection: { - identifiers: [], - observedArtifactReference, - revision: { - digest: state.observedDigest, - mediaType: "application/octet-stream", - byteLength: 4, + inspect: ({ observedArtifactReference }) => { + const observedDigest2 = state.observedDigest; + assert.isDefined(observedDigest2); + return [ + { + kind: "source-address" as const, + sourceIdentity: "reevaluation-source", + sourceAddress: + state.sourceAddress ?? "opaque:reevaluation-work", + explanation: "same source identity from observed format", + producer: capability("reevaluation.format-inspector"), + inspection: { + identifiers: [], + observedArtifactReference, + revision: { + digest: observedDigest2, + mediaType: "application/octet-stream", + byteLength: 4, + }, + metadata: [], + parts: [], }, - metadata: [], - parts: [], }, - }, - ], + ]; + }, }, ], collectors: [ @@ -12406,7 +12598,9 @@ function reevaluationWorkflow( ], canInterpret: ({ sourceRecords }) => sourceRecords.length === 1, interpret: ({ sourceRecords }) => { - const recordId = sourceRecords[0].id; + const firstSourceRecord4 = sourceRecords[0]; + assert.isDefined(firstSourceRecord4); + const recordId = firstSourceRecord4.id; const sourceResolvedSubject = state.sourceResolvedSubject === "alpha" || state.sourceResolvedSubject === "beta" @@ -12565,7 +12759,7 @@ function reevaluationWorkflow( ? { mentionIds: ["reevaluation-voice-mention"] } : {}), }, - ], + ] as const, subjectRole: "asset", type: "asset.contribution" as const, sourceSlot: "reevaluation.credit.voice", @@ -12577,7 +12771,7 @@ function reevaluationWorkflow( { participants: [ { entityId: "reevaluation-editor", role: "editing" }, - ], + ] as const, subjectRole: "asset", type: "asset.contribution" as const, sourceSlot: "reevaluation.credit.editing", @@ -12597,7 +12791,7 @@ function reevaluationWorkflow( entityId: "reevaluation-persona", role: "related", }, - ], + ] as const, subjectRole: "asset", } : { @@ -12610,7 +12804,7 @@ function reevaluationWorkflow( entityId: "reevaluation-persona", role: "related", }, - ], + ] as const, }), ...(sourceResolvedSubject === undefined ? {} @@ -12641,7 +12835,7 @@ function reevaluationWorkflow( participants: [ { entityId: "journey-series", role: "series" }, { entityId: "journey-performer", role: "performer" }, - ], + ] as const, qualifiers: [ { key: "asset.membership.position", @@ -12657,7 +12851,7 @@ function reevaluationWorkflow( participants: [ { entityId: "journey-series", role: "series" }, { entityId: "journey-performer", role: "performer" }, - ], + ] as const, qualifiers: [ { key: "asset.membership.position", value: "1" }, ], @@ -12670,7 +12864,7 @@ function reevaluationWorkflow( participants: [ { entityId: "journey-alternate", role: "series" }, { entityId: "journey-performer", role: "performer" }, - ], + ] as const, qualifiers: [ { key: "asset.membership.position", @@ -12686,7 +12880,7 @@ function reevaluationWorkflow( participants: [ { entityId: "journey-alternate", role: "series" }, { entityId: "journey-performer", role: "performer" }, - ], + ] as const, qualifiers: [ { key: "asset.membership.position", value: "9" }, ], @@ -12698,7 +12892,7 @@ function reevaluationWorkflow( sourceRecordIds: [recordId], participants: [ { entityId: "journey-series", role: "series" }, - ], + ] as const, qualifiers: [ { key: "asset.membership.position", value: "1" }, ], @@ -12710,7 +12904,7 @@ function reevaluationWorkflow( sourceRecordIds: [recordId], participants: [ { entityId: "journey-alternate", role: "series" }, - ], + ] as const, qualifiers: [ { key: "asset.membership.position", value: "3" }, ], @@ -12800,10 +12994,12 @@ function reevaluationWorkflow( ], managedStore: { async stage(content) { + const role3 = content.role; + assert.isDefined(role3); return { candidateId: content.candidateId, sourceRecordId: content.sourceRecordId, - role: content.role, + role: role3, stagedReference: `staged:${content.sourceRecordId}`, digest: sha256(content.bytes), mediaType: content.mediaType, @@ -12856,7 +13052,9 @@ function reevaluationWorkflow( // 与已部署组合一致:精确主体检查与分页共用同一当前目录成员资格。 currentCatalogueQueries: catalogue, relationshipTraversalQueries: catalogue, - metadataPolicyConfiguration: state.metadataPolicyConfiguration, + ...(state.metadataPolicyConfiguration === undefined + ? {} + : { metadataPolicyConfiguration: state.metadataPolicyConfiguration }), relatedEntityIdentity: catalogue, relationshipDecisionPersistence: catalogue, }); @@ -12920,7 +13118,7 @@ function crossBasisObservedEntityGraph( role: "author", mentionKeys: ["contributor-mention"], }, - ], + ] as const, qualifiers: [], locator: `cross-basis:${graph}:${observedArtifactReference}:contributor`, }, @@ -13095,48 +13293,58 @@ function crossBasisReplacementWorkflow( canInterpret: ({ sourceRecords }) => sourceRecords.length === 1 && sourceRecords[0]?.sourceIdentity === "cross-basis-source", - interpret: ({ sourceRecords }) => [ - { - id: "cross-basis-interpretation", - producer: capability("cross-basis.interpreter"), - sourceRecordIds: [sourceRecords[0].id], - sourceIdentity: "cross-basis-source", - sourceAddress: "opaque:cross-basis-work", - metadata: [ - { - property: "asset.title", - sourceSlot: "cross-basis.source.title", - value: "Source work", - sourceRecordIds: [sourceRecords[0].id], - }, - ], - sourceArtifacts: [], - }, - ], + interpret: ({ sourceRecords }) => { + const firstSourceRecord5 = sourceRecords[0]; + assert.isDefined(firstSourceRecord5); + + return [ + { + id: "cross-basis-interpretation", + producer: capability("cross-basis.interpreter"), + sourceRecordIds: [firstSourceRecord5.id], + sourceIdentity: "cross-basis-source", + sourceAddress: "opaque:cross-basis-work", + metadata: [ + { + property: "asset.title", + sourceSlot: "cross-basis.source.title", + value: "Source work", + sourceRecordIds: [firstSourceRecord5.id], + }, + ], + sourceArtifacts: [], + }, + ]; + }, }, { capability: capability("cross-basis.supplement-interpreter"), canInterpret: ({ sourceRecords }) => sourceRecords.length === 1 && sourceRecords[0]?.sourceIdentity === "cross-basis-supplement", - interpret: ({ sourceRecords }) => [ - { - id: "cross-basis-supplement-interpretation", - producer: capability("cross-basis.supplement-interpreter"), - sourceRecordIds: [sourceRecords[0].id], - sourceIdentity: "cross-basis-supplement", - sourceAddress: "opaque:cross-basis-supplement", - metadata: [ - { - property: "asset.awards", - sourceSlot: "cross-basis.supplement.awards", - value: "Supplement award", - sourceRecordIds: [sourceRecords[0].id], - }, - ], - sourceArtifacts: [], - }, - ], + interpret: ({ sourceRecords }) => { + const firstSourceRecord7 = sourceRecords[0]; + assert.isDefined(firstSourceRecord7); + + return [ + { + id: "cross-basis-supplement-interpretation", + producer: capability("cross-basis.supplement-interpreter"), + sourceRecordIds: [firstSourceRecord7.id], + sourceIdentity: "cross-basis-supplement", + sourceAddress: "opaque:cross-basis-supplement", + metadata: [ + { + property: "asset.awards", + sourceSlot: "cross-basis.supplement.awards", + value: "Supplement award", + sourceRecordIds: [firstSourceRecord7.id], + }, + ], + sourceArtifacts: [], + }, + ]; + }, }, ], recognizers: [ @@ -13268,11 +13476,20 @@ it("replaces recognition basis in both directions through a reopened Application const preview = await catalogue.inspectChangeImpactPreviewForExecution( proposed.execution.id, ); - expect(preview?.proposal.observedEntityGraphs ?? []).toHaveLength(0); + assert.isDefined(preview); + const proposal6 = preview?.proposal; + assert.isDefined(proposal6); + expect(proposal6.observedEntityGraphs ?? []).toHaveLength(0); expect(preview?.status).toBe("awaiting-confirmation"); - expect(preview?.proposal.basis.kind).toBe(successor); - if (preview?.proposal.basis.kind === "source-interpretation") - expect(preview.proposal.basis.interpretation.sourceRecordIds).toEqual([ + const proposal7 = preview?.proposal; + assert.isDefined(proposal7); + expect(proposal7.basis.kind).toBe(successor); + const proposal8 = preview?.proposal; + assert.isDefined(proposal8); + const proposal9 = preview.proposal; + assert.isDefined(proposal9); + if (proposal8.basis.kind === "source-interpretation") + expect(sourceInterpretation(proposal9.basis).sourceRecordIds).toEqual([ "cross-basis-record", ]); expect(preview?.projected.recognitionSourceAssociation).toEqual( @@ -13396,11 +13613,9 @@ it("replaces recognition basis in both directions through a reopened Application ); expect( durableHistory - .prepare( - `SELECT recognition_source_association_id AS associationId, + .prepare(`SELECT recognition_source_association_id AS associationId, recognition_decision_id AS decisionId - FROM recognition_heads WHERE subject_id = ?`, - ) + FROM recognition_heads WHERE subject_id = ?`) .get(before!.entity.id), ).toEqual({ associationId: @@ -13411,10 +13626,8 @@ it("replaces recognition basis in both directions through a reopened Application }); expect( durableHistory - .prepare( - `SELECT 1 FROM recognition_decision_supersessions - WHERE predecessor_decision_id = ? AND successor_decision_id = ?`, - ) + .prepare(`SELECT 1 FROM recognition_decision_supersessions + WHERE predecessor_decision_id = ? AND successor_decision_id = ?`) .get( before!.recognitionDecision.id, after!.accepted.recognitionDecision.id, @@ -13422,10 +13635,8 @@ it("replaces recognition basis in both directions through a reopened Application ).toBeDefined(); expect( durableHistory - .prepare( - `SELECT 1 FROM source_associations - WHERE subject_id = ? AND source_identity = ? AND native_address = ?`, - ) + .prepare(`SELECT 1 FROM source_associations + WHERE subject_id = ? AND source_identity = ? AND native_address = ?`) .get( before!.entity.id, "cross-basis-supplement", @@ -13434,10 +13645,8 @@ it("replaces recognition basis in both directions through a reopened Application ).toBeDefined(); expect( durableHistory - .prepare( - `SELECT 1 FROM metadata_assertions - WHERE subject_id = ? AND value = ? AND source_record_ids_json <> '[]'`, - ) + .prepare(`SELECT 1 FROM metadata_assertions + WHERE subject_id = ? AND value = ? AND source_record_ids_json <> '[]'`) .get(before!.entity.id, "Supplement award"), ).toBeDefined(); durableHistory.close(); @@ -13450,10 +13659,8 @@ it("replaces recognition basis in both directions through a reopened Application const history = new DatabaseSync(path.join(root, "catalogue.sqlite")); expect( history - .prepare( - `SELECT 1 FROM metadata_assertions - WHERE value = ? AND source_record_ids_json <> '[]'`, - ) + .prepare(`SELECT 1 FROM metadata_assertions + WHERE value = ? AND source_record_ids_json <> '[]'`) .get("Source work"), ).toBeDefined(); history.close(); @@ -13476,7 +13683,7 @@ it("adopts only the successor's complete observed graph after SQLite reopen", as const { root, catalogue } = await createCatalogueWithRoot(); const state = { basis: "observed-evidence" as const, - observedGraph: "d1" as const, + observedGraph: "d1" as "d1" | "d2" | "empty", observedGeneration: 1, }; const workflow = crossBasisReplacementWorkflow(catalogue, state); @@ -13497,6 +13704,7 @@ it("adopts only the successor's complete observed graph after SQLite reopen", as }), ]); const observedD1 = (await workflow.findRelatedEntities("Observed D1"))[0]; + assert.isDefined(observedD1); const automaticD1 = (await workflow.inspectCurrentCatalogueEntity( observedD1.id, ))!.relationships.find(({ type }) => type === "asset.contribution")!; @@ -13643,6 +13851,7 @@ it("adopts only the successor's complete observed graph after SQLite reopen", as ); if (successor === "d2") { const observedD2 = (await resumed.findRelatedEntities("Observed D2"))[0]; + assert.isDefined(observedD2); await expect( resumed.inspectCurrentCatalogueEntity(observedD2.id), ).resolves.toMatchObject({ @@ -13668,7 +13877,7 @@ it("公开观察身份只在当前图中参与查找、合并与关系查询", a const { catalogue } = await createCatalogueWithRoot(); const state = { basis: "observed-evidence" as const, - observedGraph: "d1" as const, + observedGraph: "d1" as "d1" | "d2" | "empty", observedGeneration: 1, }; const workflow = crossBasisReplacementWorkflow(catalogue, state); @@ -13686,6 +13895,7 @@ it("公开观察身份只在当前图中参与查找、合并与关系查询", a expect.objectContaining({ label: "Observed D1", identity: "local" }), ]); const d1 = observedD1[0]; + assert.isDefined(d1); await expect( workflow.inspectCurrentCatalogueEntity(d1.id), ).resolves.toMatchObject({ @@ -13758,7 +13968,7 @@ it("公开观察身份只在当前图中参与查找、合并与关系查询", a const historical = await createCatalogueWithRoot(); const historicalState = { basis: "observed-evidence" as const, - observedGraph: "d1" as const, + observedGraph: "d1" as "d1" | "d2" | "empty", observedGeneration: 1, }; const historicalWorkflow = crossBasisReplacementWorkflow( @@ -13775,6 +13985,7 @@ it("公开观察身份只在当前图中参与查找、合并与关系查询", a const historicalD1 = ( await historicalWorkflow.findRelatedEntities("Observed D1") )[0]; + assert.isDefined(historicalD1); const persistentCandidate = await historicalWorkflow.createLocalRelatedEntity({ commitmentId: `historical-observed-identity/candidate:${successorGraph}`, @@ -13832,12 +14043,15 @@ it("公开观察身份只在当前图中参与查找、合并与关系查询", a if (successorGraph === "d2") { const observedD2 = await historicalReopenedWorkflow.findRelatedEntities("Observed D2"); + assert.isDefined(observedD2); expect(observedD2).toEqual([ expect.objectContaining({ label: "Observed D2", identity: "local" }), ]); + const firstObservedD2 = observedD2[0]; + assert.isDefined(firstObservedD2); await expect( historicalReopenedWorkflow.inspectCurrentCatalogueEntity( - observedD2[0].id, + firstObservedD2.id, ), ).resolves.toMatchObject({ relationships: [ @@ -13853,7 +14067,7 @@ it("keeps each observed graph support with its own artifact revision after reope const { root, catalogue } = await createCatalogueWithRoot(); const state = { basis: "observed-evidence" as const, - observedGraph: "d1" as const, + observedGraph: "d1" as "d1" | "d2" | "empty", observedGeneration: 1, observedArtifactReferences: ["left.bin", "right.bin"], }; @@ -13953,7 +14167,7 @@ it("keeps one observed artifact's revision graph members isolated after reopen", participants: [ { entityId: base.entity.id, role: "asset" }, { entityId, role: "author", mentionIds: [mentionId] }, - ], + ] as const, type: "asset.contribution", sourceSlot: "revision-isolation.contributor", sourceRecordIds: [], @@ -14197,7 +14411,9 @@ function multiRecoveryWorkflow( canInterpret: () => true, interpret: ({ sourceRecords, evidence }) => { state.counts.interpret += 1; - const sourceAddress = evidence[0].sourceAddress!; + const firstEvidence = evidence[0]; + assert.isDefined(firstEvidence); + const sourceAddress = firstEvidence.sourceAddress!; const productRecords = sourceAddress === "opaque:query" ? sourceRecords.filter( @@ -14323,10 +14539,12 @@ function multiRecoveryWorkflow( }; }, async stage(content) { + const role4 = content.role; + assert.isDefined(role4); return { candidateId: content.candidateId, sourceRecordId: content.sourceRecordId, - role: content.role, + role: role4, stagedReference: `staged:${content.idempotencyKey}`, digest: sha256(content.bytes), mediaType: content.mediaType, @@ -14464,18 +14682,16 @@ describe("SQLite Asset Refresh persistence", () => { ): Promise => { const bytes = new Uint8Array([revision]); await writeFile(path.join(media, "book.bin"), bytes); - const registrations = product.result.workflow.observedRootRegistrations; - const bound = await registrations.resolveActiveScope({ - registrationId: library.rootIds[0]!, + const firstRootId = library.rootIds[0]; + assert.isDefined(firstRootId); + const observation = await product.result.workflow.scanObservedScope({ + registrationId: firstRootId, relativeScope: ".", - assetLibraryId: library.id, + acceptTrusted: false, }); - let snapshot: NonNullable; - try { - snapshot = await registrations.observeBoundScope(bound.scope); - } finally { - await registrations.closeBoundScope(bound.scope); - } + const snapshot3 = observation.execution.snapshot; + assert.isDefined(snapshot3); + const snapshot = snapshot3; observations.set(revision, snapshot); const parentId = `local-parent-${revision}`; const parentDigest = sha256(bytes); @@ -14668,7 +14884,7 @@ describe("SQLite Asset Refresh persistence", () => { artifactAssociations: [ { id: `local-material-${revision}`, - target: { kind: "entity", entityId: "asset" }, + target: { kind: "entity" as const, entityId: "asset" }, artifactId: parentId, role: "asset.material-delivered", }, @@ -14701,28 +14917,36 @@ describe("SQLite Asset Refresh persistence", () => { id: string, fact: AcceptedRecognition, revision: number, - ): WorkflowExecution => ({ - ...execution(), - id, - selection: { directory: media }, - snapshot: observations.get(revision)!, - workflowInput: initialObservation.execution.workflowInput, - resolvedSelection: initialObservation.execution.resolvedSelection, - status: "completed", - stage: "completed", - outcome: "accepted", - acceptedAssetId: fact.entity.id, - proposalId: fact.proposal.id, - workItems: [ - { - key: "local-book", - relativePaths: ["book.bin"], - outcome: "accepted", - acceptedAssetId: fact.entity.id, - proposalId: fact.proposal.id, - }, - ], - }); + ): WorkflowExecution => { + const workflowInput2 = initialObservation.execution.workflowInput; + assert.isDefined(workflowInput2); + const resolvedSelection2 = + initialObservation.execution.resolvedSelection; + assert.isDefined(resolvedSelection2); + return { + ...execution(), + id, + selection: { directory: media }, + snapshot: observations.get(revision)!, + workflowInput: workflowInput2, + resolvedSelection: resolvedSelection2, + status: "completed", + stage: "completed", + outcome: "accepted", + acceptedAssetId: fact.entity.id, + proposalId: fact.proposal.id, + workItems: [ + { + key: "local-book", + sourceRecordIds: [], + relativePaths: ["book.bin"], + outcome: "accepted", + acceptedAssetId: fact.entity.id, + proposalId: fact.proposal.id, + }, + ], + }; + }; const materialBindings = ( fact: AcceptedRecognition, revision: number, @@ -14764,12 +14988,13 @@ describe("SQLite Asset Refresh persistence", () => { revision: number, ) => { const completed = completeExecution(id, next, revision); - await adapter.createExecution({ + const running = { ...completed, - status: "running", - stage: "observed", - outcome: undefined, - }); + status: "running" as const, + stage: "recognition" as const, + }; + delete running.outcome; + await adapter.createExecution(running); await adapter.refreshAcceptedRecognition({ expectedExecution: await executionBaseline(adapter, id), execution: completed, @@ -14798,12 +15023,13 @@ describe("SQLite Asset Refresh persistence", () => { first, 1, ); - await catalogue.createExecution({ + const initialRunning = { ...initialCompleted, - status: "running", - stage: "observed", - outcome: undefined, - }); + status: "running" as const, + stage: "recognition" as const, + }; + delete initialRunning.outcome; + await catalogue.createExecution(initialRunning); await acceptRecognition(catalogue, { execution: initialCompleted, accepted: first, @@ -14815,11 +15041,9 @@ describe("SQLite Asset Refresh persistence", () => { const lifecycleAfterSecond = new DatabaseSync(cataloguePath); expect( lifecycleAfterSecond - .prepare( - `SELECT assertion_id, predecessor_assertion_id + .prepare(`SELECT assertion_id, predecessor_assertion_id FROM metadata_assertion_lifecycles - WHERE source_identity = 'local-observed'`, - ) + WHERE source_identity = 'local-observed'`) .all(), ).toEqual([ { @@ -14870,19 +15094,19 @@ describe("SQLite Asset Refresh persistence", () => { const historicalCounts = { titles: ( beforeRepeat - .prepare( - `SELECT COUNT(*) AS count FROM metadata_assertions - WHERE property = 'asset.title' AND source_slot = 'epub.opf.title'`, - ) - .get() as { readonly count: number } + .prepare(`SELECT COUNT(*) AS count FROM metadata_assertions + WHERE property = 'asset.title' AND source_slot = 'epub.opf.title'`) + .get() as { + readonly count: number; + } ).count, covers: ( beforeRepeat - .prepare( - `SELECT COUNT(*) AS count FROM artifact_associations - WHERE role = 'asset.primary-cover'`, - ) - .get() as { readonly count: number } + .prepare(`SELECT COUNT(*) AS count FROM artifact_associations + WHERE role = 'asset.primary-cover'`) + .get() as { + readonly count: number; + } ).count, }; beforeRepeat.close(); @@ -14907,21 +15131,21 @@ describe("SQLite Asset Refresh persistence", () => { expect( ( history - .prepare( - `SELECT COUNT(*) AS count FROM metadata_assertions - WHERE property = 'asset.title' AND source_slot = 'epub.opf.title'`, - ) - .get() as { readonly count: number } + .prepare(`SELECT COUNT(*) AS count FROM metadata_assertions + WHERE property = 'asset.title' AND source_slot = 'epub.opf.title'`) + .get() as { + readonly count: number; + } ).count, ).toBe(historicalCounts.titles); expect( ( history - .prepare( - `SELECT COUNT(*) AS count FROM artifact_associations - WHERE role = 'asset.primary-cover'`, - ) - .get() as { readonly count: number } + .prepare(`SELECT COUNT(*) AS count FROM artifact_associations + WHERE role = 'asset.primary-cover'`) + .get() as { + readonly count: number; + } ).count, ).toBe(historicalCounts.covers); history.close(); @@ -14954,7 +15178,9 @@ describe("SQLite Asset Refresh persistence", () => { body: "{}", }; await retainSourceRecords(catalogue, "execution", [record]); - const fact = accepted(); + const fact: { + -readonly [K in keyof AcceptedRecognition]: AcceptedRecognition[K]; + } = accepted(); const parent = { id: "epub-artifact" }; const cover = { id: "cover-artifact" }; fact.artifacts = [parent, cover]; @@ -15069,10 +15295,10 @@ describe("SQLite Asset Refresh persistence", () => { basis: { kind: "source-interpretation" as const, interpretation: { - ...fact.proposal.basis.interpretation, + ...sourceInterpretation(fact.proposal.basis), id: "local-provenance-refresh-interpretation", sourceRecordIds: [refreshRecord.id], - metadata: fact.proposal.basis.interpretation.metadata.map( + metadata: sourceInterpretation(fact.proposal.basis).metadata.map( (metadata) => ({ ...metadata, sourceRecordIds: [refreshRecord.id], @@ -15100,18 +15326,21 @@ describe("SQLite Asset Refresh persistence", () => { relationships: [], artifactAssociations: fact.artifactAssociations.map((association) => ({ ...association, - observedArtifactPart: - association.observedArtifactPart === undefined - ? undefined - : { + ...(association.observedArtifactPart === undefined + ? {} + : { + observedArtifactPart: { ...association.observedArtifactPart, locator: "epub:another-cover", }, + }), })), }; + const recognitionSourceAssociation4 = fact.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation4); const heads = await later.freezeSourceLineageHeads({ assetId: fact.entity.id, - sourceAssociation: fact.recognitionSourceAssociation, + sourceAssociation: recognitionSourceAssociation4, coverageIds: [record.coverage.id], }); await expect( @@ -15182,12 +15411,16 @@ describe("SQLite Asset Refresh persistence", () => { digest: "reevaluated-digest", }; await catalogue.createExecution(reevaluation); + const recognitionSourceAssociation5 = current.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation5); const expectedLineageHeads = await catalogue.freezeSourceLineageHeads({ assetId: current.entity.id, - sourceAssociation: current.recognitionSourceAssociation, + sourceAssociation: recognitionSourceAssociation5, coverageIds: ["complete"], }); await retainSourceRecords(catalogue, "reevaluation", [reevaluatedRecord]); + const recognitionSourceAssociation6 = current.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation6); const refreshed = { ...accepted(), proposal: { @@ -15216,7 +15449,7 @@ describe("SQLite Asset Refresh persistence", () => { }, }, entity: current.entity, - recognitionSourceAssociation: current.recognitionSourceAssociation, + recognitionSourceAssociation: recognitionSourceAssociation6, sourceAssociations: current.sourceAssociations, recognitionDecision: current.recognitionDecision, metadata: [ @@ -15326,9 +15559,11 @@ describe("SQLite Asset Refresh persistence", () => { digest: "atomic-digest", }; await catalogue.createExecution(reevaluation); + const recognitionSourceAssociation7 = current.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation7); const expectedLineageHeads = await catalogue.freezeSourceLineageHeads({ assetId: current.entity.id, - sourceAssociation: current.recognitionSourceAssociation, + sourceAssociation: recognitionSourceAssociation7, coverageIds: ["complete"], }); await retainSourceRecords(catalogue, reevaluation.id, [nextRecord]); @@ -15340,7 +15575,7 @@ describe("SQLite Asset Refresh persistence", () => { basis: { kind: "source-interpretation", interpretation: { - ...current.proposal.basis.interpretation, + ...sourceInterpretation(current.proposal.basis), id: "atomic-interpretation", sourceRecordIds: [nextRecord.id], metadata: [ @@ -15365,8 +15600,9 @@ describe("SQLite Asset Refresh persistence", () => { entityResolutionDecisions: [], relationships: [ { - id: "atomic-relationship", - participants: [{ entityId: "atomic-persona", role: "voice" }], + participants: [ + { entityId: "atomic-persona", role: "voice" }, + ] as const, subjectRole: "asset", type: "asset.contribution", sourceSlot: "source.credit.voice", @@ -15379,7 +15615,7 @@ describe("SQLite Asset Refresh persistence", () => { }, metadata: [ { - target: { kind: "entity", entityId: current.entity.id }, + target: { kind: "entity" as const, entityId: current.entity.id }, property: "asset.title", sourceSlot: "source.title", value: "Atomic replacement", @@ -15404,7 +15640,7 @@ describe("SQLite Asset Refresh persistence", () => { participants: [ { entityId: current.entity.id, role: "asset" }, { entityId: "atomic-persona", role: "voice" }, - ], + ] as const, type: "asset.contribution", sourceSlot: "source.credit.voice", sourceRecordIds: [nextRecord.id], @@ -15465,13 +15701,12 @@ describe("SQLite Asset Refresh persistence", () => { locations: count("locations"), associations: count("artifact_associations"), }; - database.exec( - `CREATE TRIGGER fail_atomic_association + database.exec(`CREATE TRIGGER fail_atomic_association BEFORE INSERT ON artifact_associations WHEN NEW.id = 'atomic-association' - BEGIN SELECT RAISE(ABORT, 'injected artifact association failure'); END`, - ); - + BEGIN SELECT RAISE(ABORT, 'injected artifact association failure'); END`); + const firstWorkItem = reevaluation.workItems[0]; + assert.isDefined(firstWorkItem); await expect( catalogue.refreshAcceptedRecognition({ expectedExecution: await executionBaseline(catalogue, reevaluation.id), @@ -15484,7 +15719,7 @@ describe("SQLite Asset Refresh persistence", () => { proposalId: next.proposal.id, workItems: [ { - ...reevaluation.workItems[0], + ...firstWorkItem, proposalId: next.proposal.id, acceptedAssetId: current.entity.id, outcome: "accepted", @@ -15512,10 +15747,8 @@ describe("SQLite Asset Refresh persistence", () => { }).toEqual(before); expect( database - .prepare( - `SELECT source_record_id FROM source_lineage_heads - WHERE subject_id = ? AND coverage_id = ?`, - ) + .prepare(`SELECT source_record_id FROM source_lineage_heads + WHERE subject_id = ? AND coverage_id = ?`) .get(current.entity.id, "complete"), ).toEqual({ source_record_id: initialRecord.id }); database.close(); @@ -15581,6 +15814,8 @@ describe("SQLite Asset Refresh persistence", () => { }, }, }; + const firstArtifactAssociation = accepted().artifactAssociations[0]; + assert.isDefined(firstArtifactAssociation); await expect( acceptRecognition(catalogue, { execution: { @@ -15600,7 +15835,6 @@ describe("SQLite Asset Refresh persistence", () => { subjectId: "collision-asset", sourceIdentity: "source", nativeAddress: "https://source.example/work", - proposalId: proposal.id, }, sourceAssociations: [ { @@ -15608,7 +15842,6 @@ describe("SQLite Asset Refresh persistence", () => { subjectId: "collision-asset", sourceIdentity: "source", nativeAddress: "https://source.example/work", - proposalId: proposal.id, }, ], recognitionDecision: { @@ -15625,8 +15858,8 @@ describe("SQLite Asset Refresh persistence", () => { relationships: [], artifactAssociations: [ { - ...accepted().artifactAssociations[0], - target: { kind: "entity", entityId: "collision-asset" }, + ...firstArtifactAssociation, + target: { kind: "entity" as const, entityId: "collision-asset" }, }, ], }, @@ -15714,24 +15947,22 @@ describe("SQLite Asset Refresh persistence", () => { const identityBeforeDatabase = new DatabaseSync( path.join(root, "catalogue.sqlite"), ); + assert.isDefined(initialAccepted); + const recognitionSourceAssociation8 = + initialAccepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation8); const identityBefore = { association: identityBeforeDatabase - .prepare( - `SELECT id, subject_id, source_identity, native_address - FROM source_associations WHERE id = ?`, - ) - .get(initialAccepted!.recognitionSourceAssociation.id), + .prepare(`SELECT id, subject_id, source_identity, native_address + FROM source_associations WHERE id = ?`) + .get(recognitionSourceAssociation8.id), decision: identityBeforeDatabase - .prepare( - `SELECT id, proposal_id, asset_id, accepted_rule_results_json - FROM recognition_decisions WHERE id = ?`, - ) + .prepare(`SELECT id, proposal_id, asset_id, accepted_rule_results_json + FROM recognition_decisions WHERE id = ?`) .get(initialAccepted!.recognitionDecision.id), head: identityBeforeDatabase - .prepare( - `SELECT subject_id, recognition_source_association_id, recognition_decision_id - FROM recognition_heads WHERE subject_id = ?`, - ) + .prepare(`SELECT subject_id, recognition_source_association_id, recognition_decision_id + FROM recognition_heads WHERE subject_id = ?`) .get(initialAccepted!.entity.id), }; identityBeforeDatabase.close(); @@ -15821,16 +16052,16 @@ describe("SQLite Asset Refresh persistence", () => { ( database .prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${predicate}`) - .get() as { readonly count: number } + .get() as { + readonly count: number; + } ).count; const observedArtifacts = database - .prepare( - `SELECT DISTINCT artifact_id + .prepare(`SELECT DISTINCT artifact_id FROM locations WHERE kind = 'observed' AND address = '/observed/reevaluation/work.bin' - ORDER BY artifact_id`, - ) - .all() as readonly { readonly artifact_id: string }[]; + ORDER BY artifact_id`) + .all(); expect(observedArtifacts).toHaveLength(2); expect(observedArtifacts).toEqual( expect.arrayContaining([ @@ -15847,48 +16078,42 @@ describe("SQLite Asset Refresh persistence", () => { ), ).toBe(2); const artifactsSharingObservedBlob = database - .prepare( - `SELECT DISTINCT artifact_id + .prepare(`SELECT DISTINCT artifact_id FROM artifact_revisions WHERE blob_digest = 'reevaluation-shared-observed-digest' - ORDER BY artifact_id`, - ) + ORDER BY artifact_id`) .all(); expect(artifactsSharingObservedBlob).toEqual(observedArtifacts); expect(count("entity_kind_assertions", "kind = 'digital-asset'")).toBe(1); expect(count("source_associations")).toBe(1); expect(count("recognition_decisions")).toBe(1); + assert.isDefined(initialAccepted); + const recognitionSourceAssociation9 = + initialAccepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation9); expect( database - .prepare( - `SELECT id, subject_id, source_identity, native_address - FROM source_associations WHERE id = ?`, - ) - .get(initialAccepted!.recognitionSourceAssociation.id), + .prepare(`SELECT id, subject_id, source_identity, native_address + FROM source_associations WHERE id = ?`) + .get(recognitionSourceAssociation9.id), ).toEqual(identityBefore.association); expect( database - .prepare( - `SELECT id, proposal_id, asset_id, accepted_rule_results_json - FROM recognition_decisions WHERE id = ?`, - ) + .prepare(`SELECT id, proposal_id, asset_id, accepted_rule_results_json + FROM recognition_decisions WHERE id = ?`) .get(initialAccepted!.recognitionDecision.id), ).toEqual(identityBefore.decision); expect( database - .prepare( - `SELECT subject_id, recognition_source_association_id, recognition_decision_id - FROM recognition_heads WHERE subject_id = ?`, - ) + .prepare(`SELECT subject_id, recognition_source_association_id, recognition_decision_id + FROM recognition_heads WHERE subject_id = ?`) .get(initialAccepted!.entity.id), ).toEqual(identityBefore.head); expect( ( database - .prepare( - `SELECT COUNT(*) AS count FROM recognition_decision_supersessions - WHERE predecessor_decision_id = ?`, - ) + .prepare(`SELECT COUNT(*) AS count FROM recognition_decision_supersessions + WHERE predecessor_decision_id = ?`) .get(initialAccepted!.recognitionDecision.id) as { readonly count: number; } @@ -15896,33 +16121,25 @@ describe("SQLite Asset Refresh persistence", () => { ).toBe(0); expect( database - .prepare( - `SELECT predecessor_assertion_id FROM metadata_assertion_lifecycles - WHERE predecessor_assertion_id IS NOT NULL`, - ) + .prepare(`SELECT predecessor_assertion_id FROM metadata_assertion_lifecycles + WHERE predecessor_assertion_id IS NOT NULL`) .get(), ).toBeDefined(); expect( database - .prepare( - `SELECT predecessor_relationship_id FROM relationship_lifecycles - WHERE predecessor_relationship_id IS NOT NULL`, - ) + .prepare(`SELECT predecessor_relationship_id FROM relationship_lifecycles + WHERE predecessor_relationship_id IS NOT NULL`) .get(), ).toBeUndefined(); const associationLifecycle = database - .prepare( - `SELECT successor_association_id + .prepare(`SELECT successor_association_id FROM artifact_association_supersessions - WHERE predecessor_association_id = ?`, - ) + WHERE predecessor_association_id = ?`) .get(predecessor!.id) ?? database - .prepare( - `SELECT id FROM artifact_association_retractions - WHERE association_id = ?`, - ) + .prepare(`SELECT id FROM artifact_association_retractions + WHERE association_id = ?`) .get(predecessor!.id); expect(associationLifecycle).toBeDefined(); expect( @@ -15976,12 +16193,14 @@ describe("SQLite Asset Refresh persistence", () => { initiatedBy: "user-1", }, }); + const recognitionSourceAssociation10 = current.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation10); expect(refreshed.sourceRefreshRuns).toMatchObject([ { initiatedBy: "user-1", requestedSelection: { subjectId: current!.entity.id, - sourceAssociationId: current!.recognitionSourceAssociation.id, + sourceAssociationId: recognitionSourceAssociation10.id, }, phase: "terminal", outcome: "succeeded", @@ -16036,7 +16255,7 @@ describe("SQLite Asset Refresh persistence", () => { participants: [ { entityId: subject.entity.id, role: "member" }, { entityId: local.id, role: "performer" }, - ], + ] as const, qualifiers: [{ key: "asset.membership.position", value: "2" }], }, }; @@ -16173,6 +16392,9 @@ describe("SQLite Asset Refresh persistence", () => { phase: "reviewing", changeSets: [{ disposition: "pending" }], }); + assert.isDefined(run); + const firstChangeSet = run.changeSets[0]; + assert.isDefined(firstChangeSet); await expect( catalogue.listCurrentAttentionPage({ limit: 10 }), ).resolves.toMatchObject({ @@ -16181,15 +16403,17 @@ describe("SQLite Asset Refresh persistence", () => { kind: "source-refresh-review", executionId: awaitingReview.execution.id, assetId: current!.entity.id, - sourceRefreshChangeSetId: run!.changeSets[0].id, + sourceRefreshChangeSetId: firstChangeSet.id, }, ], }); - + assert.isDefined(run); + const firstChangeSet2 = run.changeSets[0]; + assert.isDefined(firstChangeSet2); await expect( workflow.reviewSourceRefreshChangeSet({ runId: run!.id, - changeSetId: run!.changeSets[0].id, + changeSetId: firstChangeSet2.id, disposition: "approved", }), ).resolves.toMatchObject({ phase: "terminal", outcome: "succeeded" }); @@ -16209,12 +16433,15 @@ describe("SQLite Asset Refresh persistence", () => { }, sourceRefreshRuns: [{ phase: "terminal", outcome: "succeeded" }], }); + assert.isDefined(run); + const firstChangeSet3 = run.changeSets[0]; + assert.isDefined(firstChangeSet3); expect( (await catalogue.listCurrentAttentionPage({ limit: 10 })).items, ).not.toEqual( expect.arrayContaining([ expect.objectContaining({ - sourceRefreshChangeSetId: run!.changeSets[0].id, + sourceRefreshChangeSetId: firstChangeSet3.id, }), ]), ); @@ -16255,21 +16482,26 @@ describe("SQLite Asset Refresh persistence", () => { }); const run = awaitingReview.sourceRefreshRuns?.[0]; expect(awaitingReview.execution.status).toBe("running"); + assert.isDefined(run); + const firstChangeSet4 = run.changeSets[0]; + assert.isDefined(firstChangeSet4); expect( (await catalogue.listCurrentAttentionPage({ limit: 10 })).items, ).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "source-refresh-review", - sourceRefreshChangeSetId: run!.changeSets[0].id, + sourceRefreshChangeSetId: firstChangeSet4.id, }), ]), ); - + assert.isDefined(run); + const firstChangeSet5 = run.changeSets[0]; + assert.isDefined(firstChangeSet5); await expect( workflow.reviewSourceRefreshChangeSet({ runId: run!.id, - changeSetId: run!.changeSets[0].id, + changeSetId: firstChangeSet5.id, disposition: "rejected", }), ).resolves.toMatchObject({ phase: "terminal", outcome: "failed" }); @@ -16289,12 +16521,15 @@ describe("SQLite Asset Refresh persistence", () => { }, ], }); + assert.isDefined(run); + const firstChangeSet6 = run.changeSets[0]; + assert.isDefined(firstChangeSet6); expect( (await catalogue.listCurrentAttentionPage({ limit: 10 })).items, ).not.toEqual( expect.arrayContaining([ expect.objectContaining({ - sourceRefreshChangeSetId: run!.changeSets[0].id, + sourceRefreshChangeSetId: firstChangeSet6.id, }), ]), ); @@ -16335,13 +16570,16 @@ describe("SQLite Asset Refresh persistence", () => { }); const run = awaitingReview.sourceRefreshRuns?.[0]; expect(awaitingReview.execution.status).toBe("running"); + assert.isDefined(run); + const firstChangeSet7 = run.changeSets[0]; + assert.isDefined(firstChangeSet7); expect( (await catalogue.listCurrentAttentionPage({ limit: 10 })).items, ).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "source-refresh-review", - sourceRefreshChangeSetId: run!.changeSets[0].id, + sourceRefreshChangeSetId: firstChangeSet7.id, }), ]), ); @@ -16368,12 +16606,15 @@ describe("SQLite Asset Refresh persistence", () => { }, ], }); + assert.isDefined(run); + const firstChangeSet8 = run.changeSets[0]; + assert.isDefined(firstChangeSet8); expect( (await catalogue.listCurrentAttentionPage({ limit: 10 })).items, ).not.toEqual( expect.arrayContaining([ expect.objectContaining({ - sourceRefreshChangeSetId: run!.changeSets[0].id, + sourceRefreshChangeSetId: firstChangeSet8.id, }), ]), ); @@ -16547,11 +16788,13 @@ describe("SQLite Asset Refresh persistence", () => { outcome: "accepted", }, }); - + assert.isDefined(run); + const firstChangeSet9 = run.changeSets[0]; + assert.isDefined(firstChangeSet9); await expect( workflow.reviewSourceRefreshChangeSet({ runId: run!.id, - changeSetId: run!.changeSets[0].id, + changeSetId: firstChangeSet9.id, disposition: "approved", }), ).resolves.toMatchObject({ @@ -16884,6 +17127,7 @@ describe("SQLite Asset Refresh persistence", () => { executionId: "source-refresh-normalized-facts-initial", acceptTrusted: true, }); + assert.isDefined(initial); const current = await scanAccepted(catalogue, initial.execution.id); expect(current).toBeDefined(); const selection = { @@ -16899,6 +17143,7 @@ describe("SQLite Asset Refresh persistence", () => { state.stableSourceFacts = true; const firstStable = await workflow.refreshMetadata({ selection }); + assert.isDefined(firstStable); expect(firstStable).toMatchObject({ execution: { status: "completed", outcome: "accepted" }, }); @@ -16917,12 +17162,19 @@ describe("SQLite Asset Refresh persistence", () => { }; const repeated = await workflow.refreshMetadata({ selection }); + assert.isDefined(repeated); + const firstSourceRecord9 = firstStable.sourceRecords[0]; + assert.isDefined(firstSourceRecord9); + const firstSourceRecord10 = repeated.sourceRecords[0]; + assert.isDefined(firstSourceRecord10); const stableRecordIds = [ - firstStable.sourceRecords[0].id, - repeated.sourceRecords[0].id, + firstSourceRecord9.id, + firstSourceRecord10.id, ].toSorted((left, right) => left.localeCompare(right)); + const firstSourceRecord11 = initial.sourceRecords[0]; + assert.isDefined(firstSourceRecord11); const stableRelationshipRecordIds = [ - initial.sourceRecords[0].id, + firstSourceRecord11.id, ...stableRecordIds, ].toSorted((left, right) => left.localeCompare(right)); const after = { @@ -16932,38 +17184,40 @@ describe("SQLite Asset Refresh persistence", () => { relationshipLifecycles: count("relationship_lifecycles"), }; const lineage = database - .prepare( - `SELECT source_record_id FROM source_lineage_heads + .prepare(`SELECT source_record_id FROM source_lineage_heads WHERE subject_id = ? AND source_identity = ? AND native_address = ? - AND coverage_id = ?`, - ) + AND coverage_id = ?`) .get( current!.entity.id, "reevaluation-source", "opaque:reevaluation-work", "reevaluation.complete", - ) as { readonly source_record_id: string } | undefined; + ) as + | { + readonly source_record_id: string; + } + | undefined; const metadataProvenanceCount = ( database - .prepare( - `SELECT COUNT(*) AS count + .prepare(`SELECT COUNT(*) AS count FROM metadata_assertion_sources provenance JOIN metadata_assertion_lifecycles lifecycle ON lifecycle.assertion_id = provenance.assertion_id - WHERE lifecycle.source_slot = ?`, - ) - .get("reevaluation.title") as { readonly count: number } + WHERE lifecycle.source_slot = ?`) + .get("reevaluation.title") as { + readonly count: number; + } ).count; const relationshipProvenanceCount = ( database - .prepare( - `SELECT COUNT(*) AS count + .prepare(`SELECT COUNT(*) AS count FROM relationship_sources provenance JOIN relationship_lifecycles lifecycle ON lifecycle.relationship_id = provenance.relationship_id - WHERE lifecycle.source_slot = ?`, - ) - .get("reevaluation.credit.voice") as { readonly count: number } + WHERE lifecycle.source_slot = ?`) + .get("reevaluation.credit.voice") as { + readonly count: number; + } ).count; database.close(); @@ -17081,23 +17335,17 @@ describe("SQLite Asset Refresh persistence", () => { metadata: count( "SELECT COUNT(*) AS count FROM metadata_assertions WHERE subject_id = ?", ), - metadataLifecycles: count( - `SELECT COUNT(*) AS count + metadataLifecycles: count(`SELECT COUNT(*) AS count FROM metadata_assertion_lifecycles lifecycle JOIN metadata_assertions assertion ON assertion.id = lifecycle.assertion_id - WHERE assertion.subject_id = ?`, - ), - relationships: count( - `SELECT COUNT(*) AS count + WHERE assertion.subject_id = ?`), + relationships: count(`SELECT COUNT(*) AS count FROM relationship_lifecycles lifecycle - WHERE lifecycle.source_subject_id = ?`, - ), - relationshipLifecycles: count( - `SELECT COUNT(*) AS count + WHERE lifecycle.source_subject_id = ?`), + relationshipLifecycles: count(`SELECT COUNT(*) AS count FROM relationship_lifecycles lifecycle JOIN entity_relationships relationship ON relationship.id = lifecycle.relationship_id - WHERE lifecycle.source_subject_id = ?`, - ), + WHERE lifecycle.source_subject_id = ?`), }; database.close(); return counts; @@ -17109,17 +17357,19 @@ describe("SQLite Asset Refresh persistence", () => { const after = subjectFactCounts(); const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); const lineage = database - .prepare( - `SELECT source_record_id FROM source_lineage_heads + .prepare(`SELECT source_record_id FROM source_lineage_heads WHERE subject_id = ? AND source_identity = ? AND native_address = ? - AND coverage_id = ?`, - ) + AND coverage_id = ?`) .get( current!.entity.id, "reevaluation-source", "opaque:reevaluation-work", "reevaluation.complete", - ) as { readonly source_record_id: string } | undefined; + ) as + | { + readonly source_record_id: string; + } + | undefined; database.close(); expect(before).toEqual({ @@ -17188,8 +17438,8 @@ describe("SQLite Asset Refresh persistence", () => { } ).count; const counts = { - metadata: count( - `SELECT COUNT(*) AS count FROM metadata_assertions assertion + metadata: + count(`SELECT COUNT(*) AS count FROM metadata_assertions assertion WHERE assertion.subject_id = ? AND NOT EXISTS ( SELECT 1 FROM metadata_assertion_lifecycles successor @@ -17198,10 +17448,9 @@ describe("SQLite Asset Refresh persistence", () => { AND NOT EXISTS ( SELECT 1 FROM metadata_assertion_retractions retraction WHERE retraction.assertion_id = assertion.id - )`, - ), - relationships: count( - `SELECT COUNT(*) AS count FROM entity_relationships relationship + )`), + relationships: + count(`SELECT COUNT(*) AS count FROM entity_relationships relationship JOIN relationship_lifecycles lifecycle ON lifecycle.relationship_id = relationship.id WHERE lifecycle.source_subject_id = ? @@ -17212,8 +17461,7 @@ describe("SQLite Asset Refresh persistence", () => { AND NOT EXISTS ( SELECT 1 FROM relationship_retractions retraction WHERE retraction.relationship_id = relationship.id - )`, - ), + )`), }; database.close(); return counts; @@ -17249,11 +17497,13 @@ describe("SQLite Asset Refresh persistence", () => { ], }); expect(currentFactCounts()).toEqual({ metadata: 1, relationships: 1 }); - + assert.isDefined(run); + const firstChangeSet10 = run.changeSets[0]; + assert.isDefined(firstChangeSet10); await expect( workflow.reviewSourceRefreshChangeSet({ runId: run!.id, - changeSetId: run!.changeSets[0].id, + changeSetId: firstChangeSet10.id, disposition: "approved", }), ).resolves.toMatchObject({ phase: "terminal", outcome: "succeeded" }); @@ -17306,8 +17556,7 @@ describe("SQLite Asset Refresh persistence", () => { } ).count; const facts = { - metadata: count( - `SELECT COUNT(*) AS count + metadata: count(`SELECT COUNT(*) AS count FROM metadata_assertion_lifecycles lifecycle JOIN metadata_assertions assertion ON assertion.id = lifecycle.assertion_id WHERE assertion.subject_id = ? @@ -17319,10 +17568,8 @@ describe("SQLite Asset Refresh persistence", () => { AND NOT EXISTS ( SELECT 1 FROM metadata_assertion_retractions retraction WHERE retraction.assertion_id = assertion.id - )`, - ), - relationships: count( - `SELECT COUNT(*) AS count + )`), + relationships: count(`SELECT COUNT(*) AS count FROM relationship_lifecycles lifecycle JOIN entity_relationships relationship ON relationship.id = lifecycle.relationship_id WHERE lifecycle.source_subject_id = ? @@ -17334,8 +17581,7 @@ describe("SQLite Asset Refresh persistence", () => { AND NOT EXISTS ( SELECT 1 FROM relationship_retractions retraction WHERE retraction.relationship_id = relationship.id - )`, - ), + )`), }; database.close(); return facts; @@ -17346,13 +17592,15 @@ describe("SQLite Asset Refresh persistence", () => { ReturnType >["sourceRefreshRuns"] >[number], - ) => - await workflow.reviewSourceRefreshChangeSet({ + ) => { + const firstChangeSet11 = run.changeSets[0]; + assert.isDefined(firstChangeSet11); + return await workflow.reviewSourceRefreshChangeSet({ runId: run.id, - changeSetId: run.changeSets[0].id, + changeSetId: firstChangeSet11.id, disposition: "approved", }); - + }; expect(currentSlotFacts(current!.entity.id)).toEqual({ metadata: 1, relationships: 1, @@ -17456,24 +17704,20 @@ describe("SQLite Asset Refresh persistence", () => { const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); expect( database - .prepare( - `SELECT lifecycle.predecessor_assertion_id + .prepare(`SELECT lifecycle.predecessor_assertion_id FROM metadata_assertion_lifecycles lifecycle JOIN metadata_assertions assertion ON assertion.id = lifecycle.assertion_id WHERE assertion.subject_id = ? - AND lifecycle.source_slot = 'reevaluation.source-subject.name'`, - ) + AND lifecycle.source_slot = 'reevaluation.source-subject.name'`) .get("reevaluation-source-subject-beta"), ).toEqual({ predecessor_assertion_id: null }); expect( database - .prepare( - `SELECT lifecycle.predecessor_relationship_id + .prepare(`SELECT lifecycle.predecessor_relationship_id FROM relationship_lifecycles lifecycle JOIN entity_relationships relationship ON relationship.id = lifecycle.relationship_id WHERE lifecycle.source_subject_id = ? - AND lifecycle.source_slot = 'reevaluation.source-subject.association'`, - ) + AND lifecycle.source_slot = 'reevaluation.source-subject.association'`) .get("reevaluation-source-subject-beta"), ).toEqual({ predecessor_relationship_id: null }); database.close(); @@ -17533,7 +17777,9 @@ describe("SQLite Asset Refresh persistence", () => { .prepare( `SELECT COUNT(*) AS count FROM ${table} WHERE ${predicate}`, ) - .get() as { readonly count: number } + .get() as { + readonly count: number; + } ).count, ).toBe(1); } @@ -17632,9 +17878,15 @@ describe("SQLite Asset Refresh persistence", () => { informationRequirement: requirement, }); expect(initial.execution.outcome).toBe("accepted"); - const initialAccepted = (await catalogue.inspectExecution( + const inspectExecutionResult = await catalogue.inspectExecution( initial.execution.id, - ))!.workItemInspections![0].accepted!; + ); + assert.isDefined(inspectExecutionResult); + const workItemInspections2 = inspectExecutionResult.workItemInspections; + assert.isDefined(workItemInspections2); + const firstWorkItemInspections2 = workItemInspections2![0]; + assert.isDefined(firstWorkItemInspections2); + const initialAccepted = firstWorkItemInspections2.accepted!; const predecessor = initialAccepted.artifactAssociations.find( (association) => association.role === "asset.primary-cover", )!; @@ -17656,9 +17908,15 @@ describe("SQLite Asset Refresh persistence", () => { expect(refreshed.execution.workItems?.[0]?.reason).toBeUndefined(); expect(refreshed.execution.outcome).toBe("accepted"); expect(state.sourceVersion).toBe(2); - const refreshedAccepted = (await catalogue.inspectExecution( + const inspectExecutionResult2 = await catalogue.inspectExecution( refreshed.execution.id, - ))!.workItemInspections![0].accepted!; + ); + assert.isDefined(inspectExecutionResult2); + const workItemInspections3 = inspectExecutionResult2.workItemInspections; + assert.isDefined(workItemInspections3); + const firstWorkItemInspections3 = workItemInspections3![0]; + assert.isDefined(firstWorkItemInspections3); + const refreshedAccepted = firstWorkItemInspections3.accepted!; const successor = refreshedAccepted.artifactAssociations.find( (association) => association.role === "asset.primary-cover", )!; @@ -17666,23 +17924,18 @@ describe("SQLite Asset Refresh persistence", () => { const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); const associationLifecycle = database - .prepare( - `SELECT successor_association_id + .prepare(`SELECT successor_association_id FROM artifact_association_supersessions - WHERE predecessor_association_id = ?`, - ) + WHERE predecessor_association_id = ?`) .get(predecessor.id) ?? database - .prepare( - `SELECT id FROM artifact_association_retractions - WHERE association_id = ?`, - ) + .prepare(`SELECT id FROM artifact_association_retractions + WHERE association_id = ?`) .get(predecessor.id); expect(associationLifecycle).toBeDefined(); expect( database - .prepare( - `SELECT COUNT(*) AS count + .prepare(`SELECT COUNT(*) AS count FROM artifact_associations aa WHERE aa.role = 'asset.primary-cover' AND NOT EXISTS ( @@ -17692,8 +17945,7 @@ describe("SQLite Asset Refresh persistence", () => { AND NOT EXISTS ( SELECT 1 FROM artifact_association_retractions r WHERE r.association_id = aa.id - )`, - ) + )`) .get(), ).toEqual({ count: 1 }); database.close(); @@ -17726,8 +17978,15 @@ describe("SQLite Asset Refresh persistence", () => { relationshipTypes: [], }, }); - const previous = (await catalogue.inspectExecution(initial.execution.id))! - .workItemInspections![0].accepted!; + const inspectExecutionResult3 = await catalogue.inspectExecution( + initial.execution.id, + ); + assert.isDefined(inspectExecutionResult3); + const workItemInspections4 = inspectExecutionResult3.workItemInspections; + assert.isDefined(workItemInspections4); + const firstWorkItemInspections4 = workItemInspections4![0]; + assert.isDefined(firstWorkItemInspections4); + const previous = firstWorkItemInspections4.accepted!; const predecessor = previous.artifactAssociations.find( (association) => association.role === "asset.primary-cover", )!; @@ -17745,10 +18004,18 @@ describe("SQLite Asset Refresh persistence", () => { relationshipTypes: [], }, }); + assert.isDefined(refreshed); expect(refreshed.execution.outcome).toBe("accepted"); - const current = (await catalogue.inspectExecution(refreshed.execution.id))! - .workItemInspections![0].accepted!; + const inspectExecutionResult4 = await catalogue.inspectExecution( + refreshed.execution.id, + ); + assert.isDefined(inspectExecutionResult4); + const workItemInspections5 = inspectExecutionResult4.workItemInspections; + assert.isDefined(workItemInspections5); + const firstWorkItemInspections5 = workItemInspections5![0]; + assert.isDefined(firstWorkItemInspections5); + const current = firstWorkItemInspections5.accepted!; expect( current.artifactAssociations.some( (association) => association.id === predecessor.id, @@ -17757,15 +18024,17 @@ describe("SQLite Asset Refresh persistence", () => { expect(current.recognitionDecision).toEqual(previous.recognitionDecision); const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); + const workItems2 = refreshed.execution.workItems; + assert.isDefined(workItems2); + const firstWorkItems2 = workItems2![0]; + assert.isDefined(firstWorkItems2); expect( database - .prepare( - `SELECT proposal_id, source_record_ids_json, recognition_decision_id - FROM artifact_association_retractions WHERE association_id = ?`, - ) + .prepare(`SELECT proposal_id, source_record_ids_json, recognition_decision_id + FROM artifact_association_retractions WHERE association_id = ?`) .get(predecessor.id), ).toEqual({ - proposal_id: refreshed.execution.workItems![0].proposalId, + proposal_id: firstWorkItems2.proposalId, source_record_ids_json: JSON.stringify([ `reevaluation-record-${state.sourceVersion}`, ]), @@ -17773,11 +18042,9 @@ describe("SQLite Asset Refresh persistence", () => { }); expect( database - .prepare( - `SELECT successor_association_id + .prepare(`SELECT successor_association_id FROM artifact_association_supersessions - WHERE predecessor_association_id = ?`, - ) + WHERE predecessor_association_id = ?`) .get(predecessor.id), ).toBeUndefined(); expect( @@ -17819,6 +18086,8 @@ describe("SQLite Asset Refresh persistence", () => { }, digest: "predecessor-cover-digest", }; + const firstArtifactAssociation2 = accepted().artifactAssociations[0]; + assert.isDefined(firstArtifactAssociation2); const initiallyAccepted: AcceptedRecognition = { ...accepted(), proposal: { @@ -17833,7 +18102,7 @@ describe("SQLite Asset Refresh persistence", () => { }, artifactAssociations: [ { - ...accepted().artifactAssociations[0], + ...firstArtifactAssociation2, sourceRecordIds: [predecessorCoverRecord.id], }, ], @@ -17848,6 +18117,7 @@ describe("SQLite Asset Refresh persistence", () => { accepted: initiallyAccepted, }); const current = (await catalogue.inspectExecution("execution"))!.accepted!; + assert.isDefined(current); const reevaluation = { ...execution(), id: "cover-provenance-refresh" }; const nextCoverRecord = { ...predecessorCoverRecord, @@ -17867,9 +18137,11 @@ describe("SQLite Asset Refresh persistence", () => { digest: "unrelated-incomplete-digest", }; await catalogue.createExecution(reevaluation); + const recognitionSourceAssociation11 = current.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation11); const expectedLineageHeads = await catalogue.freezeSourceLineageHeads({ assetId: current.entity.id, - sourceAssociation: current.recognitionSourceAssociation, + sourceAssociation: recognitionSourceAssociation11, coverageIds: ["cover", "other"], }); await retainSourceRecords(catalogue, reevaluation.id, [ @@ -17884,7 +18156,7 @@ describe("SQLite Asset Refresh persistence", () => { basis: { kind: "source-interpretation", interpretation: { - ...current.proposal.basis.interpretation, + ...sourceInterpretation(current.proposal.basis), id: "cover-provenance-interpretation", sourceRecordIds: [nextCoverRecord.id, unrelatedIncompleteRecord.id], metadata: [ @@ -17905,7 +18177,7 @@ describe("SQLite Asset Refresh persistence", () => { }, metadata: [ { - target: { kind: "entity", entityId: current.entity.id }, + target: { kind: "entity" as const, entityId: current.entity.id }, property: "asset.title", sourceSlot: "source.title", value: "Cover provenance refresh", @@ -17936,13 +18208,13 @@ describe("SQLite Asset Refresh persistence", () => { }); const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); + const firstArtifactAssociation3 = current.artifactAssociations[0]; + assert.isDefined(firstArtifactAssociation3); expect( database - .prepare( - `SELECT source_record_ids_json FROM artifact_association_retractions - WHERE association_id = ?`, - ) - .get(current.artifactAssociations[0].id), + .prepare(`SELECT source_record_ids_json FROM artifact_association_retractions + WHERE association_id = ?`) + .get(firstArtifactAssociation3.id), ).toEqual({ source_record_ids_json: JSON.stringify([nextCoverRecord.id]) }); expect( database @@ -18000,6 +18272,8 @@ describe("SQLite Asset Refresh persistence", () => { }, digest: "predecessor-cover-digest", }; + const firstArtifactAssociation4 = accepted().artifactAssociations[0]; + assert.isDefined(firstArtifactAssociation4); const initiallyAccepted: AcceptedRecognition = { ...accepted(), proposal: { @@ -18014,7 +18288,7 @@ describe("SQLite Asset Refresh persistence", () => { }, artifactAssociations: [ { - ...accepted().artifactAssociations[0], + ...firstArtifactAssociation4, sourceRecordIds: [predecessorCoverRecord.id], }, ], @@ -18038,9 +18312,11 @@ describe("SQLite Asset Refresh persistence", () => { digest: `invalid-${coverage.id}-digest`, }; await catalogue.createExecution(reevaluation); + const recognitionSourceAssociation12 = current.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation12); const expectedLineageHeads = await catalogue.freezeSourceLineageHeads({ assetId: current.entity.id, - sourceAssociation: current.recognitionSourceAssociation, + sourceAssociation: recognitionSourceAssociation12, coverageIds: [coverage.id], }); await retainSourceRecords(catalogue, reevaluation.id, [invalidRecord]); @@ -18052,7 +18328,7 @@ describe("SQLite Asset Refresh persistence", () => { basis: { kind: "source-interpretation", interpretation: { - ...current.proposal.basis.interpretation, + ...sourceInterpretation(current.proposal.basis), id: `invalid-retraction-interpretation-${coverage.id}`, sourceRecordIds: [invalidRecord.id], metadata: [ @@ -18073,7 +18349,7 @@ describe("SQLite Asset Refresh persistence", () => { }, metadata: [ { - target: { kind: "entity", entityId: current.entity.id }, + target: { kind: "entity" as const, entityId: current.entity.id }, property: "asset.title", sourceSlot: "source.title", value: "Invalid retraction refresh", @@ -18136,10 +18412,8 @@ describe("SQLite Asset Refresh persistence", () => { ).toBeUndefined(); expect( database - .prepare( - `SELECT source_record_id FROM source_lineage_heads - WHERE subject_id = ? AND coverage_id = ?`, - ) + .prepare(`SELECT source_record_id FROM source_lineage_heads + WHERE subject_id = ? AND coverage_id = ?`) .get(current.entity.id, coverage.id), ).toEqual( coverage.id === "cover" @@ -18156,7 +18430,7 @@ describe("SQLite Asset Refresh persistence", () => { it("resumes a staged explicit sibling in a new catalogue instance without replaying completed or source work", async () => { const { root, catalogue: firstCatalogue } = await createCatalogueWithRoot(); - const state = { + const state: Parameters[1] = { generation: randomUUID(), blockSecondStore: true, counts: { @@ -18237,7 +18511,6 @@ describe("SQLite Asset Refresh persistence", () => { const laterWorkflow = multiRecoveryWorkflow(laterCatalogue, state); const resumed = await laterWorkflow.resume({ executionId: "multi-interrupted", - acceptTrusted: true, }); expect(resumed.execution.outcome).toBe("accepted"); @@ -18293,8 +18566,7 @@ describe("SQLite Asset Refresh persistence", () => { const cover = covers[0]!; expect(item.sourceRecordIds).toContain(cover.sourceRecordId); const references = database - .prepare( - `SELECT DISTINCT a.id AS associationId, a.artifact_id AS artifactId + .prepare(`SELECT DISTINCT a.id AS associationId, a.artifact_id AS artifactId FROM artifact_associations a INNER JOIN locations l ON l.artifact_id = a.artifact_id INNER JOIN artifact_revisions r ON r.artifact_id = a.artifact_id @@ -18302,8 +18574,7 @@ describe("SQLite Asset Refresh persistence", () => { WHERE a.subject_id = ? AND a.role = ? AND EXISTS (SELECT 1 FROM json_each(a.source_record_ids_json) WHERE value = ?) AND l.kind = 'managed' AND l.address = ? - AND b.digest = ? AND b.media_type = ? AND b.byte_length = ?`, - ) + AND b.digest = ? AND b.media_type = ? AND b.byte_length = ?`) .all( cover.subjectId!, cover.role, @@ -18312,7 +18583,10 @@ describe("SQLite Asset Refresh persistence", () => { cover.blob.digest, cover.blob.mediaType, cover.blob.byteLength, - ) as { associationId: string; artifactId: string }[]; + ) as { + associationId: string; + artifactId: string; + }[]; expect(references).toHaveLength(1); return references[0]!; }); @@ -18327,7 +18601,9 @@ describe("SQLite Asset Refresh persistence", () => { ( database .prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${predicate}`) - .get() as { readonly count: number } + .get() as { + readonly count: number; + } ).count; expect( exactOne("artifact_associations", "role = 'asset.primary-cover'"), @@ -18354,14 +18630,15 @@ describe("SQLite Asset Refresh persistence", () => { ( database .prepare("SELECT COUNT(*) AS count FROM recognition_decisions") - .get() as { readonly count: number } + .get() as { + readonly count: number; + } ).count, ).toBe(2); database.close(); await expect( laterWorkflow.resume({ executionId: "multi-interrupted", - acceptTrusted: true, }), ).rejects.toThrow("只有未完成的刷新记录可以继续"); @@ -18436,7 +18713,6 @@ describe("SQLite Asset Refresh persistence", () => { const laterCatalogue = SqliteCatalogue.forProductData(root); const resumed = await multiRecoveryWorkflow(laterCatalogue, state).resume({ executionId: "conflicting-recovery", - acceptTrusted: true, }); expect(resumed.execution.workItems).toEqual( @@ -18502,10 +18778,12 @@ describe("SQLite Asset Refresh persistence", () => { ); expect(awaiting).toMatchObject({ state: "awaiting-selection" }); expect(awaiting?.candidates).toHaveLength(2); - + assert.isDefined(awaiting); + const selectedCandidate = awaiting.candidates[1]; + assert.isDefined(selectedCandidate); const selected = await recovered.selectSourceAssociationCandidate({ executionId: prepared.executionId, - candidateReference: awaiting!.candidates[1].reference, + candidateReference: selectedCandidate.reference, }); expect(selected.state).toBe("awaiting-confirmation"); expect(selected.preview?.projected.metadata).toEqual( @@ -18585,8 +18863,8 @@ describe("SQLite Asset Refresh persistence", () => { contractVersion: "1", }; const accepted = inspection?.workItemInspections?.[0]?.accepted; - - expect(accepted?.proposal.basis.interpretation.producer).toEqual( + assert.isDefined(accepted); + expect(sourceInterpretation(accepted?.proposal.basis).producer).toEqual( interpreter, ); expect(accepted?.metadata).toEqual( @@ -18677,7 +18955,9 @@ describe("SQLite Asset Refresh persistence", () => { blobs: ( database .prepare("SELECT digest AS id FROM blobs ORDER BY digest") - .all() as unknown as readonly { readonly id: string }[] + .all() as unknown as readonly { + readonly id: string; + }[] ).map((row) => row.id), revisions: ids("SELECT id FROM artifact_revisions ORDER BY id"), associations: ids("SELECT id FROM artifact_associations ORDER BY id"), @@ -18693,7 +18973,9 @@ describe("SQLite Asset Refresh persistence", () => { const executionsBefore = ( database .prepare("SELECT id FROM workflow_executions ORDER BY id") - .all() as unknown as readonly { readonly id: string }[] + .all() as unknown as readonly { + readonly id: string; + }[] ).map((row) => row.id); const second = await scanFixtureRegisteredObservedScope(workflow, { @@ -18717,10 +18999,8 @@ describe("SQLite Asset Refresh persistence", () => { expect(second.sourceRecords).toEqual([]); expect(persistentGraph()).toEqual(before); const executionsAfter = database - .prepare( - `SELECT id, outcome, reused_execution_id, work_items_json - FROM workflow_executions ORDER BY id`, - ) + .prepare(`SELECT id, outcome, reused_execution_id, work_items_json + FROM workflow_executions ORDER BY id`) .all() as unknown as readonly { readonly id: string; readonly outcome: string | null; @@ -18814,12 +19094,14 @@ describe("SQLite Asset Refresh persistence", () => { expect(JSON.stringify(collected)).not.toContain(reflection); await catalogue.createExecution(initial); await retainSourceRecords(catalogue, initial.id, collected.records); + const firstFailure = collected.failures[0]; + assert.isDefined(firstFailure); await catalogue.finishExecution(initial, { ...initial, status: "failed", stage: "failed", outcome: "failed", - reason: collected.failures[0]?.reason, + reason: firstFailure.reason, }); const publicInspection = await catalogue.inspectExecution(initial.id); @@ -18862,9 +19144,13 @@ describe("SQLite Asset Refresh persistence", () => { const catalogue = await createCatalogue(); await catalogue.createAutomationIntentForTesting({ id: "intent", - scope: { observedRootId: "/observed", relativeScope: "." }, + scope: { + observedRootId: "/observed", + relativeScope: ".", + assetLibraryId: "library-catalogue", + }, enabled: true, - intervalMilliseconds: 1_000, + intervalMilliseconds: 1000, workflowInputVersion: "asset-refresh-input/1", runInitial: true, policyReferences: ["policy"], @@ -18917,9 +19203,13 @@ describe("SQLite Asset Refresh persistence", () => { const catalogue = SqliteCatalogue.forProductData(root); await catalogue.createAutomationIntentForTesting({ id: "intent", - scope: { observedRootId: "/observed", relativeScope: "." }, + scope: { + observedRootId: "/observed", + relativeScope: ".", + assetLibraryId: "library-catalogue", + }, enabled: true, - intervalMilliseconds: 1_000, + intervalMilliseconds: 1000, workflowInputVersion: "asset-refresh-input/1", runInitial: true, policyReferences: ["policy"], @@ -19032,7 +19322,7 @@ describe("SQLite Asset Refresh persistence", () => { assetLibraryId: "library-catalogue", observedRootId: "/bounded", relativeScope: ".", - intervalMilliseconds: 1_000, + intervalMilliseconds: 1000, acceptTrusted: false, runInitial: false, }); @@ -19052,13 +19342,11 @@ describe("SQLite Asset Refresh persistence", () => { const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); const executionRows = () => database - .prepare( - `SELECT child.snapshot_json, parent.workflow_input_json + .prepare(`SELECT child.snapshot_json, parent.workflow_input_json FROM workflow_executions parent INNER JOIN library_scan_directories directory ON directory.parent_execution_id = parent.id INNER JOIN workflow_executions child ON child.id = directory.child_execution_id - WHERE parent.automation_intent_id = ? ORDER BY parent.rowid`, - ) + WHERE parent.automation_intent_id = ? ORDER BY parent.rowid`) .all(enabled.intent.id) as Array<{ readonly snapshot_json: string; readonly workflow_input_json: string; @@ -19080,15 +19368,27 @@ describe("SQLite Asset Refresh persistence", () => { timer?.(); await host.settled(); expect(executionRows()).toHaveLength(2); - expect(JSON.parse(executionRows()[0].snapshot_json).files).toHaveLength(1); - expect(JSON.parse(executionRows()[1].snapshot_json).files).toHaveLength(2); + const firstExecutionRowsResult = executionRows()[0]; + assert.isDefined(firstExecutionRowsResult); + expect( + JSON.parse(firstExecutionRowsResult.snapshot_json).files, + ).toHaveLength(1); + const selectedExecutionRowsResult = executionRows()[1]; + assert.isDefined(selectedExecutionRowsResult); + expect( + JSON.parse(selectedExecutionRowsResult.snapshot_json).files, + ).toHaveLength(2); fileCount = 3; await expect( workflow.triggerAutomation(enabled.intent.id, "scan-now", now), ).resolves.toMatchObject({ status: "started" }); expect(executionRows()).toHaveLength(3); - expect(JSON.parse(executionRows()[2].workflow_input_json)).toMatchObject({ + const selectedExecutionRowsResult2 = executionRows()[2]; + assert.isDefined(selectedExecutionRowsResult2); + expect( + JSON.parse(selectedExecutionRowsResult2.workflow_input_json), + ).toMatchObject({ trigger: "scan-now", }); await workflow.disableAutomation(enabled.intent.id); @@ -19103,7 +19403,7 @@ describe("SQLite Asset Refresh persistence", () => { assetLibraryId: "library-catalogue", observedRootId: "/another-bounded-scope", relativeScope: ".", - intervalMilliseconds: 1_000, + intervalMilliseconds: 1000, acceptTrusted: false, }); expect(initial.initial).toMatchObject({ @@ -19154,7 +19454,7 @@ describe("SQLite Asset Refresh persistence", () => { assetLibraryId: "library-catalogue", observedRootId: "/shutdown-first", relativeScope: ".", - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, acceptTrusted: true, runInitial: false, }); @@ -19162,7 +19462,7 @@ describe("SQLite Asset Refresh persistence", () => { assetLibraryId: "library-catalogue", observedRootId: "/shutdown-sibling", relativeScope: ".", - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, acceptTrusted: true, runInitial: false, }); @@ -19192,6 +19492,7 @@ describe("SQLite Asset Refresh persistence", () => { const running = allRunning.filter( (execution) => execution.automationIntentId !== undefined, ); + assert.isDefined(running); expect(running).toHaveLength(1); expect(running[0]).toMatchObject({ automationIntentId: first.intent.id, @@ -19200,7 +19501,9 @@ describe("SQLite Asset Refresh persistence", () => { operationKind: "scan-asset-library", }); expect(running[0]).not.toHaveProperty("cancellationRequestedAt"); - const scan = await workflow.inspectAssetLibraryScan(running[0].id); + const firstRunning = running[0]; + assert.isDefined(firstRunning); + const scan = await workflow.inspectAssetLibraryScan(firstRunning.id); const childId = scan?.directories[0]?.childExecutionId; expect(childId).toEqual(expect.any(String)); const child = allRunning.find(({ id }) => id === childId); @@ -19211,9 +19514,11 @@ describe("SQLite Asset Refresh persistence", () => { ), ).toBe(true); const replacementHost = new ExecutionHost(); - const resumed = await replacementHost.runWithControl( - async () => await workflow.resume({ executionId: running[0].id }), - ); + const resumed = await replacementHost.runWithControl(async () => { + const firstRunning2 = running[0]; + assert.isDefined(firstRunning2); + return await workflow.resume({ executionId: firstRunning2.id }); + }); expect(resumed.execution).toMatchObject({ status: "completed", outcome: "accepted", @@ -19282,7 +19587,7 @@ describe("SQLite Asset Refresh persistence", () => { relativeScope: ".", }, enabled: true, - intervalMilliseconds: 1_000, + intervalMilliseconds: 1000, workflowInputVersion: "asset-refresh-input/1", runInitial: true, policyReferences: ["policy"], @@ -19342,7 +19647,7 @@ describe("SQLite Asset Refresh persistence", () => { assetLibraryId: "library-catalogue", observedRootId: "/bounded-recovery", relativeScope: ".", - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, acceptTrusted: true, runInitial: false, }); @@ -19381,6 +19686,7 @@ describe("SQLite Asset Refresh persistence", () => { await reachedCheckpoint; await expect(closing).resolves.toBeUndefined(); const stopped = await firstWorkflow.inspect("hard-crash-execution"); + assert.isDefined(stopped); expect(stopped).toMatchObject({ execution: { status: "running", @@ -19720,7 +20026,6 @@ describe("SQLite Asset Refresh persistence", () => { await expect( restartedWorkflow.resume({ executionId: "incompatible-recovery", - acceptTrusted: true, }), ).resolves.toMatchObject({ execution: { @@ -20043,7 +20348,7 @@ describe("SQLite Asset Refresh persistence", () => { assetLibraryId: "library-catalogue", observedRootId: "/observed/frozen-automation-configuration", relativeScope: ".", - intervalMilliseconds: 60_000, + intervalMilliseconds: 60000, acceptTrusted: false, policyReferences: [frozenReference], runInitial: false, @@ -20208,7 +20513,7 @@ describe("SQLite Asset Refresh persistence", () => { relativeScope: ".", }, enabled: true, - intervalMilliseconds: 1_000, + intervalMilliseconds: 1000, workflowInputVersion: "asset-refresh-input/1", runInitial: true, policyReferences: ["policy"], @@ -20312,6 +20617,13 @@ describe("SQLite Asset Refresh persistence", () => { outcome: "accepted", acceptedAssetId: "asset", }; + const firstEntity = accepted().entities[0]; + assert.isDefined(firstEntity); + const firstEntityMention = accepted().entityMentions[0]; + assert.isDefined(firstEntityMention); + const firstEntityResolutionDecision = + accepted().entityResolutionDecisions[0]; + assert.isDefined(firstEntityResolutionDecision); const submission = { assetId: "asset", sourceAssociation: accepted().recognitionSourceAssociation, @@ -20331,13 +20643,13 @@ describe("SQLite Asset Refresh persistence", () => { ], entities: [ { - ...accepted().entities[0], + ...firstEntity, sourceRecordIds: ["refreshed-record"], }, ], relationships: [ { - participants: [{ entityId: "persona", role: "voice" }], + participants: [{ entityId: "persona", role: "voice" }] as const, subjectRole: "asset", type: "asset.contribution" as const, sourceSlot: "source.credit.voice", @@ -20346,14 +20658,14 @@ describe("SQLite Asset Refresh persistence", () => { ], entityMentions: [ { - ...accepted().entityMentions[0], + ...firstEntityMention, id: "refreshed-mention", sourceRecordIds: ["refreshed-record"], }, ], entityResolutionDecisions: [ { - ...accepted().entityResolutionDecisions[0], + ...firstEntityResolutionDecision, id: "refreshed-resolution", mentionId: "refreshed-mention", sourceRecordIds: ["refreshed-record"], @@ -20473,48 +20785,38 @@ describe("SQLite Asset Refresh persistence", () => { ).toBeDefined(); expect( history - .prepare( - `SELECT 1 FROM relationship_retractions - WHERE relationship_id = ?`, - ) + .prepare(`SELECT 1 FROM relationship_retractions + WHERE relationship_id = ?`) .get("relationship"), ).toBeUndefined(); expect( history - .prepare( - `SELECT 1 FROM relationship_lifecycles - WHERE predecessor_relationship_id = ?`, - ) + .prepare(`SELECT 1 FROM relationship_lifecycles + WHERE predecessor_relationship_id = ?`) .get("relationship"), ).toBeDefined(); expect( history - .prepare( - `SELECT 1 FROM metadata_assertions - WHERE subject_id = ? AND value = ?`, - ) + .prepare(`SELECT 1 FROM metadata_assertions + WHERE subject_id = ? AND value = ?`) .get("asset", "Work"), ).toBeDefined(); const originalAssertion = history - .prepare( - `SELECT id FROM metadata_assertions - WHERE subject_id = ? AND value = ?`, - ) - .get("asset", "Work") as { readonly id: number }; + .prepare(`SELECT id FROM metadata_assertions + WHERE subject_id = ? AND value = ?`) + .get("asset", "Work") as { + readonly id: number; + }; expect( history - .prepare( - `SELECT 1 FROM metadata_assertion_retractions - WHERE assertion_id = ?`, - ) + .prepare(`SELECT 1 FROM metadata_assertion_retractions + WHERE assertion_id = ?`) .get(originalAssertion.id), ).toBeUndefined(); expect( history - .prepare( - `SELECT 1 FROM metadata_assertion_lifecycles - WHERE predecessor_assertion_id = ?`, - ) + .prepare(`SELECT 1 FROM metadata_assertion_lifecycles + WHERE predecessor_assertion_id = ?`) .get(originalAssertion.id), ).toBeDefined(); history.close(); @@ -20571,6 +20873,11 @@ describe("SQLite Asset Refresh persistence", () => { END `); injectedFailure.close(); + const firstEntityMention2 = submission.interpretation.entityMentions[0]; + assert.isDefined(firstEntityMention2); + const firstEntityResolutionDecision2 = + submission.interpretation.entityResolutionDecisions[0]; + assert.isDefined(firstEntityResolutionDecision2); await expect( catalogue.applyMetadataRefresh({ expectedExecution: await executionBaseline( @@ -20596,14 +20903,14 @@ describe("SQLite Asset Refresh persistence", () => { ], entityMentions: [ { - ...submission.interpretation.entityMentions[0], + ...firstEntityMention2, id: "atomic-mention", sourceRecordIds: [atomicRecord.id], }, ], entityResolutionDecisions: [ { - ...submission.interpretation.entityResolutionDecisions[0], + ...firstEntityResolutionDecision2, id: "atomic-resolution", mentionId: "atomic-mention", sourceRecordIds: [atomicRecord.id], @@ -20920,6 +21227,7 @@ describe("SQLite Asset Refresh persistence", () => { }; const proposal = { id: "new-proposal", + boundaries: accepted().proposal.boundaries, basis: { kind: "source-interpretation" as const, interpretation: { @@ -20948,7 +21256,9 @@ describe("SQLite Asset Refresh persistence", () => { ], relationships: [ { - participants: [{ entityId: "new-persona", role: "voice" }], + participants: [ + { entityId: "new-persona", role: "voice" }, + ] as const, subjectRole: "asset", type: "asset.contribution" as const, sourceSlot: "source.credit.voice", @@ -20985,6 +21295,10 @@ describe("SQLite Asset Refresh persistence", () => { recognitionSourceAssociationId: "association", recognitionDecisionId: "decision", }); + const recognitionSourceAssociation13 = + enrichedBeforeIdentityChange.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation13); + const preview: ChangeImpactPreview = { version: "change-impact-preview/2", id: "preview", @@ -21023,12 +21337,8 @@ describe("SQLite Asset Refresh persistence", () => { }), ), recognitionSourceAssociation: { - sourceIdentity: - enrichedBeforeIdentityChange.recognitionSourceAssociation - .sourceIdentity, - nativeAddress: - enrichedBeforeIdentityChange.recognitionSourceAssociation - .nativeAddress, + sourceIdentity: recognitionSourceAssociation13.sourceIdentity, + nativeAddress: recognitionSourceAssociation13.nativeAddress, }, metadata: enrichedBeforeIdentityChange.metadata, entities: enrichedBeforeIdentityChange.entities ?? [], @@ -21041,7 +21351,6 @@ describe("SQLite Asset Refresh persistence", () => { blobs: enrichedBeforeIdentityChange.blobs, artifactRevisions: enrichedBeforeIdentityChange.artifactRevisions, artifactAssociations: enrichedBeforeIdentityChange.artifactAssociations, - primaryCoverArtifactId: "artifact", }, projected: { recognitionState: "recognized", @@ -21064,7 +21373,10 @@ describe("SQLite Asset Refresh persistence", () => { ...enrichedBeforeIdentityChange.metadata.filter((assertion) => assertion.sourceRecordIds.includes(supplementRecord.id), ), - ...proposal.basis.interpretation.metadata, + ...proposal.basis.interpretation.metadata.map((assertion) => ({ + ...assertion, + target: { kind: "entity" as const, entityId: "asset" }, + })), ], entities: [newEntity], entityMentions: [newMention], @@ -21076,7 +21388,7 @@ describe("SQLite Asset Refresh persistence", () => { participants: [ { entityId: "asset", role: "asset" }, { entityId: "new-persona", role: "voice" }, - ], + ] as const, type: "asset.contribution", sourceSlot: "source.credit.voice", sourceRecordIds: ["new-record"], @@ -21119,7 +21431,6 @@ describe("SQLite Asset Refresh persistence", () => { sourceRecordIds: ["new-record"], }, ], - primaryCoverArtifactId: "new-cover", }, blockingConditions: [], status: "awaiting-confirmation", @@ -21343,10 +21654,8 @@ describe("SQLite Asset Refresh persistence", () => { ).toBeDefined(); expect( history - .prepare( - `SELECT 1 FROM recognition_decision_supersessions - WHERE predecessor_decision_id = ?`, - ) + .prepare(`SELECT 1 FROM recognition_decision_supersessions + WHERE predecessor_decision_id = ?`) .get("decision"), ).toBeDefined(); expect( @@ -21354,10 +21663,8 @@ describe("SQLite Asset Refresh persistence", () => { ).toBeDefined(); expect( history - .prepare( - `SELECT 1 FROM artifact_association_supersessions - WHERE predecessor_association_id = ?`, - ) + .prepare(`SELECT 1 FROM artifact_association_supersessions + WHERE predecessor_association_id = ?`) .get("artifact-association"), ).toBeDefined(); expect( @@ -21475,12 +21782,18 @@ describe("SQLite Asset Refresh persistence", () => { }, reverseProposal, ); + const recognitionSourceAssociation15 = + current.accepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation15); const reverseBaseline = await catalogue.freezeChangeImpactBaseline({ assetId: "asset", - recognitionSourceAssociationId: - current.accepted.recognitionSourceAssociation.id, + recognitionSourceAssociationId: recognitionSourceAssociation15.id, recognitionDecisionId: current.accepted.recognitionDecision.id, }); + const recognitionSourceAssociation16 = + current.accepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation16); + const currentProjection = { sourceAssociations: current.accepted.sourceAssociations.map( ({ sourceIdentity, nativeAddress }) => ({ @@ -21489,10 +21802,8 @@ describe("SQLite Asset Refresh persistence", () => { }), ), recognitionSourceAssociation: { - sourceIdentity: - current.accepted.recognitionSourceAssociation.sourceIdentity, - nativeAddress: - current.accepted.recognitionSourceAssociation.nativeAddress, + sourceIdentity: recognitionSourceAssociation16.sourceIdentity, + nativeAddress: recognitionSourceAssociation16.nativeAddress, }, metadata: current.accepted.metadata, entities: current.accepted.entities ?? [], @@ -21507,6 +21818,9 @@ describe("SQLite Asset Refresh persistence", () => { artifactAssociations: current.accepted.artifactAssociations, primaryCoverArtifactId: "new-cover", }; + const recognitionSourceAssociation18 = + current.accepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation18); const reversePreview: ChangeImpactPreview = { version: "change-impact-preview/2", id: "reverse-preview", @@ -21535,7 +21849,7 @@ describe("SQLite Asset Refresh persistence", () => { baselines: [ { key: "source-association", - value: current.accepted.recognitionSourceAssociation.id, + value: recognitionSourceAssociation18.id, }, { key: "recognition-decision", @@ -21543,8 +21857,8 @@ describe("SQLite Asset Refresh persistence", () => { }, { key: "source-records", - value: current.accepted.proposal.basis.interpretation.sourceRecordIds - .toSorted() + value: sourceInterpretation(current.accepted.proposal.basis) + .sourceRecordIds.toSorted() .join(","), }, { key: "complete-current-projection", value: reverseBaseline }, @@ -21553,14 +21867,18 @@ describe("SQLite Asset Refresh persistence", () => { projected: { ...currentProjection, sourceAssociations: currentProjection.sourceAssociations.map( - (association) => - association.nativeAddress === - current.accepted.recognitionSourceAssociation.nativeAddress + (association) => { + const recognitionSourceAssociation19 = + current.accepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation19); + return association.nativeAddress === + recognitionSourceAssociation19.nativeAddress ? { sourceIdentity: "source", nativeAddress: "https://source.example/work", } - : association, + : association; + }, ), recognitionSourceAssociation: { sourceIdentity: "source", @@ -21570,7 +21888,10 @@ describe("SQLite Asset Refresh persistence", () => { ...current.accepted.metadata.filter((assertion) => assertion.sourceRecordIds.includes(supplementRecord.id), ), - ...reverseProposal.basis.interpretation.metadata, + ...reverseProposal.basis.interpretation.metadata.map((assertion) => ({ + ...assertion, + target: { kind: "entity" as const, entityId: "asset" }, + })), ], }, blockingConditions: [], @@ -21582,11 +21903,14 @@ describe("SQLite Asset Refresh persistence", () => { changeImpactApplyInput(reversePreview), ), ).resolves.toBe("applied"); + const recognitionSourceAssociation20 = + current.accepted.recognitionSourceAssociation; + assert.isDefined(recognitionSourceAssociation20); await expect( catalogue.freezeSourceLineageHeads({ assetId: "asset", sourceAssociation: { - ...current.accepted.recognitionSourceAssociation, + ...recognitionSourceAssociation20, sourceIdentity: "source", nativeAddress: "https://source.example/work", }, @@ -21814,6 +22138,7 @@ describe("SQLite Asset Refresh persistence", () => { coverAcquisition: { candidateId: "cover", sourceRecordId: "record", + role: "asset.primary-cover", stagedReference: "/managed/staging/cover", digest: "digest", mediaType: "image/jpeg", @@ -21832,6 +22157,8 @@ describe("SQLite Asset Refresh persistence", () => { stage: "cover-stored", managedCover: { candidateId: "cover", + sourceRecordId: "record", + role: "asset.primary-cover", blob: { digest: "digest", mediaType: "image/jpeg", byteLength: 4 }, location: { address: "/managed/blob" }, }, @@ -21861,7 +22188,9 @@ describe("SQLite Asset Refresh persistence", () => { directory: "/observed", informationRequirement: { metadataUsageContext: "asset.ordinary", - metadata: [{ property: "asset.body", necessity: "optional" }], + metadata: [ + { property: "asset.body", necessity: "optional" as const }, + ], artifactRoles: [ { role: "fanbox.attachment.future-material", @@ -21896,7 +22225,9 @@ describe("SQLite Asset Refresh persistence", () => { selection: { informationRequirement: { metadataUsageContext: "asset.ordinary", - metadata: [{ property: "asset.body", necessity: "optional" }], + metadata: [ + { property: "asset.body", necessity: "optional" as const }, + ], artifactRoles: [ { role: "fanbox.attachment.future-material", @@ -21939,7 +22270,6 @@ describe("SQLite Asset Refresh persistence", () => { subjectId: "asset", sourceIdentity: "source", nativeAddress: "source:work", - proposalId: "recognition-proposal", }, recognitionDecisionId: "recognition-decision", sourceAssociations: [], @@ -21947,11 +22277,13 @@ describe("SQLite Asset Refresh persistence", () => { metadataSourceRecords: [], informationRequirement: { metadataUsageContext: "asset.ordinary", - metadata: [{ property: "asset.awards", necessity: "optional" }], + metadata: [ + { property: "asset.awards", necessity: "optional" as const }, + ], artifactRoles: [ { role: "asset.primary-cover", - necessity: "optional", + necessity: "optional" as const, maximum: 1, }, ], @@ -21975,7 +22307,6 @@ describe("SQLite Asset Refresh persistence", () => { subjectId: "asset", sourceIdentity: "supplement", nativeAddress: "supplement:work", - proposalId: "source-enrichment:execution:supplement", }, interpretation: { id: "supplement-interpretation", @@ -22298,7 +22629,7 @@ describe("SQLite Asset Refresh persistence", () => { participants: [ { entityId: "asset", role: "asset" }, { entityId: "persona", role: "voice" }, - ], + ] as const, qualifiers: [{ key: "credit-note", value: "official" }], }, ], @@ -22403,7 +22734,9 @@ describe("SQLite Asset Refresh persistence", () => { .prepare( "SELECT COUNT(*) AS count FROM source_lineage_heads WHERE source_record_id = 'search-record'", ) - .get() as { readonly count: number } + .get() as { + readonly count: number; + } ).count, ).toBe(0); database.close(); @@ -22431,16 +22764,19 @@ describe("SQLite Asset Refresh persistence", () => { ...base, proposal: { ...base.proposal, - interpretation: { - ...base.proposal.basis.interpretation, - metadata: [ - ...base.proposal.basis.interpretation.metadata, - workTitleCandidate, - ], - entities: [ - ...(base.proposal.basis.interpretation.entities ?? []), - work, - ], + basis: { + kind: "source-interpretation", + interpretation: { + ...base.proposal.basis.interpretation, + metadata: [ + ...base.proposal.basis.interpretation.metadata, + workTitleCandidate, + ], + entities: [ + ...(base.proposal.basis.interpretation.entities ?? []), + work, + ], + }, }, }, metadata: [ @@ -22555,7 +22891,7 @@ describe("SQLite Asset Refresh persistence", () => { participants: [ { entityId: work.id, role: "bangumi.subject.predecessor" }, { entityId: participant.id, role: "bangumi.subject.sequel" }, - ], + ] as const, type: "bangumi.subject.related", sourceSlot: "bangumi.subject.related", sourceRecordIds: [recordId], @@ -22627,7 +22963,6 @@ describe("SQLite Asset Refresh persistence", () => { subjectId: assetId, sourceIdentity: "bangumi", nativeAddress, - proposalId: proposal.id, }, sourceAssociations: [ { @@ -22635,7 +22970,6 @@ describe("SQLite Asset Refresh persistence", () => { subjectId: assetId, sourceIdentity: "bangumi", nativeAddress, - proposalId: proposal.id, }, ], recognitionDecision: { @@ -22701,34 +23035,36 @@ describe("SQLite Asset Refresh persistence", () => { "other-record", "https://api.bgm.tv/v0/episodes/831402", ); - for (const [index, [record, accepted]] of [ - [ - firstRecord, - acceptedWork("asset-1", firstRecord.id, firstRecord.nativeAddress), - ], - [ - siblingRecord, - acceptedWork( - "asset-1-sibling", - siblingRecord.id, - siblingRecord.nativeAddress, - ), - ], + for (const [index, [record, accepted]] of ( [ - secondRecord, - acceptedWork("asset-2", secondRecord.id, secondRecord.nativeAddress), - ], - [ - otherRecord, - acceptedWork( - "asset-3", - otherRecord.id, - otherRecord.nativeAddress, - "Other work", - true, - ), - ], - ].entries()) { + [ + firstRecord, + acceptedWork("asset-1", firstRecord.id, firstRecord.nativeAddress), + ], + [ + siblingRecord, + acceptedWork( + "asset-1-sibling", + siblingRecord.id, + siblingRecord.nativeAddress, + ), + ], + [ + secondRecord, + acceptedWork("asset-2", secondRecord.id, secondRecord.nativeAddress), + ], + [ + otherRecord, + acceptedWork( + "asset-3", + otherRecord.id, + otherRecord.nativeAddress, + "Other work", + true, + ), + ], + ] as const + ).entries()) { const executionId = `shared-work-execution-${index + 1}`; await catalogue.createExecution({ ...execution(), id: executionId }); await retainSourceRecords(catalogue, executionId, [record]); @@ -22764,7 +23100,7 @@ describe("SQLite Asset Refresh persistence", () => { accepted: { metadata: [ expect.objectContaining({ - target: { kind: "entity", entityId: work.id }, + target: { kind: "entity" as const, entityId: work.id }, property: "bangumi.subject.name", value: "Shared work", }), @@ -22811,14 +23147,16 @@ describe("SQLite Asset Refresh persistence", () => { expect(current?.accepted?.metadata.map((item) => item.value)).not.toContain( "Other work", ); + const relationships2 = current?.accepted?.relationships; + assert.isDefined(relationships2); expect( - current?.accepted?.relationships.flatMap((item) => + relationships2.flatMap((item) => item.participants.map((participant) => participant.entityId), ), ).not.toContain(otherSeries.id); - expect(current?.accepted?.entities.map((item) => item.id)).not.toContain( - otherSeries.id, - ); + const entities2 = current?.accepted?.entities; + assert.isDefined(entities2); + expect(entities2.map((item) => item.id)).not.toContain(otherSeries.id); expect(current?.accepted?.artifacts.map((item) => item.id)).not.toContain( "other-work-cover-artifact", ); @@ -22837,12 +23175,10 @@ describe("SQLite Asset Refresh persistence", () => { const database = new DatabaseSync(path.join(root, "catalogue.sqlite")); expect( database - .prepare( - `SELECT COUNT(*) AS count FROM metadata_assertions assertion + .prepare(`SELECT COUNT(*) AS count FROM metadata_assertions assertion JOIN metadata_assertion_lifecycles lifecycle ON lifecycle.assertion_id = assertion.id WHERE assertion.subject_id = ? AND assertion.property = ? - AND assertion.value = ? AND lifecycle.source_slot = ?`, - ) + AND assertion.value = ? AND lifecycle.source_slot = ?`) .get( work.id, "bangumi.subject.name", @@ -22866,48 +23202,38 @@ describe("SQLite Asset Refresh persistence", () => { ).toEqual({ count: 1 }); expect( database - .prepare( - `SELECT COUNT(*) AS count FROM metadata_assertion_sources source + .prepare(`SELECT COUNT(*) AS count FROM metadata_assertion_sources source JOIN metadata_assertions assertion ON assertion.id = source.assertion_id - WHERE assertion.subject_id = ? AND assertion.value = ?`, - ) + WHERE assertion.subject_id = ? AND assertion.value = ?`) .get(work.id, "Shared work"), ).toEqual({ count: 3 }); expect( database - .prepare( - `SELECT COUNT(*) AS count FROM relationship_sources source + .prepare(`SELECT COUNT(*) AS count FROM relationship_sources source JOIN source_records record ON record.id = source.source_record_id - WHERE source.relationship_id = ? AND record.native_address = ?`, - ) + WHERE source.relationship_id = ? AND record.native_address = ?`) .get("shared-work-series", firstRecord.nativeAddress), ).toEqual({ count: 3 }); expect( database - .prepare( - `SELECT COUNT(*) AS count FROM relationship_sources - WHERE relationship_id = ? AND source_record_id = ?`, - ) + .prepare(`SELECT COUNT(*) AS count FROM relationship_sources + WHERE relationship_id = ? AND source_record_id = ?`) .get("shared-work-series", firstRecord.id), ).toEqual({ count: 1 }); expect( database - .prepare( - `SELECT COUNT(*) AS count FROM metadata_assertion_sources source + .prepare(`SELECT COUNT(*) AS count FROM metadata_assertion_sources source JOIN metadata_assertions assertion ON assertion.id = source.assertion_id WHERE assertion.subject_id = ? AND assertion.value = ? - AND source.source_record_id = ?`, - ) + AND source.source_record_id = ?`) .get(work.id, "Shared work", firstRecord.id), ).toEqual({ count: 1 }); expect( database - .prepare( - `SELECT COUNT(*) AS count FROM artifact_associations association + .prepare(`SELECT COUNT(*) AS count FROM artifact_associations association JOIN json_each(association.source_record_ids_json) provenance JOIN source_records record ON record.id = provenance.value - WHERE association.id = ? AND record.native_address = ?`, - ) + WHERE association.id = ? AND record.native_address = ?`) .get("shared-work-cover", firstRecord.nativeAddress), ).toEqual({ count: 3 }); expect( @@ -22981,7 +23307,6 @@ describe("SQLite Asset Refresh persistence", () => { id: "association-2", subjectId: "asset-2", nativeAddress: "https://source.example/work-2", - proposalId: "proposal-2", }, sourceAssociations: [ { @@ -22989,7 +23314,6 @@ describe("SQLite Asset Refresh persistence", () => { id: "association-2", subjectId: "asset-2", nativeAddress: "https://source.example/work-2", - proposalId: "proposal-2", }, ], recognitionDecision: { @@ -23251,11 +23575,13 @@ describe("SQLite Asset Refresh persistence", () => { metadataSourceRecords: [{ id: "record", sourceIdentity: "source" }], informationRequirement: { metadataUsageContext: "asset.ordinary", - metadata: [{ property: "asset.awards", necessity: "optional" }], + metadata: [ + { property: "asset.awards", necessity: "optional" as const }, + ], artifactRoles: [ { role: "asset.primary-cover", - necessity: "optional", + necessity: "optional" as const, maximum: 1, }, ], @@ -23345,11 +23671,16 @@ describe("SQLite Asset Refresh persistence", () => { retainedEnrichmentExecution, pendingEnrichmentExecution, ); + const firstSourceEnrichment = + pendingEnrichmentExecution.sourceEnrichments?.[0]; + assert.isDefined(firstSourceEnrichment); + const pendingCheckpoint = firstSourceEnrichment; + assert(pendingCheckpoint.stage === "association-interpreted"); const appliedEnrichmentExecution: WorkflowExecution = { ...pendingEnrichmentExecution, sourceEnrichments: [ { - ...pendingEnrichmentExecution.sourceEnrichments![0], + ...pendingCheckpoint, stage: "applied", }, ], @@ -23486,7 +23817,7 @@ describe("SQLite Asset Refresh persistence", () => { const [reopened] = await later.findAcceptedForSubjects(["asset"]); expect(reopened?.accepted).not.toHaveProperty("metadataPolicyIds"); - expect(reopened?.accepted.metadataSourceRecords).toEqual([ + expect(reopened?.accepted).toHaveProperty("metadataSourceRecords", [ { id: "record", sourceIdentity: "source", diff --git a/packages/composition/test/tsconfig.json b/packages/composition/test/tsconfig.json deleted file mode 100644 index e516b7f..0000000 --- a/packages/composition/test/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["**/*.ts"] -} diff --git a/packages/composition/tsconfig.json b/packages/composition/tsconfig.json index 4d954ba..3594588 100644 --- a/packages/composition/tsconfig.json +++ b/packages/composition/tsconfig.json @@ -11,5 +11,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/composition/turbo.json b/packages/composition/turbo.json index 43bafe8..86957ea 100644 --- a/packages/composition/turbo.json +++ b/packages/composition/turbo.json @@ -2,8 +2,80 @@ "extends": ["//"], "tags": ["composition"], "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, "test": { - "dependsOn": ["$TURBO_EXTENDS$", "@digital/db-migrations#build"] + "dependsOn": ["build", "@digital/db-migrations#build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "test:process": { + "dependsOn": [ + "build", + "@digital/db-migrations#build", + "@digital/cli#build" + ], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:heavy"], + "cache": false } } } diff --git a/packages/db-migrations/package.json b/packages/db-migrations/package.json index c3ee1f5..3edb004 100644 --- a/packages/db-migrations/package.json +++ b/packages/db-migrations/package.json @@ -26,10 +26,11 @@ "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules .", "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "pretest": "pnpm build", - "test": "vitest run --reporter=verbose", + "test": "vitest run --exclude '**/production-migrations.test.ts' --reporter=verbose", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/db-migrations", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo", + "test:process": "vitest run test/production-migrations.test.ts --reporter=verbose" }, "dependencies": { "@digital/product-data": "workspace:*", diff --git a/packages/db-migrations/turbo.json b/packages/db-migrations/turbo.json index c2a44fb..872eda5 100644 --- a/packages/db-migrations/turbo.json +++ b/packages/db-migrations/turbo.json @@ -1 +1,87 @@ -{ "extends": ["//"], "tags": ["tooling"] } +{ + "extends": ["//"], + "tags": ["tooling"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "prepare-only": { + "tags": ["work:preparation"], + "cache": false, + "dependsOn": ["build"] + }, + "offline-restore": { + "tags": ["work:on-demand"], + "cache": false, + "dependsOn": ["build"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "test:process": { + "dependsOn": ["build", "@digital/web#build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:heavy"], + "cache": false + } + } +} diff --git a/packages/domain/package.json b/packages/domain/package.json index 9016b3e..2a070d3 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/domain", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "valibot": "catalog:" diff --git a/packages/domain/test/local-provenance.test.ts b/packages/domain/test/local-provenance.test.ts index fd362c7..8e15ccb 100644 --- a/packages/domain/test/local-provenance.test.ts +++ b/packages/domain/test/local-provenance.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { validateAcceptedRecognition, validateRecognitionProposal, validateReevaluatedRecognition, type AcceptedRecognition, + type ArtifactAssociation, + type MetadataAssertion, type RecognitionProposal, } from "../src/index.ts"; @@ -14,7 +16,7 @@ const materialScope = { materialReferences: ["book.epub"], }; -function accepted(): AcceptedRecognition { +function accepted() { const proposal = { id: "proposal", boundaries: [materialScope], @@ -55,7 +57,6 @@ function accepted(): AcceptedRecognition { subjectId: "asset", sourceIdentity: "official", nativeAddress: "https://example.test/book", - proposalId: "proposal", }, ], recognitionSourceAssociation: { @@ -63,7 +64,6 @@ function accepted(): AcceptedRecognition { subjectId: "asset", sourceIdentity: "official", nativeAddress: "https://example.test/book", - proposalId: "proposal", }, recognitionDecision: { id: "decision", @@ -102,7 +102,7 @@ function accepted(): AcceptedRecognition { locator: "epub:cover", }, }, - ], + ] satisfies [ArtifactAssociation], metadata: [ { target: { @@ -120,8 +120,8 @@ function accepted(): AcceptedRecognition { locator: "epub:opf/dc:title[1]", }, }, - ], - }; + ] satisfies [MetadataAssertion], + } satisfies AcceptedRecognition; } describe("local format provenance", () => { @@ -219,11 +219,11 @@ describe("local format provenance", () => { ...value, recognitionSourceAssociation: { ...value.recognitionSourceAssociation!, - proposalId: "refresh-proposal", + nativeAddress: "https://example.test/refreshed-book", }, sourceAssociations: value.sourceAssociations.map((association) => ({ ...association, - proposalId: "refresh-proposal", + nativeAddress: "https://example.test/refreshed-book", })), }), "immutable Source Association", @@ -263,8 +263,8 @@ describe("local format provenance", () => { it("rejects a local metadata assertion without either source or observed-part provenance", () => { const current = accepted(); - const metadata = { ...current.metadata[0] }; - delete metadata.observedArtifactPart; + const { observedArtifactPart: _observedArtifactPart, ...metadata } = + current.metadata[0]; const invalid: AcceptedRecognition = { ...current, metadata: [metadata], @@ -472,9 +472,12 @@ describe("识别依据", () => { expect(validateRecognitionProposal(proposal)).toBe(proposal); if (proposal.basis.kind !== "observed-evidence") throw new Error("测试提案缺少观察证据依据"); - expect(proposal.basis.evidence[0].inspection!.identifiers[0].locators).toBe( - locators, - ); + const firstEvidence = proposal.basis.evidence[0]; + assert.isDefined(firstEvidence); + assert.isDefined(firstEvidence.inspection); + const firstIdentifier = firstEvidence.inspection.identifiers[0]; + assert.isDefined(firstIdentifier); + expect(firstIdentifier.locators).toBe(locators); }); it.each([ diff --git a/packages/domain/test/observed-material-association.test.ts b/packages/domain/test/observed-material-association.test.ts index 0cc0295..d82c630 100644 --- a/packages/domain/test/observed-material-association.test.ts +++ b/packages/domain/test/observed-material-association.test.ts @@ -190,23 +190,25 @@ describe("observed material association", () => { "a non-whole role", (value: ObservedMaterialAssociationSubmission) => ({ ...value, - artifactAssociations: [ - { ...value.artifactAssociations[0], role: "asset.cover" }, - value.artifactAssociations[1], - ], + artifactAssociations: value.artifactAssociations.map( + (association, index) => + index === 0 ? { ...association, role: "asset.cover" } : association, + ), }), ], [ "a cross-subject association", (value: ObservedMaterialAssociationSubmission) => ({ ...value, - artifactAssociations: [ - { - ...value.artifactAssociations[0], - target: { kind: "entity" as const, entityId: "asset-other" }, - }, - value.artifactAssociations[1], - ], + artifactAssociations: value.artifactAssociations.map( + (association, index) => + index === 0 + ? { + ...association, + target: { kind: "entity" as const, entityId: "asset-other" }, + } + : association, + ), }), ], [ @@ -255,8 +257,8 @@ describe("observed material association", () => { expect(() => validateObservedMaterialAssociationSubmission({ ...value, - artifactAssociations: [value.artifactAssociations[0]], - supports: [value.supports[0]], + artifactAssociations: value.artifactAssociations.slice(0, 1), + supports: value.supports.slice(0, 1), }), ).toThrow(); }); diff --git a/packages/domain/test/relationship-qualifiers.test.ts b/packages/domain/test/relationship-qualifiers.test.ts index 52ca374..014dbfd 100644 --- a/packages/domain/test/relationship-qualifiers.test.ts +++ b/packages/domain/test/relationship-qualifiers.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "vitest"; -import { validateSourceInterpretationBatch } from "../src/index.ts"; +import { + validateSourceInterpretationBatch, + type SourceInterpretationBatch, +} from "../src/index.ts"; describe("Entity Relationship qualifiers", () => { - const batch = (relationship: Record) => ({ + const batch = ( + relationship: NonNullable< + SourceInterpretationBatch["relationships"] + >[number], + ) => ({ id: "batch", producer: { capabilityId: "test", diff --git a/packages/domain/test/source-association-correction.test.ts b/packages/domain/test/source-association-correction.test.ts index 8dbf7a9..faa46f7 100644 --- a/packages/domain/test/source-association-correction.test.ts +++ b/packages/domain/test/source-association-correction.test.ts @@ -25,16 +25,19 @@ const predecessorDecision: RecognitionDecision = { id: "recognition-decision-old", proposalId: "recognition-proposal-old", assetId: "asset", + boundaries: [], acceptedRuleResults: [], }; const successorDecision: RecognitionDecision = { id: "recognition-decision-new", proposalId: "recognition-proposal-new", assetId: "asset", + boundaries: [], acceptedRuleResults: [], }; const successorProposal: RecognitionProposal = { id: successorDecision.proposalId, + boundaries: [], basis: { kind: "manual-boundary", proposedBy: "operator", diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json index 5420f5e..814d105 100644 --- a/packages/domain/tsconfig.json +++ b/packages/domain/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/domain/turbo.json b/packages/domain/turbo.json index 86fa5dd..089ce2b 100644 --- a/packages/domain/turbo.json +++ b/packages/domain/turbo.json @@ -1,4 +1,64 @@ { "extends": ["//"], - "tags": ["domain"] + "tags": ["domain"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } } diff --git a/packages/environment/package.json b/packages/environment/package.json index 69fd56d..ec535df 100644 --- a/packages/environment/package.json +++ b/packages/environment/package.json @@ -21,7 +21,8 @@ "digital-development": "./.turbo/development/dist/container-environment.js", "types": "./dist/container-environment.d.ts", "default": "./dist/container-environment.js" - } + }, + "./cli": "./dist/cli.js" }, "scripts": { "build": "tsc -p tsconfig.build.json --pretty false", @@ -34,7 +35,8 @@ "prepack": "pnpm exec turbo run build --filter=.", "test": "vitest run --reporter=verbose --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/environment", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@valibot/to-json-schema": "catalog:", diff --git a/packages/environment/src/internal/container-operation.ts b/packages/environment/src/internal/container-operation.ts index 71f3be0..593f398 100644 --- a/packages/environment/src/internal/container-operation.ts +++ b/packages/environment/src/internal/container-operation.ts @@ -272,7 +272,10 @@ export async function runContainerOperation( if ( /^(?:Error(?: response from daemon)?: )?No such (?:container|volume|network|object|image): /iu.test( diagnostic, - ) + ) || + (object.kind === "network" && + diagnostic === + `Error response from daemon: network ${object.id ?? object.name} not found`) ) { object.state = "absent"; object.running = false; diff --git a/packages/environment/test/container-environment.test.ts b/packages/environment/test/container-environment.test.ts index 251fcd8..d53d998 100644 --- a/packages/environment/test/container-environment.test.ts +++ b/packages/environment/test/container-environment.test.ts @@ -7,33 +7,36 @@ import { expect, test } from "vitest"; import { manageContainerEnvironment } from "../src/index.ts"; /** 仅在 Docker 命令外部边界模拟应答丢失;对象状态和收尾结果由公开操作观察。 */ -test("创建应答丢失后停止已创建的自有容器,保留产品数据和原首因,不停止外部对象", async () => { - const root = await mkdtemp( - path.join(tmpdir(), "digital-container-response-loss-"), - ); - const state = path.join(root, "daemon.json"); - // 夹具内容是纯 JavaScript;明确 ESM 扩展名,避免每条 Docker 命令重复处理 TypeScript 入口。 - const cli = path.join(root, "docker.mjs"); - await writeFile( - state, - JSON.stringify({ - containers: [ - { - Id: "external-id", - Name: "/external", - Config: { Labels: {} }, - State: { Running: true, Pid: 99, ExitCode: 0, OOMKilled: false }, - NetworkSettings: { Ports: {} }, - }, - ], - images: [], - volumes: [], - networks: [], - }), - ); - await writeFile( - cli, - `import fs from 'node:fs'; +test.each(["legacy", "current"])( + "创建应答丢失及 %s 网络不存在应答:保留数据和首因,只收尾自有资源", + async (missingNetworkDiagnostic) => { + const root = await mkdtemp( + path.join(tmpdir(), "digital-container-response-loss-"), + ); + const state = path.join(root, "daemon.json"); + // 夹具内容是纯 JavaScript;明确 ESM 扩展名,避免每条 Docker 命令重复处理 TypeScript 入口。 + const cli = path.join(root, "docker.mjs"); + await writeFile( + state, + JSON.stringify({ + missingNetworkDiagnostic, + containers: [ + { + Id: "external-id", + Name: "/external", + Config: { Labels: {} }, + State: { Running: true, Pid: 99, ExitCode: 0, OOMKilled: false }, + NetworkSettings: { Ports: {} }, + }, + ], + images: [], + volumes: [], + networks: [], + }), + ); + await writeFile( + cli, + `import fs from 'node:fs'; const file=process.argv[2];const args=process.argv.slice(3).filter((_,i,a)=>a[i-1]!=='--host'&&a[i]!=='--host'); const state=JSON.parse(fs.readFileSync(file,'utf8')); const labels=()=>Object.fromEntries(args.flatMap((a,i)=>a==='--label'?[args[i+1].split(/=(.*)/s).slice(0,2)]:[])); @@ -49,52 +52,73 @@ else if(args[1]==='create'&&args[0]!=='container'){ const name=args[args.indexOf('--name')+1],o={Id:'owned-id',Name:'/'+name,Config:{Labels:labels()},Image:'sha256:image-one',State:{Running:true,Pid:42,ExitCode:0,OOMKilled:false},NetworkSettings:{Ports:{}}};state.containers.push(o);fs.writeFileSync(file,JSON.stringify(state));console.error('creation response lost');process.exit(77); }else if(args[1]==='ls')output=list(args[0]).filter(matches).map(o=>args[0]==='volume'?o.Name:o.Id).join('\\n'); else if(args[1]==='inspect'){ - const o=find(args[0]);if(!o){console.error('Error: No such '+args[0]+': '+args.at(-1));process.exit(1)}output=JSON.stringify([o]); + const o=find(args[0]);if(!o){console.error(args[0]==='network'&&state.missingNetworkDiagnostic==='current'?'Error response from daemon: network '+args.at(-1)+' not found':'Error: No such '+args[0]+': '+args.at(-1));process.exit(1)}output=JSON.stringify([o]); }else if(args[0]==='container'&&args[1]==='stop'){ const o=find('container');if(!o)throw Error('missing');o.State={Running:false,Pid:0,ExitCode:143,OOMKilled:false};output=o.Id; }else if(args[0]==='container'&&args[1]==='logs')output='full diagnostic'; else throw Error('unsupported external command '+JSON.stringify(args)); fs.writeFileSync(file,JSON.stringify(state));console.log(output); `, - ); - try { - const reference = { workspacePath: root, environment: "response-loss" }; - const prepared = await manageContainerEnvironment({ - ...reference, - action: "prepare", - endpoint: { - command: process.execPath, - arguments: [cli, state], - host: "unix:///test-daemon", - }, - image: "provided-image", - }); - expect(prepared.status).toBe("ok"); - const started = await manageContainerEnvironment({ - ...reference, - action: "start", - waitFor: "running", - }); - expect(started.status).toBe("failed"); - expect(started.failure?.message).toContain("creation response lost"); - const inspected = await manageContainerEnvironment({ - ...reference, - action: "inspect", - }); - expect( - inspected.observation?.objects.find((object) => object.id === "owned-id"), - ).toMatchObject({ running: false, pid: 0, exitCode: 143 }); - expect( - inspected.observation?.objects.find((object) => object.kind === "volume"), - ).toMatchObject({ state: "present" }); - const actual = JSON.parse(await readFile(state, "utf8")) as { - containers: { Id: string; State: { Running: boolean } }[]; - }; - expect( - actual.containers.find((object) => object.Id === "external-id")?.State - .Running, - ).toBe(true); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); + ); + try { + const reference = { workspacePath: root, environment: "response-loss" }; + const prepared = await manageContainerEnvironment({ + ...reference, + action: "prepare", + endpoint: { + command: process.execPath, + arguments: [cli, state], + host: "unix:///test-daemon", + }, + image: "provided-image", + }); + expect(prepared.status).toBe("ok"); + const started = await manageContainerEnvironment({ + ...reference, + action: "start", + waitFor: "running", + }); + expect(started.status).toBe("failed"); + expect(started.failure?.message).toContain("creation response lost"); + const inspected = await manageContainerEnvironment({ + ...reference, + action: "inspect", + }); + expect( + inspected.observation?.objects.find( + (object) => object.id === "owned-id", + ), + ).toMatchObject({ running: false, pid: 0, exitCode: 143 }); + expect( + inspected.observation?.objects.find( + (object) => object.kind === "volume", + ), + ).toMatchObject({ state: "present" }); + const actual = JSON.parse(await readFile(state, "utf8")) as { + containers: { Id: string; State: { Running: boolean } }[]; + }; + expect( + actual.containers.find((object) => object.Id === "external-id")?.State + .Running, + ).toBe(true); + await writeFile(state, JSON.stringify({ ...actual, networks: [] })); + const networkRemoved = await manageContainerEnvironment({ + ...reference, + action: "inspect", + }); + expect(networkRemoved.status).toBe("ok"); + expect( + networkRemoved.observation?.objects.find( + (object) => object.kind === "network", + ), + ).toMatchObject({ state: "absent" }); + expect( + networkRemoved.observation?.objects.find( + (object) => object.kind === "volume", + ), + ).toMatchObject({ state: "present" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/environment/turbo.json b/packages/environment/turbo.json index 7cb5eb1..c6d4bdc 100644 --- a/packages/environment/turbo.json +++ b/packages/environment/turbo.json @@ -2,14 +2,82 @@ "extends": ["//"], "tags": ["environment"], "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "cli": { + "tags": ["work:on-demand"], + "cache": false + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "postbuild": { + "tags": ["work:lifecycle"], + "cache": false + }, + "prepack": { + "tags": ["work:lifecycle"], + "cache": false + }, "test": { "dependsOn": [ - "$TURBO_EXTENDS$", + "build", "@digital/web#build", "@digital/web#test:e2e", "@digital/cli#build", "@digital/db-migrations#build" - ] + ], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:heavy"], + "cache": false + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] } } } diff --git a/packages/filesystem/package.json b/packages/filesystem/package.json index 2f94151..87dc348 100644 --- a/packages/filesystem/package.json +++ b/packages/filesystem/package.json @@ -19,12 +19,13 @@ "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules .", "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "test:public-interface": "pnpm build && tsc -p tsconfig.public-interface.json --noEmit --pretty false", + "test:public-interface": "tsc -p tsconfig.public-interface.json --noEmit --pretty false", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose --exclude '**/*.live.test.ts'", "test:corpus-real": "vitest run test/fixed-corpus-observation.live.test.ts --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/filesystem", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*" diff --git a/packages/filesystem/src/index.ts b/packages/filesystem/src/index.ts index ba55e14..4ca6042 100644 --- a/packages/filesystem/src/index.ts +++ b/packages/filesystem/src/index.ts @@ -1,5 +1,9 @@ import { createHash, randomUUID } from "node:crypto"; -import { constants as fsConstants } from "node:fs"; +import { + constants as fsConstants, + type Stats, + type BigIntStats, +} from "node:fs"; import { access, lstat, @@ -110,13 +114,13 @@ class NodeBoundDirectory { class NodeBoundFile { readonly handle: FileHandle; - readonly details: Awaited>; + readonly details: Stats | BigIntStats; readonly entryDirectory: FileHandle; readonly entryName: string; constructor( handle: FileHandle, - details: Awaited>, + details: Stats | BigIntStats, entryDirectory: FileHandle, entryName: string, ) { @@ -543,7 +547,7 @@ class NodeObservedByteSource extends ObservedByteSource { constructor( handle: FileHandle, logicalLocation: string, - details: Awaited>, + details: Stats | BigIntStats, entryDirectory: FileHandle, entryName: string, ) { @@ -583,7 +587,7 @@ class NodeObservedByteSource extends ObservedByteSource { } private async assertStable(): Promise { - let current: Awaited>; + let current: Stats | BigIntStats; let currentEntry: Awaited>; try { [current, currentEntry] = await Promise.all([ @@ -925,7 +929,7 @@ export class ReadOnlyFilesystemObserver implements ObservedFilesystemCapability readonly activeDirectoryTargets: ReadonlySet; readonly visitedDirectoryTargets: Set; }): Promise { - let directoryDetails: Awaited>; + let directoryDetails: Stats | BigIntStats; try { directoryDetails = await input.directory.handle.stat(); } catch (error) { @@ -1253,7 +1257,7 @@ function allowsDirectoryLinks( function fileObservation( relativePath: string, - details: Awaited>, + details: Stats | BigIntStats, objectIdentityScope: string, ): { readonly relativePath: string; @@ -1283,7 +1287,7 @@ function fileObservation( } function opaqueObjectIdentity( - details: Awaited>, + details: Stats | BigIntStats, observerScope: string, ): | { @@ -1620,10 +1624,7 @@ class NodeManagedArtifactByteSource { private readonly identity: string; private closed = false; - constructor( - handle: FileHandle, - details: Awaited>, - ) { + constructor(handle: FileHandle, details: Stats | BigIntStats) { this.handle = handle; this.byteLength = Number(details.size); this.identity = observedFileIdentity(details); diff --git a/packages/filesystem/test/artifact-frame-proof.test.ts b/packages/filesystem/test/artifact-frame-proof.test.ts index a7331c7..bf675cd 100644 --- a/packages/filesystem/test/artifact-frame-proof.test.ts +++ b/packages/filesystem/test/artifact-frame-proof.test.ts @@ -2,7 +2,7 @@ import { readFile, readdir, rm, writeFile, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, assert, expect, it } from "vitest"; import { ArtifactFrameProofStore } from "../src/index.ts"; @@ -54,7 +54,9 @@ it("publishes the fixed DGFPRF01 manifest and rejects any published-bit change", "8113a935d2d95c93fdbf5dfc3bf047889b8e27fb453dc5fb2d96d8ad732a83bd", ); - manifestBytes[70] ^= 1; + const originalByte = manifestBytes[70]; + assert.isDefined(originalByte); + manifestBytes[70] = originalByte ^ 1; await writeFile(location, manifestBytes); await expect(store.inspect(proof)).resolves.toBeUndefined(); await expect(store.open(proof)).rejects.toThrow( diff --git a/packages/filesystem/tsconfig.json b/packages/filesystem/tsconfig.json index 5420f5e..814d105 100644 --- a/packages/filesystem/tsconfig.json +++ b/packages/filesystem/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/filesystem/turbo.json b/packages/filesystem/turbo.json index bb82cc0..27218c4 100644 --- a/packages/filesystem/turbo.json +++ b/packages/filesystem/turbo.json @@ -1 +1,86 @@ -{ "extends": ["//"], "tags": ["infrastructure"] } +{ + "extends": ["//"], + "tags": ["infrastructure"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "test:public-interface": { + "tags": ["quality:light"], + "dependsOn": ["build"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "test:corpus-real": { + "dependsOn": ["build"], + "cache": false, + "passThroughEnv": [ + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_TMDB_ACCESS_TOKEN", + "DIGITAL_OMDB_API_KEY", + "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", + "DIGITAL_BANGUMI_CLIENT_ID", + "DIGITAL_BANGUMI_CLIENT_SECRET", + "DIGITAL_BANGUMI_REDIRECT_URI", + "DIGITAL_FANBOX_LIVE_FAILURE_EVIDENCE_PATH", + "DIGITAL_CI_EVIDENCE" + ], + "tags": ["quality:conditional"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/playback-format/package.json b/packages/playback-format/package.json index 552d0c7..84c3829 100644 --- a/packages/playback-format/package.json +++ b/packages/playback-format/package.json @@ -22,7 +22,8 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "test": "vitest run --reporter=verbose", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/playback-format", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*" diff --git a/packages/playback-format/turbo.json b/packages/playback-format/turbo.json index 011f2ed..db6b1da 100644 --- a/packages/playback-format/turbo.json +++ b/packages/playback-format/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/presentation/package.json b/packages/presentation/package.json index 9cf0d60..41fa3e7 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -21,7 +21,8 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts --source" + "dev:build": "pnpm --workspace-root run development:build @digital/presentation", + "dev:compile": "tsc -p tsconfig.json --customConditions digital-development --pretty false --noEmit" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/packages/presentation/test/asset-refresh-selection.test.ts b/packages/presentation/test/asset-refresh-selection.test.ts index 2bb810b..c7cec88 100644 --- a/packages/presentation/test/asset-refresh-selection.test.ts +++ b/packages/presentation/test/asset-refresh-selection.test.ts @@ -1,7 +1,10 @@ import type { AssetRefreshWorkflow } from "@digital/application"; import { describe, expect, it } from "vitest"; -import { resolveAssetRefreshSelection } from "../src/index"; +import { + resolveAssetRefreshSelection, + type AssetRefreshSelectionNode, +} from "../src/index"; describe("Asset Refresh Selection resolver", () => { it("resolves a synthetic parent and explicit multi-selection without losing repeated subject paths", () => { @@ -51,7 +54,7 @@ describe("Asset Refresh Selection resolver", () => { const parent: { key: string; label: string; - children: unknown[]; + children: AssetRefreshSelectionNode[]; } = { key: "parent", label: "Parent", @@ -77,13 +80,20 @@ describe("Asset Refresh Selection resolver", () => { } return { execution: { + id: "selection-refresh", + selection: { intent: "metadata-refresh" }, + status: "completed", + stage: "completed", + sourceRecordIds: [], + dispositions: [], targetDispositions: input.selection.subjects.map((target) => ({ key: `subject:${target.subjectId}`, target: { kind: "subject" as const, subjectId: target.subjectId }, status: "completed" as const, })), }, - } as Awaited>; + sourceRecords: [], + }; }, } satisfies Pick; const selection = resolveAssetRefreshSelection([ diff --git a/packages/presentation/test/bangumi-view.test.ts b/packages/presentation/test/bangumi-view.test.ts index 249b05c..7113742 100644 --- a/packages/presentation/test/bangumi-view.test.ts +++ b/packages/presentation/test/bangumi-view.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { generalAssetViewInformationRequirement, @@ -159,7 +159,7 @@ function frierenFacts(): readonly AssetViewFacts[] { }, }; const episodes = ( - workId: string, + workId: keyof typeof works, workTitle: string, count: number, continuousOffset: number, @@ -240,6 +240,7 @@ describe("Bangumi Asset View", () => { const projected = projectBangumiCatalogue(bloomFacts()); expect(projected).toHaveLength(1); + assert.isDefined(projected[0]); expect(projected[0]).toMatchObject({ id: "bangumi-series:entity_bangumi-subject_243981", title: "终将成为你", @@ -268,10 +269,13 @@ describe("Bangumi Asset View", () => { const projected = projectBangumiCatalogue(frierenFacts()); expect(projected).toHaveLength(1); + assert.isDefined(projected[0]); expect(projected[0].works.map((work) => work.title)).toEqual([ "葬送的芙莉莲", "葬送的芙莉莲 第二季", ]); + assert.isDefined(projected[0].works[0]); + assert.isDefined(projected[0].works[1]); expect(projected[0].works[0].episodes).toHaveLength(28); expect(projected[0].works[1].episodes).toHaveLength(10); expect(projected[0].episodes).toHaveLength(38); @@ -293,6 +297,7 @@ describe("Bangumi Asset View", () => { const reversed = projectBangumiCatalogue(frierenFacts().toReversed()); expect(reversed).toEqual(forward); + assert.isDefined(reversed[0]); expect( reversed[0].episodes.map((episode) => episode.continuousPosition), ).toEqual(Array.from({ length: 38 }, (_, index) => index + 1)); @@ -319,6 +324,7 @@ describe("Bangumi Asset View", () => { const projected = projectBangumiCatalogue(facts); expect(projected).toHaveLength(1); + assert.isDefined(projected[0]); expect(projected[0].works).toHaveLength(2); expect(projected[0].episodes).toHaveLength(38); }); diff --git a/packages/presentation/test/view-facts.ts b/packages/presentation/test/view-facts.ts index 62852f0..eed78e9 100644 --- a/packages/presentation/test/view-facts.ts +++ b/packages/presentation/test/view-facts.ts @@ -46,7 +46,7 @@ function description( EffectiveDescriptionRead["metadata"][number]["result"], { status: "resolved" } >["values"]; - const valuesByProperty = new Map(); + const valuesByProperty = new Map(); for (const [property, value] of properties) { const selected = valuesByProperty.get(property) ?? []; selected.push({ diff --git a/packages/presentation/tsconfig.json b/packages/presentation/tsconfig.json index 9868cef..c46a181 100644 --- a/packages/presentation/tsconfig.json +++ b/packages/presentation/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/presentation/turbo.json b/packages/presentation/turbo.json index 59c6022..de96870 100644 --- a/packages/presentation/turbo.json +++ b/packages/presentation/turbo.json @@ -3,7 +3,62 @@ "tags": ["presentation"], "tasks": { "build": { - "outputs": [] + "dependsOn": ["^build"], + "outputs": [], + "tags": ["quality:light"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] } } } diff --git a/packages/product-application/package.json b/packages/product-application/package.json index d5114d9..7c303ff 100644 --- a/packages/product-application/package.json +++ b/packages/product-application/package.json @@ -22,7 +22,8 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "test": "vitest run --reporter=verbose --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/product-application", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/packages/product-application/tsconfig.json b/packages/product-application/tsconfig.json index 5420f5e..814d105 100644 --- a/packages/product-application/tsconfig.json +++ b/packages/product-application/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/product-application/turbo.json b/packages/product-application/turbo.json index 4b41083..0cf50ef 100644 --- a/packages/product-application/turbo.json +++ b/packages/product-application/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["application"] } +{ + "extends": ["//"], + "tags": ["application"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/product-data/package.json b/packages/product-data/package.json index f82785d..4e8e118 100644 --- a/packages/product-data/package.json +++ b/packages/product-data/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "build": "tsc -b tsconfig.build.json --force --pretty false", "test": "vitest run --reporter=verbose", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/product-data", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*" diff --git a/packages/product-data/tsconfig.json b/packages/product-data/tsconfig.json index 4d954ba..3594588 100644 --- a/packages/product-data/tsconfig.json +++ b/packages/product-data/tsconfig.json @@ -11,5 +11,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/product-data/turbo.json b/packages/product-data/turbo.json index bb82cc0..dc430f7 100644 --- a/packages/product-data/turbo.json +++ b/packages/product-data/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["infrastructure"] } +{ + "extends": ["//"], + "tags": ["infrastructure"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json index 33e5310..2274ce7 100644 --- a/packages/sqlite/package.json +++ b/packages/sqlite/package.json @@ -30,7 +30,8 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/sqlite", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/packages/sqlite/turbo.json b/packages/sqlite/turbo.json index bb82cc0..6c36ac7 100644 --- a/packages/sqlite/turbo.json +++ b/packages/sqlite/turbo.json @@ -1 +1,66 @@ -{ "extends": ["//"], "tags": ["infrastructure"] } +{ + "extends": ["//"], + "tags": ["infrastructure"], + "tasks": { + "db:prepare:disposable": { + "tags": ["work:preparation"], + "cache": false, + "dependsOn": ["build"] + }, + "db:prepare:test-product-data": { + "tags": ["work:preparation"], + "cache": false, + "dependsOn": ["build"] + }, + "db:push:disposable": { + "tags": ["work:preparation"], + "cache": false + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/test-support/package.json b/packages/test-support/package.json index 2cb885b..0c2ec07 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -34,7 +34,8 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/test-support", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "devDependencies": { "@digital/typescript-config": "workspace:*", diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts index b85e4b1..d5ddc03 100644 --- a/packages/test-support/src/index.ts +++ b/packages/test-support/src/index.ts @@ -183,9 +183,7 @@ export class InMemoryObservedFilesystemCapability { private readonly scopes = new WeakSet(); private readonly options: InMemoryObservedFilesystemOptions; readonly capability: InMemoryCapabilityCoordinate; - readonly objectIdentityContract: - | InMemoryFilesystemObjectIdentityContract - | undefined; + readonly objectIdentityContract?: InMemoryFilesystemObjectIdentityContract; readonly boundScopes: InMemoryBoundObservedScope[] = []; readonly openedPaths: string[] = []; readonly closedScopes: InMemoryBoundObservedScope[] = []; @@ -197,7 +195,8 @@ export class InMemoryObservedFilesystemCapability { capabilityVersion: "1", contractVersion: "1", }; - this.objectIdentityContract = options.objectIdentityContract; + if (options.objectIdentityContract !== undefined) + this.objectIdentityContract = options.objectIdentityContract; } async inspectReadableDirectory(candidate: string): Promise<{ diff --git a/packages/test-support/test/controlled-source-io.test.ts b/packages/test-support/test/controlled-source-io.test.ts index 01de757..2f417ef 100644 --- a/packages/test-support/test/controlled-source-io.test.ts +++ b/packages/test-support/test/controlled-source-io.test.ts @@ -266,9 +266,17 @@ describe("可执行根的受控来源启动解析", () => { "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01668691", ), ).rejects.toBeInstanceOf(ControlledRequestRefusal); - await expect(resolved.sessionTransport.transact()).rejects.toBeInstanceOf( - ControlledRequestRefusal, - ); + await expect( + resolved.sessionTransport.transact({ + creatorId: "offline-creator", + privateContext: { + format: "fanbox-browser-request-context/2", + userAgent: "offline-test", + cookies: [], + }, + use: async (get) => await get({ kind: "login-status" }), + }), + ).rejects.toBeInstanceOf(ControlledRequestRefusal); }); }); diff --git a/packages/test-support/tsconfig.json b/packages/test-support/tsconfig.json index 5420f5e..814d105 100644 --- a/packages/test-support/tsconfig.json +++ b/packages/test-support/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/test-support/turbo.json b/packages/test-support/turbo.json index 167c499..8408b60 100644 --- a/packages/test-support/turbo.json +++ b/packages/test-support/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["test-support"] } +{ + "extends": ["//"], + "tags": ["test-support"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/typescript-config/turbo.json b/packages/typescript-config/turbo.json index db0d794..61227d4 100644 --- a/packages/typescript-config/turbo.json +++ b/packages/typescript-config/turbo.json @@ -1,4 +1,17 @@ { "extends": ["//"], - "tags": ["tooling"] + "tags": ["tooling"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "cache": false, + "tags": ["work:repair"] + }, + "build": {}, + "dev:build": {}, + "typecheck": {} + } } diff --git a/packages/web-delivery/package.json b/packages/web-delivery/package.json index af7d88f..b36bdcd 100644 --- a/packages/web-delivery/package.json +++ b/packages/web-delivery/package.json @@ -28,7 +28,8 @@ "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "test": "vitest run --reporter=verbose --passWithNoTests", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/web-delivery", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/packages/web-delivery/turbo.json b/packages/web-delivery/turbo.json index 492de3a..e2cb8b3 100644 --- a/packages/web-delivery/turbo.json +++ b/packages/web-delivery/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["delivery"] } +{ + "extends": ["//"], + "tags": ["delivery"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/packages/workspace-automation/README.md b/packages/workspace-automation/README.md new file mode 100644 index 0000000..709d49d --- /dev/null +++ b/packages/workspace-automation/README.md @@ -0,0 +1,222 @@ +# 普通目录预览与开发更新 + +`@digital/workspace-automation` 中的普通目录预览实现 是仓库唯一的普通目录准备与接入入口:一条命令把公开的环境生命周期 +(`@digital/environment` 的 `prepare / start / inspect / stop / rebuild / delete`)与正式的 +产品目录接入串起来,得到一个有资料、可浏览的预览。它不复制环境内部命令,也不自己拼进程、 +数据库或来源装配。 + +```bash +pnpm preview up --material /srv/media/books --material /srv/media/comics --library 我的书架 + # 两个真实目录归入同一资产库,默认离线;服务保持运行并打印地址 +pnpm preview:verify # 用原受控代表目录运行预览场景断言并按归属收尾 +pnpm preview help # 用法说明(--help 等同) +pnpm preview --help +``` + +`@digital/test-support/product-client` 是本入口使用的公共产品客户端:只走真实 HTTP 入口 +(`/api/ready`、`/api/settings`、`/api/settings/libraries`、`/api/refresh`、`/api/executions/:id`、 +`/api/intake`、`/api/catalogue`、`/api/correction-subjects/:id`、`/api/cover-choice/:id`、 +`/api/cover-decisions`、`/api/preview`、`/api/artifacts/:digest`、`/api/automation`、 +`/api/playback-interactions`),不接内部 Application、不灌库、不 mock 业务。 +取消信号在客户端创建时注入:已经排队或进行中的请求都会随取消立即失败,不会留下晚到操作。 + +## 三个阶段分别报告 + +1. **准备**:普通资料目录与运行配置被记录,产品数据世代建立。 +2. **服务就绪**:只采信环境自己给出的就绪结论(私有控制认证 + 签名探测),不由 + 「已准备」或「进程存活」推导。 +3. **接入完成**:按产品工作流的终态判断,绝不用就绪掩盖接入失败,也不按资产数量代产品宣布成功。 + +## 单独执行 + +`prepare / start / inspect / intake / stop / rebuild / delete` 各自可单独执行,每次都会报告 +实际地址、实际端口与产品日志位置。`help`(或 `--help`)打印用法说明后以 0 结束。命令写在选项 +之前(`preview up --port 52010`,不是 `preview --port 52010 up`),顺序不对时在请求阶段以 2 拒绝, +不触达任何环境操作。`--workspace` +必须是一份代码检出目录(默认本仓根),运行数据落在 `<工作区>/.digital/environments/<环境>`; +`--material` 与 `--manifest` 按字面值判定,必须是绝对路径,入口不按调用终端的工作目录猜测意图。 + +## 输入与来源 + +日常预览必须通过 `--material` 明确选择真实目录,可重复指定;一次接入建立一个命名资产库, +目录内的作品由正式识别流程处理。资产库组织由使用者选择,入口不按作品或测试案例拆库。 +复用环境的 `up` 未指定新目录时刷新既有资产库;尚无资产库时沿用环境输入。 +`intake` 未指定目录时沿用环境输入。没有已识别资产的目录仍可登记和观察,工作流结论独立报告。 + +日常来源默认 `offline`,不注入来源响应或合成凭据。需要真实联网时使用: + +```bash +pnpm preview up --material /srv/media --library 我的资产 \ + --source-mode real --allow-real-source --real-condition 已确认网络和素材可用 +``` + +需要授权的来源通过产品设置配置真实连接。未配置、不可用或缺少封面时保留实际状态, +入口不替换成测试封面,也不根据测试清单指定作品身份。 + +`verify` 默认使用 `fixtures/controlled-source-io/material/RJ01668691` 及其受控清单。 +完整资料验收由 `verify-audio-post-corpus.ts` 和 `verify-publication-screen-corpus.ts` 显式运行; +它们通过 `corpus-verification.ts` 配置验收所需的合成连接,不参与日常预览。 +显式受控模式须提供绝对路径的 `--manifest`。真实模式须同时给出 `--allow-real-source` 与 +至少一条 `--real-condition`,缺少条件即拒绝。相对目录、相对清单及 52001—52999 以外的端口同样被拒绝。 + +日常环境名默认 `completion-01-preview`,验收每次分配独立名称,可用 `--environment` 覆盖。 +验收临时输入同样按运行隔离;仅在自建环境成功回收后删除,失败或仍由调用方持有的现场保留输入供复现。 + +## 归属与收尾 + +- **自建 verify 环境**:成功后先保存证据,再停止并删除;任何断言失败或异常都改走失败保留分支—— + 先停止自有服务,再保留可重启的数据、日志与证据载体,不宣称已回收。 + CI 设置 `DIGITAL_CI_EVIDENCE` 时,结果与隔离输入写入该证据根,成功先停止并保留真实产品数据与日志;由同一 CI 交接在核实上传后调用公共删除入口。 +- **恢复上次失败记录**:`failed` 记录(上次准备失败,带着它自己的日志与数据载体)按恢复处理—— + 本入口重新准备并拥有**本次启动的服务**,收尾只停止这个服务;那条记录与它的载体不属于本入口, + 成功也不删除,停止与删除仍归原使用者决定。只有本次运行之前完全没有记录(`unprepared`)才取得删除权。 +- **`--provided`**:环境由调用方提供,本入口绝不准备、启动、停止、重建或删除它——成功、失败 + 与中断都一样,只做只读查看和业务断言,之后仍可被提供方独立使用。 +- **`up` 的复用前提**:只有本次运行之前没有任何环境记录(`unprepared`)或上次准备失败 + (`failed`)时,`up` 才自建并取得归属;已有世代一律原样复用——就绪世代直接沿用它的运行地址, + 不重新准备也不重启,归属仍归原使用者,失败也只保留现状。配置不兼容以 3 拒绝,未被接管以 4 拒绝。 +- **verify 前提**:环境在本次运行之前已存在且未给 `--provided` 时,只有 `failed` 记录允许按恢复 + 运行,其余状态直接拒绝,绝不复用并删除他人状态。 +- 主错误与收尾错误始终分开打印,收尾错误不覆盖主错误;`SIGINT`/`SIGTERM` 会中止进行中的 + 等待与晚到操作,自有服务按归属停止、现场保留后才退出(130/143),提供方环境原样留下。 + +## 退出码 + +| 码 | 含义 | +| --- | ------------------------------------------ | +| 0 | 结论成功 | +| 1 | 环境或产品操作失败(主错误已打印) | +| 2 | 用法或来源选择不有效(未触达任何环境操作) | +| 3 | 配置不兼容,需要显式重建 | +| 4 | 环境不受管理 | +| 5 | 环境拒绝请求 | +| 6 | 服务已就绪,但目录接入或业务断言未通过 | +| 130 | 被 SIGINT 中断;资源按归属处理后才退出 | +| 143 | 被 SIGTERM 中断;资源按归属处理后才退出 | + +## 覆盖与后续 + +本入口取代了根上原来的便捷脚本 `prototype`(`pnpm --filter @digital/web dev`):那个入口 +只起前端开发服务,不准备环境、不建产品数据、不做目录接入,也没有归属与收尾约定,因此这些 +逻辑过去散落在各人的手工命令里。`pnpm preview` 现在承担同一职责,`verify` 子命令把 +「从未接入 → 浏览 / 刷新 / 读取封面 / 人工编辑 / 子集与多目录接入 / 未列入来源请求失败 → +改名、自动刷新、播放进度与人工封面在重启后保留 → 显式重建保持输入字节」做成可重复执行的断言。 +日常 `up` 成功后保持服务运行;使用者显式停止、重启、重建或删除。日常预览与代表验证沿用同一 +公共生命周期,业务输入、来源响应和预期留在调用方,不由环境能力解释作品。 + +## 保留产品状态的代码更新 + +先通过普通预览完成准备和目录接入,再停止现有服务,进入同一个环境的代码更新会话: + +```bash +pnpm preview up --environment my-preview --material /srv/media --library 我的资产 +pnpm preview stop --environment my-preview +pnpm preview:dev my-preview +``` + +开发入口要求已有、已停止的准备记录,不重复接入目录,也不重建产品数据。 +领域、应用、来源及服务端变更由 Turbo 的实际依赖图构建并替换服务,组件与样式沿用热更新。 +编译失败时旧服务停止,完整诊断留在开发日志中;恢复有效代码后会话自动恢复。 +各包通过自己的 `dev:compile` 编译本地输出;`dev:build` 交由根开发任务停止旧世代并记录失败,包之间的构建顺序仍由 Turbo 原生图决定。 + +另一个终端可用 `pnpm preview inspect --environment my-preview` 查看实际地址、更新状态与日志。 +用 `pnpm preview stop --environment my-preview` 独立终结本次开发会话;停止后代码变化不再启动服务。 +终端中断开发入口也会停止自有服务并保留产品数据及日志。准备、启动、停止、重建和删除仍可单独使用; +普通启动缺省使用生产运行模式,切换模式须先显式停止。 + +## 与本地自动化并行 + +开发预览、CLI 和 Web 自动化使用各自的代码检出、环境名、空闲端口、临时目录及证据目录。 +每份检出独立安装依赖和构建;开发会话使用自己的 `.turbo/development/dist`,正式构建仍写本检出的 +`dist`。只读素材可以共用,产品数据与来源连接设置不可共用,也不要让两份检出互相指向构建输出。 +使用原 CLI journey 和 Web managed/provided 入口,不直接启动未经准备的产品进程。 + +只有实际完成公共停止并确认自有进程退出后才能删除自己的环境。失败现场保留原错误、产品日志和 +浏览器诊断,经公共 `start` 显式重启后继续排查;无法确认归属或退出时保留现场,不按端口清理他方资源。 + +## 单容器与 Compose 部署 + +部署入口是 [Web Dockerfile](../../apps/web/Dockerfile) 与 +[Compose 配置](../../docker-compose.yml),不需要测试框架或素材清单。以下命令从仓库根目录执行, +要求已有可用的 Docker;Compose 用法另需 Docker Compose。构建使用正式 Turbo 依赖图: + +```bash +docker build -f apps/web/Dockerfile --target standalone -t digital-standalone:latest . +docker build -f apps/web/Dockerfile --target runtime -t digital-runtime:latest . +``` + +`standalone` 携带正式迁移,默认执行 `prepare-and-start`;`runtime` 只启动 Web,不执行迁移, +未准备、版本不兼容、迁移失败或损坏的目录数据库不能就绪。两种镜像默认以 `1001:1001` 运行。 +产品数据与被观察文件必须分离:先按部署机器的权限管理方式准备已有目录,使运行用户可读写数据目录、 +可读取素材目录及其父目录。以下路径须替换为 Docker 后端机器上的实际绝对路径;远程 Docker 的挂载路径 +属于后端机器,镜像与路径也必须在该后端可用。 + +```bash +export DIGITAL_PRODUCT_DATA_HOST_PATH=/srv/digital/data +export DIGITAL_OBSERVED_ROOT_HOST_PATH=/srv/digital/media +export DIGITAL_UID=1001 +export DIGITAL_GID=1001 +``` + +单容器直接准备并启动: + +```bash +docker run -d --name digital-web --user "$DIGITAL_UID:$DIGITAL_GID" \ + -p 127.0.0.1:3000:3000 \ + --mount "type=bind,src=$DIGITAL_PRODUCT_DATA_HOST_PATH,dst=/data" \ + --mount "type=bind,src=$DIGITAL_OBSERVED_ROOT_HOST_PATH,dst=/observed/media,readonly" \ + digital-standalone:latest +curl --fail http://127.0.0.1:3000/api/ready +docker logs digital-web +``` + +也可以先单独准备,再用 `runtime` 启动,替代上面的单容器命令;迁移成功退出后才启动服务: + +```bash +docker run --rm --user "$DIGITAL_UID:$DIGITAL_GID" \ + --mount "type=bind,src=$DIGITAL_PRODUCT_DATA_HOST_PATH,dst=/data" \ + digital-standalone:latest prepare-only +docker run -d --name digital-web --user "$DIGITAL_UID:$DIGITAL_GID" \ + -p 127.0.0.1:3000:3000 \ + --mount "type=bind,src=$DIGITAL_PRODUCT_DATA_HOST_PATH,dst=/data" \ + --mount "type=bind,src=$DIGITAL_OBSERVED_ROOT_HOST_PATH,dst=/observed/media,readonly" \ + digital-runtime:latest +``` + +Compose 复用同一组目录和身份,并显式选择已构建镜像。UID 与 GID 都必须非零;配置不自动创建宿主挂载目录。 +`migrate` 成功退出后,`web` 才启动,素材挂载保持只读: + +```bash +export DIGITAL_MIGRATION_IMAGE=digital-standalone:latest +export DIGITAL_RUNTIME_IMAGE=digital-runtime:latest +docker compose -p digital-example up -d --no-build --wait +docker compose -p digital-example ps -a +docker compose -p digital-example logs migrate web +curl --fail http://127.0.0.1:3000/api/ready +``` + +Compose 默认只绑定 `127.0.0.1:3000`,可显式设置 `DIGITAL_BIND_ADDRESS` 与 `DIGITAL_PORT`。 +两种部署都通过产品设置接入容器内的 `/observed/media`,挂载本身不会登记资料库或执行目录接入。 +单容器用 `docker stop -t 10 digital-web` 停止、`docker start digital-web` 重启;保存所需日志后, +`docker rm digital-web` 删除已停止容器。Compose 用 `docker compose -p digital-example stop -t 10` +停止,`docker compose -p digital-example up -d --no-build --wait` 重启, +`docker compose -p digital-example down` 删除该项目容器与网络。这些命令保留绑定的数据和素材目录; +一致备份须先确认应用退出,再将整个产品数据根作为一个单元保存,素材不属于产品数据备份。 + +### 受控部署验收 + +`pnpm check:heavy` 和 `pnpm check:full` 默认运行 standalone 与 Compose 的受控部署验收。根编排显式提供本次工作区、仓库受控素材和 Compose 配置,所属包的准备任务构建正式镜像并准备 Chromium;本地与 CI 使用同一实现。只运行部署范围时使用: + +```bash +pnpm exec turbo run deployment --filter=@digital/web +``` + +验收要求可用的 Unix Docker daemon、后端可见的挂载路径和必要文件权限,不需要私人素材或真实来源授权。准备任务每次确认环境和镜像,Docker/BuildKit 复用原生构建缓存;缓存或镜像缺失时会重建,缺少运行条件会明确失败。 + +构建网络由 Docker 默认配置提供。宿主网络可达而桥接网络无法访问依赖仓库的开发环境,可显式设置 `DIGITAL_DEPLOYMENT_BUILD_NETWORK=host`,向同一正式构建传入 Docker 的原生网络选项;CI 沿用 runner 的默认网络。 + +仓库开发容器使用 Unix Docker 宿主的网络,并将工作区挂载到与宿主相同的绝对路径,使发布端口、素材和证据目录在验收进程与 daemon 两侧都可访问。更新这项配置后须重建开发容器;既有会话仍使用原来的路径与网络。 + +检查默认选择空闲端口,仍可通过 `DIGITAL_DEPLOYMENT_PORT` 与 `DIGITAL_COMPOSE_CHECK_PORT` 指定独占端口。环境及证据使用本次唯一身份;成功先保存证据再回收自有资源,包括已经验证的预期失败反例,真实失败保留诊断和数据。 + +设置 `DIGITAL_CI_EVIDENCE` 时,检查把自有环境与证据交给同一 CI 收尾流程,核实实际上传之后才删除。镜像构建成功、配置展开或辅助服务启动都不代替部署断言通过。 diff --git a/packages/workspace-automation/package.json b/packages/workspace-automation/package.json new file mode 100644 index 0000000..490d3b7 --- /dev/null +++ b/packages/workspace-automation/package.json @@ -0,0 +1,29 @@ +{ + "name": "@digital/workspace-automation", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint": "oxlint --quiet --format=unix --type-aware --config ../../oxlint.config.ts .", + "lint:fix": "oxlint --format=unix --type-aware --config ../../oxlint.config.ts . --fix", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + }, + "dependencies": { + "@digital/cli": "workspace:*", + "@digital/environment": "workspace:*", + "@digital/test-support": "workspace:*" + }, + "devDependencies": { + "@digital/typescript-config": "workspace:*", + "@types/node": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript-7": "catalog:" + }, + "engines": { + "node": "24" + } +} diff --git a/scripts/build-controlled-fanbox-session-fixture.ts b/packages/workspace-automation/scripts/build-controlled-fanbox-session-fixture.ts similarity index 98% rename from scripts/build-controlled-fanbox-session-fixture.ts rename to packages/workspace-automation/scripts/build-controlled-fanbox-session-fixture.ts index 85809e6..9f64274 100644 --- a/scripts/build-controlled-fanbox-session-fixture.ts +++ b/packages/workspace-automation/scripts/build-controlled-fanbox-session-fixture.ts @@ -8,7 +8,9 @@ import { loadControlledSourceIo, type ControlledRequestEntryInput, type ControlledSessionEntryInput, -} from "../packages/test-support/src/source-io.ts"; +} from "@digital/test-support/source-io"; + +import { requireInput } from "./inputs.ts"; /** * 受控 FANBOX 会话夹具的维护脚本:写出缩减资料、封闭端点响应字节、合成凭据与各份清单, @@ -18,12 +20,7 @@ import { * `expectations/customudon-7225781.reviewed-oracle.json` 记录的独立审阅结论; * 其余字段只是既有解析契约要求的协议代表值,见本目录 README。 */ -const fixtureDirectory = path.resolve( - import.meta.dirname, - "..", - "fixtures", - "controlled-fanbox-session", -); +const fixtureDirectory = requireInput("DIGITAL_AUTOMATION_OUTPUT_DIRECTORY"); const creatorId = "customudon"; const postId = "7225781"; diff --git a/scripts/build-controlled-source-io-fixture.ts b/packages/workspace-automation/scripts/build-controlled-source-io-fixture.ts similarity index 95% rename from scripts/build-controlled-source-io-fixture.ts rename to packages/workspace-automation/scripts/build-controlled-source-io-fixture.ts index 9edf16e..d7e3a40 100644 --- a/scripts/build-controlled-source-io-fixture.ts +++ b/packages/workspace-automation/scripts/build-controlled-source-io-fixture.ts @@ -2,18 +2,15 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { deflateSync } from "node:zlib"; -import { loadControlledSourceIo } from "../packages/test-support/src/source-io.ts"; +import { loadControlledSourceIo } from "@digital/test-support/source-io"; + +import { requireInput } from "./inputs.ts"; /** * 受控外部来源 I/O 夹具的生成器:把轻量的合成媒体写进夹具目录,并给出清单的内容身份。 * 输出完全确定,因此内容身份只有在清单文本或响应字节被改动后才会变化。 */ -const fixtureDirectory = path.resolve( - import.meta.dirname, - "..", - "fixtures", - "controlled-source-io", -); +const fixtureDirectory = requireInput("DIGITAL_AUTOMATION_OUTPUT_DIRECTORY"); const workTitle = "塩対応な義妹ちゃん、大好きな兄さんの濃厚快感責めでメロ堕ち♡〜逆転イチャあま同棲ASMR〜"; diff --git a/packages/workspace-automation/scripts/build-development.ts b/packages/workspace-automation/scripts/build-development.ts new file mode 100644 index 0000000..39f9a75 --- /dev/null +++ b/packages/workspace-automation/scripts/build-development.ts @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; + +import { + stopEnvironment, + updateDevelopmentEnvironment, +} from "@digital/environment"; + +const workspacePath = process.env.DIGITAL_DEVELOPMENT_WORKSPACE; +const environment = process.env.DIGITAL_DEVELOPMENT_ENVIRONMENT; +const sessionId = process.env.DIGITAL_DEVELOPMENT_SESSION; +const [packageName, ...extraArguments] = process.argv.slice(2); +if (packageName === undefined || extraArguments.length > 0) + throw new Error("用法:pnpm development:build <所属工作区包名>"); +if ( + workspacePath === undefined || + environment === undefined || + sessionId === undefined +) + throw new Error("开发编译必须由环境开发入口启动"); +const stopped = await stopEnvironment({ + workspacePath, + environment, + developmentSession: sessionId, +}); +if (stopped.status !== "stopped" && stopped.status !== "already-stopped") + throw new Error( + stopped.failure?.message ?? "旧开发世代无法确认停止,拒绝覆盖其模块", + ); +// 根只协调共享开发世代;包自己拥有编译命令与输出,依赖顺序由 Turbo 决定。 +const child = spawn( + "pnpm", + ["--filter", packageName, "--fail-if-no-match", "run", "dev:compile"], + { cwd: workspacePath, stdio: "inherit" }, +); +const exit = await new Promise<{ + code: number | null; + signal: string | null; + failure?: string; +}>((resolve) => { + child.once("error", (error) => + resolve({ code: null, signal: null, failure: error.message }), + ); + child.once("close", (code, signal) => resolve({ code, signal })); +}); +if (exit.code !== 0) { + await updateDevelopmentEnvironment({ + workspacePath, + environment, + sessionId, + update: "failed", + failure: `开发编译 ${packageName} 退出 ${String(exit.code)} / ${String(exit.signal)}${exit.failure === undefined ? "" : `:${exit.failure}`};完整首因见开发日志`, + }); +} +process.exitCode = + exit.code ?? + (exit.signal === "SIGINT" ? 130 : exit.signal === "SIGTERM" ? 143 : 1); diff --git a/scripts/ci-environment.ts b/packages/workspace-automation/scripts/ci-environment.ts similarity index 70% rename from scripts/ci-environment.ts rename to packages/workspace-automation/scripts/ci-environment.ts index 34104cb..f162c90 100644 --- a/scripts/ci-environment.ts +++ b/packages/workspace-automation/scripts/ci-environment.ts @@ -14,33 +14,28 @@ import { deleteEnvironment, inspectEnvironment, stopEnvironment, -} from "../packages/environment/src/index.ts"; +} from "@digital/environment"; +import { manageContainerEnvironment } from "@digital/environment/container"; + +import { requireInput } from "./inputs.ts"; -type Capability = "root" | "cli" | "web"; type Owner = { workspacePath: string; environment: string }; type Collection = { owners: Owner[]; + containerOwners: Owner[]; saveErrors: string[]; cleanupErrors: string[]; collected: string[]; }; -const workspacePath = path.resolve(import.meta.dirname, ".."); -const [phase, rawCapability] = process.argv.slice(2); -if ( - rawCapability !== "root" && - rawCapability !== "cli" && - rawCapability !== "web" -) - throw new Error( - "用法:ci-environment.ts ", - ); -const capability: Capability = rawCapability; +const workspacePath = requireInput("DIGITAL_AUTOMATION_WORKSPACE"); +const [phase, ...extraArguments] = process.argv.slice(2); +if (extraArguments.length > 0) + throw new Error("用法:ci-environment.ts "); const evidence = path.resolve( process.env.DIGITAL_CI_EVIDENCE ?? - path.join(workspacePath, ".scratch", "ci", capability), + path.join(workspacePath, ".scratch", "ci", "full"), ); -const ledger = path.join(evidence, "owners.ndjson"); -const environment = `completion-18-web-${process.env.GITHUB_RUN_ID ?? process.pid}-${process.env.GITHUB_RUN_ATTEMPT ?? 1}`; +const containerLedger = path.join(evidence, "container-owners.ndjson"); const privateValues = Object.entries(process.env) .filter( ([name, value]) => @@ -75,62 +70,22 @@ async function readJson(name: string): Promise { async function run(): Promise { await mkdir(evidence, { recursive: true, mode: 0o700 }); - if (capability === "root") { - const initial = await environmentNames(); - if (initial.length !== 0) - throw new Error( - "根 CI 需要独占 fresh checkout;发现既有环境,不认领它们", - ); - await save("initial-environments.json", { - workspacePath, - names: initial, - observedAt: new Date().toISOString(), - }); - } - if (capability === "web") - await appendFile( - ledger, - `${JSON.stringify({ workspacePath, environment, issuedAt: new Date().toISOString() })}\n`, - ); - const command = - capability === "root" - ? "pnpm" - : capability === "cli" - ? "pnpm" - : process.execPath; - const args = - capability === "root" - ? ["run", "check"] - : capability === "cli" - ? [ - "--filter", - "@digital/cli", - "run", - "test:e2e", - "--retain-on-success", - "--evidence-directory", - path.join(evidence, "journeys"), - "--handoff-ledger", - ledger, - ] - : [ - "apps/web/scripts/run-managed-service-acceptance.ts", - "--mode", - "owned", - "--environment", - environment, - "--workspace", - workspacePath, - "--artifacts", - path.join(evidence, "web"), - "--ledger", - path.join(evidence, "web-ledger.ndjson"), - "--keep-on-success", - "true", - ]; + const initial = await environmentNames(); + if (initial.length !== 0) + throw new Error("CI 需要独占的新检出;发现既有环境,不认领它们"); + await save("initial-environments.json", { + workspacePath, + names: initial, + observedAt: new Date().toISOString(), + }); + const command = process.execPath; + const args = [ + "--conditions=digital-source", + "scripts/task-gates.ts", + "check:full", + ]; const startedAt = new Date().toISOString(); await save("execution.json", { - capability, workspacePath, command, args, @@ -154,13 +109,18 @@ async function run(): Promise { "COREPACK_HOME", "PNPM_HOME", "PLAYWRIGHT_BROWSERS_PATH", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_BUILDKIT", + "BUILDKIT_PROGRESS", + "DIGITAL_DEPLOYMENT_BUILD_NETWORK", ].flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]!]], ), ); const child = spawn(command, args, { cwd: workspacePath, - env: childEnvironment, + env: { ...childEnvironment, DIGITAL_CI_EVIDENCE: evidence }, stdio: ["ignore", "pipe", "pipe"], }); let interrupted: string | null = null; @@ -214,7 +174,6 @@ async function run(): Promise { process.off("SIGINT", onInt); process.off("SIGTERM", onTerm); await save("execution.json", { - capability, workspacePath, command, args, @@ -231,21 +190,10 @@ async function run(): Promise { : 1; } -async function owners(errors: string[]): Promise { - if (capability === "root") { - const initial = await readJson<{ workspacePath: string; names: string[] }>( - "initial-environments.json", - ); - if (initial.workspacePath !== workspacePath || initial.names.length !== 0) - throw new Error("缺少独占工作区的空初态,不能认领根检查环境"); - return (await environmentNames()).map((environment) => ({ - workspacePath, - environment, - })); - } +async function containerOwners(errors: string[]): Promise { let lines: string; try { - lines = await readFile(ledger, "utf8"); + lines = await readFile(containerLedger, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; @@ -256,20 +204,29 @@ async function owners(errors: string[]): Promise { const owner = JSON.parse(line) as Owner; if ( owner.workspacePath !== workspacePath || - !/^completion-(02|18)-[A-Za-z0-9._-]+$/u.test(owner.environment) + !/^completion-(15|16)-[A-Za-z0-9._-]+$/u.test(owner.environment) ) - throw new Error("交接归属不属于本作业工作区"); - result.set(owner.environment, { - workspacePath, - environment: owner.environment, - }); + throw new Error("容器交接归属不属于本作业工作区"); + result.set(owner.environment, owner); } catch (error) { - errors.push(`归属交接记录: ${errorText(error)}`); + errors.push(`容器交接记录: ${errorText(error)}`); } } return [...result.values()]; } +async function owners(containers: readonly Owner[]): Promise { + const initial = await readJson<{ workspacePath: string; names: string[] }>( + "initial-environments.json", + ); + if (initial.workspacePath !== workspacePath || initial.names.length !== 0) + throw new Error("缺少独占工作区的空初态,不能认领检查环境"); + const containerNames = new Set(containers.map((owner) => owner.environment)); + return (await environmentNames()) + .filter((name) => !containerNames.has(name)) + .map((environment) => ({ workspacePath, environment })); +} + async function environmentNames(): Promise { try { return ( @@ -298,8 +255,10 @@ async function collect(): Promise { } catch (error) { saveErrors.push(`执行记录保存状态: ${errorText(error)}`); } + const containers = await containerOwners(saveErrors); const result: Collection = { - owners: await owners(saveErrors), + owners: await owners(containers), + containerOwners: containers, saveErrors, cleanupErrors: [], collected: [], @@ -418,44 +377,83 @@ async function collect(): Promise { ); } } - if (capability === "root") { - // 根默认 CLI 仍写原正式结果路径;其命令结果与场景结局也属于本次上传。 - const journeyResults = path.join(workspacePath, "run", "02-evidence"); + for (const owner of containers) { + let stopped: + | Awaited> + | undefined; + let observed: + | Awaited> + | undefined; try { - for (const entry of await readdir(journeyResults, { - withFileTypes: true, - })) { - if (!entry.isFile()) continue; - try { - const target = path.join(evidence, "journeys"); - await mkdir(target, { recursive: true, mode: 0o700 }); - await writeFile( - path.join(target, entry.name), - redact( - await readFile(path.join(journeyResults, entry.name), "utf8"), - ), - ); - } catch (error) { - result.saveErrors.push( - `CLI 场景结果 ${entry.name}: ${errorText(error)}`, - ); - } - } + stopped = await manageContainerEnvironment({ ...owner, action: "stop" }); + } catch (error) { + result.cleanupErrors.push( + `${owner.environment} stop: ${errorText(error)}`, + ); + } + try { + observed = await manageContainerEnvironment({ + ...owner, + action: "inspect", + }); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") - result.saveErrors.push(`CLI 场景结果枚举: ${errorText(error)}`); + result.cleanupErrors.push( + `${owner.environment} inspect: ${errorText(error)}`, + ); } - for (const name of ["test-results", "playwright-report"]) { - try { - await cp( - path.join(workspacePath, "apps", "web", name), - path.join(evidence, "browser", name), - { recursive: true }, + for (const operation of [stopped, observed]) { + result.cleanupErrors.push(...(operation?.cleanupErrors ?? [])); + result.saveErrors.push(...(operation?.saveErrors ?? [])); + } + try { + await save(`${owner.environment}-stopped.json`, { stopped, observed }); + } catch (error) { + result.saveErrors.push( + `${owner.environment} 停止结果: ${errorText(error)}`, + ); + } + if ( + stopped?.status !== "ok" || + observed?.status !== "ok" || + observed.observation?.objects.some( + (object) => object.running === true || object.state === "unknown", + ) + ) { + result.cleanupErrors.push( + `${owner.environment}: 容器停止或独立退出未确认`, + ); + continue; + } + if (observed.remaining.length === 0 && !observed.observation?.dataPresent) + continue; + let saved: + | Awaited> + | undefined; + try { + saved = await manageContainerEnvironment({ + ...owner, + action: "save", + includeData: true, + directory: path.join(evidence, "containers", owner.environment), + }); + result.saveErrors.push(...saved.saveErrors); + result.cleanupErrors.push(...saved.cleanupErrors); + if (saved.status !== "ok" || saved.evidence?.dataSaved !== true) + result.saveErrors.push( + `${owner.environment}: 容器数据及日志未完整保存`, ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") - result.saveErrors.push(`浏览器诊断 ${name}: ${errorText(error)}`); - } + else result.collected.push(owner.environment); + } catch (error) { + result.saveErrors.push( + `${owner.environment} 容器数据及日志: ${errorText(error)}`, + ); + } + try { + await save(`${owner.environment}-collected.json`, saved); + } catch (error) { + result.saveErrors.push( + `${owner.environment} 收集结果: ${errorText(error)}`, + ); } } await save("collection.json", result); @@ -528,6 +526,42 @@ async function remove(): Promise { results.push({ owner, error: errorText(error) }); } } + for (const owner of collection.containerOwners) { + try { + const before = await manageContainerEnvironment({ + ...owner, + action: "inspect", + }); + if ( + before.status !== "ok" || + before.observation?.objects.some( + (object) => object.running === true || object.state === "unknown", + ) + ) + throw new Error("容器删除前退出状态未确认"); + const deletion = await manageContainerEnvironment({ + ...owner, + action: "delete", + deleteData: true, + }); + const after = await manageContainerEnvironment({ + ...owner, + action: "inspect", + }); + results.push({ owner, before, deletion, after }); + if ( + deletion.status !== "ok" || + deletion.cleanupErrors.length > 0 || + after.status !== "ok" || + after.remaining.length > 0 || + after.observation?.dataPresent + ) + failed = true; + } catch (error) { + failed = true; + results.push({ owner, error: errorText(error) }); + } + } await save("deletion.json", { artifactId: id, results, failed }); // 删除发生在上传之后,后续结果还须进入可下载的实际作业日志。 console.log( diff --git a/packages/workspace-automation/scripts/corpus-verification.ts b/packages/workspace-automation/scripts/corpus-verification.ts new file mode 100644 index 0000000..662f949 --- /dev/null +++ b/packages/workspace-automation/scripts/corpus-verification.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + intakeDirectories, + type IntakeOutcome, + type JsonObject, + type JsonValue, + type ProductClient, +} from "@digital/test-support/product-client"; + +/** 显式资料验收专用;合成来源连接不用于日常预览。 */ +export type PreviewCorpusCase = { + readonly id: string; + readonly kind: + | "audio" + | "post" + | "unrecognized" + | "epub" + | "pdf" + | "anime" + | "film"; + readonly directory: string; + readonly directed?: boolean; + readonly tmdb?: number; + readonly official?: { readonly isbn: string }; +}; +const object = (value: JsonValue | undefined): JsonObject => { + assert(value !== null && typeof value === "object" && !Array.isArray(value)); + return value as JsonObject; +}; +const array = (value: JsonValue | undefined): readonly JsonValue[] => { + assert(Array.isArray(value)); + return value; +}; +const string = (value: JsonValue | undefined): string => { + assert.equal(typeof value, "string"); + return value as string; +}; + +/** 已存在(包括撤销、不可用)的配置由使用者持有,预览不能暗中替换或恢复。 */ +async function connectionNeedsConfiguration( + client: ProductClient, + id: string, +): Promise { + const settings = await client.get("/settings"); + assert.equal(settings.status, 200); + const connection = array(object(settings.body)["sources"]) + .map(object) + .find((source) => source["id"] === id); + return ( + connection === undefined || + connection["credentialStatus"] === "unconfigured" + ); +} + +export async function configureAudioPostSources( + client: ProductClient, + fixture: string, + cases: readonly PreviewCorpusCase[], +): Promise { + if ( + !cases.some((item) => item.kind === "post") || + !(await connectionNeedsConfiguration(client, "fanbox/customudon")) + ) + return; + const browserCredential = JSON.parse( + await readFile( + path.join(fixture, "connections/synthetic-customudon.json"), + "utf8", + ), + ) as JsonObject; + const connection = await client.post("/settings/connections", { + source: "fanbox", + action: "configure", + creatorId: "customudon", + browserCredential, + }); + assert.equal(connection.status, 200); +} + +export async function configurePublicationScreenSources( + client: ProductClient, + cases: readonly PreviewCorpusCase[], + port: number, +): Promise { + const configured: JsonValue[] = []; + if (cases.some((c) => c.kind === "film")) { + for (const source of ["tmdb", "omdb"]) { + if (!(await connectionNeedsConfiguration(client, source))) continue; + const result = await client.post("/settings/connections", { + source, + action: "configure", + secret: + source === "tmdb" ? "synthetic-tmdb-token" : "synthetic-omdb-key", + }); + assert.equal(result.status, 200); + configured.push({ source, inspection: result.body }); + } + } + if ( + cases.some((c) => c.kind === "anime") && + (await connectionNeedsConfiguration(client, "bangumi")) + ) { + assert.equal( + ( + await client.post("/settings/connections", { + source: "bangumi", + action: "configure-client", + clientId: "synthetic-client", + clientSecret: "synthetic-client-secret", + redirectUri: `http://127.0.0.1:${String(port)}/api/source-connections/bangumi/callback`, + }) + ).status, + 200, + ); + const authorization = await client.post( + "/source-connections/bangumi/authorize", + {}, + ); + assert.equal(authorization.status, 201); + const state = new URL( + string(object(authorization.body)["authorizationUrl"]), + ).searchParams.get("state"); + assert(state); + const callback = await client.get("/source-connections/bangumi/callback", { + code: "synthetic-code", + state, + }); + assert.equal(callback.status, 200); + configured.push({ source: "bangumi", inspection: callback.body }); + } + return configured; +} + +export async function intakeAudioPostCase( + client: ProductClient, + fixture: string, + item: PreviewCorpusCase, + workflowTimeoutMs = 300_000, + displayName = `轻量资料 ${item.id}`, +): Promise { + return await intakeDirectories(client, { + displayName, + directories: [path.join(fixture, item.directory)], + recognitionProfileIds: + item.kind === "audio" + ? ["dlsite-voice"] + : item.kind === "post" + ? ["fanbox"] + : ["dlsite-voice", "fanbox", "books", "anime", "movies"], + sourceIdentities: ["dlsite", "fanbox"], + workflowTimeoutMs, + }); +} + +export async function intakePublicationScreenCase( + client: ProductClient, + fixture: string, + item: PreviewCorpusCase, + workflowTimeoutMs = 300_000, + displayName = `出版物与影视资料 ${item.id}`, +): Promise<{ + readonly intake: IntakeOutcome; + readonly operations: Readonly>; +}> { + const intake = await intakeDirectories(client, { + displayName, + directories: [path.join(fixture, item.directory)], + recognitionProfileIds: [ + item.kind === "anime" + ? "anime" + : item.kind === "film" + ? "movies" + : "books", + ], + sourceIdentities: + item.kind === "anime" + ? ["bangumi"] + : item.kind === "film" + ? ["tmdb", "omdb"] + : ["nbinet"], + workflowTimeoutMs, + }); + const operations: Record = {}; + const rootId = intake.observedRootIds[0]; + assert(rootId); + if (item.directed && !intake.reused) { + assert.equal(intake.assetIds.length, 0, "非规范收藏包不应自动制造资产"); + const directed = await client.post("/refresh", { + observedRootId: rootId, + relativeScope: ".", + directedSourceIdentity: "tmdb", + directedSourceInput: { movie: String(item.tmdb) }, + }); + operations["directed"] = directed; + assert.equal(directed.status, 201); + const execution = await client.awaitExecution( + string(object(directed.body)["id"]), + workflowTimeoutMs, + ); + operations["directedExecution"] = execution; + assert.equal(execution.status, "completed"); + assert.equal(execution.outcome, "accepted"); + } + if (item.official && !intake.reused) { + const observed = await client.get("/intake", { rootId }); + assert.equal(observed.status, 200); + const assetIds = [ + ...new Set( + array(object(observed.body)["files"]).flatMap((file) => + array(object(file)["assetIds"]).map(string), + ), + ), + ]; + assert(assetIds[0]); + const enrichment = await client.post("/source-enrichments", { + subjectId: assetIds[0], + sourceIdentity: "nbinet", + sourceInput: { isbn: item.official.isbn }, + assetLibraryId: intake.assetLibraryId, + }); + operations["enrichment"] = enrichment; + assert.equal(enrichment.status, 201); + const execution = await client.awaitExecution( + string(object(enrichment.body)["id"]), + workflowTimeoutMs, + ); + operations["enrichmentExecution"] = execution; + assert.equal(execution.status, "completed"); + assert.equal(execution.outcome, "accepted"); + } + return { intake, operations }; +} diff --git a/scripts/development-environment.ts b/packages/workspace-automation/scripts/development-environment.ts similarity index 75% rename from scripts/development-environment.ts rename to packages/workspace-automation/scripts/development-environment.ts index 41d3e0c..1d2e18e 100644 --- a/scripts/development-environment.ts +++ b/packages/workspace-automation/scripts/development-environment.ts @@ -1,6 +1,6 @@ -import path from "node:path"; +import { developEnvironment } from "@digital/environment"; -import { developEnvironment } from "../packages/environment/src/index.ts"; +import { requireInput } from "./inputs.ts"; const environment = process.argv[2]; if (environment === undefined || process.argv.length > 3) { @@ -10,7 +10,7 @@ if (environment === undefined || process.argv.length > 3) { process.exitCode = 2; } else { const result = await developEnvironment({ - workspacePath: path.resolve(import.meta.dirname, ".."), + workspacePath: requireInput("DIGITAL_AUTOMATION_WORKSPACE"), environment, onOutput: (text) => process.stdout.write(text), }); diff --git a/scripts/development-task.ts b/packages/workspace-automation/scripts/development-task.ts similarity index 99% rename from scripts/development-task.ts rename to packages/workspace-automation/scripts/development-task.ts index 54f4aae..afaedc3 100644 --- a/scripts/development-task.ts +++ b/packages/workspace-automation/scripts/development-task.ts @@ -6,7 +6,7 @@ import { startEnvironment, stopEnvironment, updateDevelopmentEnvironment, -} from "../packages/environment/src/index.ts"; +} from "@digital/environment"; const workspacePath = process.env.DIGITAL_DEVELOPMENT_WORKSPACE; const environment = process.env.DIGITAL_DEVELOPMENT_ENVIRONMENT; diff --git a/packages/workspace-automation/scripts/inputs.ts b/packages/workspace-automation/scripts/inputs.ts new file mode 100644 index 0000000..6f06bda --- /dev/null +++ b/packages/workspace-automation/scripts/inputs.ts @@ -0,0 +1,9 @@ +import path from "node:path"; + +/** 路径由工作区入口显式提供,不从本包的物理位置寻找其他包或资源。 */ +export function requireInput(name: string): string { + const value = process.env[name]; + if (value === undefined || !path.isAbsolute(value)) + throw new Error(`工作区入口必须提供绝对路径 ${name}`); + return value; +} diff --git a/scripts/preview-environment.ts b/packages/workspace-automation/scripts/preview-environment.ts similarity index 90% rename from scripts/preview-environment.ts rename to packages/workspace-automation/scripts/preview-environment.ts index 6e77292..a72c7f4 100644 --- a/scripts/preview-environment.ts +++ b/packages/workspace-automation/scripts/preview-environment.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from "node:crypto"; /** * 开发预览仓库入口:显式组合公开环境操作与正式产品目录接入。 * @@ -6,13 +7,12 @@ * 之后通过正常产品 HTTP 入口发起目录接入,并按产品工作流结果判断接入,绝不用就绪 * 掩盖接入失败,也不按资产数量代产品宣布成功。 * - * 来源默认受控:受控清单由使用者显式选择;真实来源必须显式启用并说明成立条件。 + * 日常来源默认离线,资料目录必须显式选择;验收默认受控,真实来源须显式启用。 * 自建环境按调用方约定收尾(成功后停止并回收,失败后停止并保留可重启现场);由他人 * 提供的环境无论成功、失败还是中断都不被本入口收尾。 */ - -import { createHash } from "node:crypto"; import { + appendFile, cp, mkdir, readFile, @@ -34,8 +34,7 @@ import { type InspectEnvironmentResult, type PrepareOperationResult, type StartOperationResult, -} from "../packages/environment/src/index.ts"; -import { loadControlledSourceIo } from "../packages/test-support/src/source-io.ts"; +} from "@digital/environment"; import { createProductClient, digestDirectory, @@ -44,31 +43,17 @@ import { type JsonObject, type JsonValue, type ProductClient, -} from "./preview-product.ts"; +} from "@digital/test-support/product-client"; +import { loadControlledSourceIo } from "@digital/test-support/source-io"; -const repositoryRoot = path.resolve(import.meta.dirname, ".."); -/** - * 环境把工作区视为一份代码检出:构建产物与产品入口都相对它解析,容器落在 - * `<工作区>/.digital/environments/<环境>`。默认取本仓根,因此换工作区必须换一份检出。 - */ -const defaultWorkspace = repositoryRoot; -const defaultMaterial = path.join( - repositoryRoot, - "fixtures", - "controlled-source-io", - "material", - "RJ01668691", -); -const defaultManifest = path.join( - repositoryRoot, - "fixtures", - "controlled-source-io", - "manifest.json", -); +import { requireInput } from "./inputs.ts"; + +const defaultWorkspace = requireInput("DIGITAL_AUTOMATION_WORKSPACE"); +const sourceDirectory = requireInput("DIGITAL_AUTOMATION_SOURCE_DIRECTORY"); +const defaultMaterial = path.join(sourceDirectory, "material", "RJ01668691"); +const defaultManifest = path.join(sourceDirectory, "manifest.json"); const defaultCoverResponse = path.join( - repositoryRoot, - "fixtures", - "controlled-source-io", + sourceDirectory, "responses", "dlsite-cover.png", ); @@ -158,6 +143,8 @@ export type PreviewRequest = { readonly skipIntake: boolean; readonly cases: readonly string[]; readonly evidencePath?: string; + /** CI 成功运行先停服留存,上传核验后的删除仍由公共环境入口完成。 */ + readonly handoffDirectory?: string; /** 信号取消条件:由入口持有,贯穿环境操作与产品 HTTP 轮询。 */ readonly signal?: AbortSignal | undefined; }; @@ -172,7 +159,7 @@ export type RunningState = { failureFinished: boolean; }; -export const usage = `用法:node scripts/preview-environment.ts [命令] [选项] +export const usage = `用法:pnpm preview [命令] [选项] 命令(缺省为 up): up 无环境记录时准备并启动,已有就绪世代则原样复用;再通过产品入口完成目录接入 @@ -188,9 +175,9 @@ export const usage = `用法:node scripts/preview-environment.ts [命令] [选 选项: --workspace <目录> 代码检出根目录,默认本仓根;运行数据落在 <工作区>/.digital/environments/<环境> - --environment <名称> 环境名,日常入口默认 completion-01-preview,verify 默认 completion-01-verify - --material <目录> 普通资料目录,可重复;默认 06 代表目录 - --source-mode <模式> controlled(默认)| real | offline + --environment <名称> 环境名,日常入口默认 completion-01-preview,verify 每次分配独立名称 + --material <目录> 普通资料目录,可重复;日常必须选择,verify 默认受控代表目录 + --source-mode <模式> offline(日常默认)| real | controlled(verify 默认) --manifest <路径> 受控来源清单绝对路径,由使用者显式选择 --content-identity <身份> 期望的清单内容身份,缺省按清单重算 --allow-real-source 真实来源的显式开关,必须同时给出 --real-condition @@ -286,7 +273,9 @@ export function parseRequest(argv: readonly string[]): PreviewRequest { throw new PreviewUsageError(`选项 ${option} 需要非负整数:${raw}`); return parsed; }; - const sourceMode = values["--source-mode"] ?? "controlled"; + const sourceMode = + values["--source-mode"] ?? + (command === "verify" ? "controlled" : "offline"); if (!["controlled", "real", "offline"].includes(sourceMode)) throw new PreviewUsageError( `来源模式只能是 controlled、real 或 offline:${sourceMode}`, @@ -302,23 +291,38 @@ export function parseRequest(argv: readonly string[]): PreviewRequest { }; const requestedPort = readNumber("--port", values["--port"]); if (requestedPort !== 0) assertPreviewPort(requestedPort); + const environment = + values["--environment"] ?? + (command === "verify" + ? `${defaultVerifyEnvironment}-${randomUUID()}` + : defaultPreviewEnvironment); + const ciEvidence = process.env.DIGITAL_CI_EVIDENCE; + const handoffDirectory = + command === "verify" && ciEvidence !== undefined && ciEvidence.length > 0 + ? path.resolve(ciEvidence) + : undefined; + const evidencePath = + values["--evidence"] ?? + (handoffDirectory === undefined + ? undefined + : path.join(handoffDirectory, "preview", environment, "outcome.json")); return { command, - workspacePath: path.resolve(values["--workspace"] ?? defaultWorkspace), - environment: - values["--environment"] ?? - (command === "verify" - ? defaultVerifyEnvironment - : defaultPreviewEnvironment), + workspacePath: path.resolve(defaultWorkspace, values["--workspace"] ?? "."), + environment, materials: materials.length === 0 - ? [defaultMaterial] + ? command === "verify" + ? [defaultMaterial] + : [] : materials.map((item) => requireAbsolute("--material", item)), sourceMode: sourceMode as PreviewSourceMode, manifestPath: values["--manifest"] === undefined - ? defaultManifest + ? command === "verify" + ? defaultManifest + : "" : requireAbsolute("--manifest", values["--manifest"]), ...(values["--content-identity"] === undefined ? {} @@ -335,9 +339,10 @@ export function parseRequest(argv: readonly string[]): PreviewRequest { explicitMaterials, skipIntake: flags.has("--no-intake"), cases, - ...(values["--evidence"] === undefined + ...(evidencePath === undefined ? {} - : { evidencePath: path.resolve(values["--evidence"]) }), + : { evidencePath: path.resolve(defaultWorkspace, evidencePath) }), + ...(handoffDirectory === undefined ? {} : { handoffDirectory }), }; } @@ -376,7 +381,9 @@ export function assertSourceSelection(request: { ) throw new PreviewUsageError("受控来源清单必须是绝对路径"); if (request.materials.length === 0) - throw new PreviewUsageError("至少要有一个普通资料目录"); + throw new PreviewUsageError( + "请通过 --material 明确选择至少一个普通资料目录", + ); for (const material of request.materials) if (!path.isAbsolute(material)) throw new PreviewUsageError(`资料目录必须是绝对路径:${material}`); @@ -681,8 +688,14 @@ async function runIntake( const intake = await intakeDirectories(client, { displayName: options.libraryName ?? request.libraryName, directories, - recognitionProfileIds: ["dlsite-voice"], - sourceIdentities: ["dlsite"], + recognitionProfileIds: + request.command === "verify" + ? ["dlsite-voice"] + : ["dlsite-voice", "fanbox", "books", "anime", "movies"], + sourceIdentities: + request.command === "verify" + ? ["dlsite"] + : ["dlsite", "fanbox", "nbinet", "bangumi", "tmdb", "omdb"], workflowTimeoutMs: request.workflowTimeoutMs, }); reporter.fact("资产库标识", intake.assetLibraryId); @@ -1423,6 +1436,30 @@ export function requestValidationCases(): readonly { throw new Error(`预期拒绝理由 ${expected},实际 ${message}`); }; return [ + { + name: "日常目录与受控验收分开", + detail: "日常入口不暗中选择测试资料或合成来源,受控验收保持显式独立", + run: () => { + const preview = parseRequest([]); + if (preview.materials.length !== 0 || preview.sourceMode !== "offline") + throw new Error("日常预览不得默认导入测试目录或受控来源"); + const verify = parseRequest(["verify"]); + if ( + verify.materials.length !== 1 || + verify.materials[0] !== defaultMaterial || + verify.manifestPath !== defaultManifest || + verify.sourceMode !== "controlled" + ) + throw new Error("受控代表verify必须保持原代表目录及清单"); + const subset = parseRequest(["up", "--material", "/ordinary/selected"]); + if ( + subset.materials.length !== 1 || + subset.materials[0] !== "/ordinary/selected" || + subset.sourceMode !== "offline" + ) + throw new Error("显式普通目录专项不能被完整默认选择覆盖"); + }, + }, { name: "真实来源默认关闭", detail: "没有显式开关就拒绝,不触达任何来源", @@ -1574,11 +1611,20 @@ async function runVerify( ? exitCodes.okay : exitCodes.intakeFailed; - const staging = path.join( - repositoryRoot, - ".scratch", - "completion-01-staging", - ); + const staging = + request.handoffDirectory === undefined + ? path.join( + defaultWorkspace, + ".scratch", + "preview-verification", + randomUUID(), + ) + : path.join( + request.handoffDirectory, + "preview", + request.environment, + "inputs", + ); const subsetDirectory = path.join(staging, "预览子集"); const multiLeftDirectory = path.join(staging, "多目录甲"); const multiRightDirectory = path.join(staging, "多目录乙"); @@ -1608,13 +1654,21 @@ async function runVerify( `环境 ${request.environment} 在本次运行之前留下上次准备失败的记录(状态 failed):本入口按恢复处理——重新准备并拥有本次启动的服务,但那条记录与它的日志、数据载体仍归原使用者,本次成功也不删除它。`, ); } + if (request.handoffDirectory !== undefined && recycle === "delete") { + await mkdir(request.handoffDirectory, { recursive: true, mode: 0o700 }); + await appendFile( + path.join(request.handoffDirectory, "owners.ndjson"), + `${JSON.stringify({ workspacePath: request.workspacePath, environment: request.environment })}\n`, + { mode: 0o600 }, + ); + } + let result: number | undefined; try { - await rm(staging, { recursive: true, force: true }); await mkdir(path.dirname(subsetDirectory), { recursive: true }); await copySubsetMaterial(subsetDirectory); await copyMaterialForDirectory(multiLeftDirectory); await copyMaterialForDirectory(multiRightDirectory); - return await runLifecycleScenarios( + result = await runLifecycleScenarios( request, reporter, running, @@ -1622,8 +1676,17 @@ async function runVerify( lifecycleCases, { subsetDirectory, multiLeftDirectory, multiRightDirectory }, ); + return result; } finally { - await rm(staging, { recursive: true, force: true }); + if ( + result === exitCodes.okay && + reporter.failures.length === 0 && + recycle === "delete" && + request.handoffDirectory === undefined && + request.signal?.aborted !== true + ) + await rm(staging, { recursive: true, force: true }); + else reporter.fact("保留验收输入(与环境现场一同供复现)", staging); } } @@ -1771,7 +1834,15 @@ async function runLifecycleScenarios( } if (recycle === "delete") { reporter.section("按归属收尾(自建环境)"); - await recycleOwned(request, reporter, running); + if (request.handoffDirectory === undefined) + await recycleOwned(request, reporter, running); + else { + await ownedStop(request, reporter); + running.startedHere = false; + reporter.say( + "自建环境已停止并保留数据、日志与输入;CI 核实上传后再经公共删除入口回收。", + ); + } } else if (recycle === "stop") { reporter.section("按归属收尾(恢复上次失败记录)"); await ownedStop(request, reporter); @@ -1807,24 +1878,32 @@ async function copyMaterialForDirectory(targetRoot: string): Promise { ); } -function reportPreviewHandoff( +async function reportPreviewHandoff( request: PreviewRequest, client: ProductClient, intake: IntakeOutcome, reporter: PreviewReporter, -): void { +): Promise { reporter.section("开发预览可用"); reporter.fact("浏览地址", client.address); + const libraries = (await client.libraries()).filter((library) => + library.observedRootIds.some((id) => intake.observedRootIds.includes(id)), + ); reporter.fact( "资产库", - `${intake.assetLibraryId}(接入资产 ${String(intake.assetIds.length)} 个)`, + libraries.map((library) => ({ + id: library.id, + name: library.displayName, + directories: [...library.directories], + })), ); + reporter.fact("合计接入资产", intake.assetIds.length); reporter.say( - "停止服务:node scripts/preview-environment.ts stop --workspace " + + "停止服务:pnpm preview stop --workspace " + `${request.workspacePath} --environment ${request.environment}`, ); reporter.say( - "再次启动(保留修改,无需重新接入):node scripts/preview-environment.ts start --workspace " + + "再次启动(保留修改,无需重新接入):pnpm preview start --workspace " + `${request.workspacePath} --environment ${request.environment}`, ); } @@ -1902,25 +1981,22 @@ async function runSingleCommand( : inspect.environment.inputs.length === 0 ? undefined : inspect.environment.inputs; - const intake = await runIntake( + await runIntake( request, client, reporter, directories === undefined ? {} : { directories }, ); - if (intake.assetIds.length === 0) { - reporter.fail( - `产品对请求目录没有产生任何可浏览资产(资产库 ${intake.assetLibraryId},工作流 ${intake.executionId}=${intake.executionStatus}/${intake.executionOutcome ?? "无"}),接入未成功。`, - ); - return exitCodes.intakeFailed; - } return exitCodes.okay; } case "up": { let client: ProductClient | undefined; + let directories = request.materials; if (request.provided) { const state = await readEnvironmentState(request, reporter); client = state.client; + if (!request.explicitMaterials) + directories = state.inspect.environment.inputs; if (client === undefined) { reporter.fail("提供的环境没有就绪结论,接入阶段未执行。"); return exitCodes.operationFailed; @@ -1930,6 +2006,11 @@ async function runSingleCommand( workspacePath: request.workspacePath, environment: request.environment, }); + if ( + !request.explicitMaterials && + precondition.environment.inputs.length > 0 + ) + directories = precondition.environment.inputs; const upOwnership = planOwnership( classifyEnvironmentPrecondition(precondition.state, request.provided), ); @@ -1993,13 +2074,20 @@ async function runSingleCommand( client = createProductClient(address, { signal: request.signal }); } if (request.skipIntake) return exitCodes.okay; + if (!request.explicitMaterials) { + const libraries = await client.libraries(); + if (libraries.length > 0) + directories = [ + ...new Set( + libraries.flatMap((library) => [...library.directories]), + ), + ]; + } try { - const intake = await runIntake(request, client, reporter); - if (intake.assetIds.length === 0) - throw new Error( - `产品对接入请求没有产生任何可浏览资产(资产库 ${intake.assetLibraryId},工作流 ${intake.executionId}=${intake.executionStatus}/${intake.executionOutcome ?? "无"})`, - ); - reportPreviewHandoff(request, client, intake, reporter); + const intake = await runIntake(request, client, reporter, { + directories, + }); + await reportPreviewHandoff(request, client, intake, reporter); } catch (error) { // 主因先落账,再对本次真正启动的自有服务收尾;复用他人服务不动。 reporter.fail(`接入阶段失败:${describeError(error)}`); @@ -2095,8 +2183,12 @@ export async function main( ? exitCodes.invalidUsage : exitCodes.operationFailed; } - // 取消之后业务立即停止,但仍以可用的清理上下文对自有服务收尾并保存证据。 - if (cancellation.signal.aborted) { + if (mainError !== undefined) reporter.fail(describeError(mainError)); + let cancellationFinished = false; + // 保存也可能被首次取消打断;已观察取消时独立收尾,保留本次启动的数据与日志。 + const finishCancellation = async (): Promise => { + if (!cancellation.signal.aborted || cancellationFinished) return false; + cancellationFinished = true; const needsEntryCleanup = !running.failureFinished && ["verify", "up", "prepare", "start"].includes(request.command); @@ -2111,35 +2203,40 @@ export async function main( for (const error of cleanupErrors) reporter.say(`收尾错误(不覆盖主错误):${error}`); } - if (mainError === undefined) + if (mainError === undefined) { mainError = new Error("收到中断信号,业务已取消"); + reporter.fail(describeError(mainError)); + } exit = interruptedExitCode(cancellation.signal.reason as NodeJS.Signals); - } - if (mainError !== undefined) reporter.fail(describeError(mainError)); + return true; + }; + await finishCancellation(); if (exit === exitCodes.okay && reporter.failures.length > 0) { exit = exitCodes.intakeFailed; } - try { - await writeEvidence(requestWithSignal, reporter, exit, mainError); - } catch (error) { - reporter.fail( - `结果保存失败(保存错误,不覆盖主错误):${describeError(error)}`, - ); - if (exit === exitCodes.okay) exit = exitCodes.operationFailed; - if (!running.failureFinished && running.startedHere) { - const cleanupErrors = await finishOwnedFailure( - requestWithSignal, - reporter, - running.ownsService, - running.startedHere, + const saveResult = async (): Promise => { + try { + await writeEvidence(requestWithSignal, reporter, exit, mainError); + } catch (error) { + reporter.fail( + `结果保存失败(保存错误,不覆盖主错误):${describeError(error)}`, ); - for (const cleanupError of cleanupErrors) - reporter.say(`收尾错误(不覆盖主错误):${cleanupError}`); - running.failureFinished = true; + if (exit === exitCodes.okay) exit = exitCodes.operationFailed; + if (!running.failureFinished && running.startedHere) { + const cleanupErrors = await finishOwnedFailure( + requestWithSignal, + reporter, + running.ownsService, + running.startedHere, + ); + for (const cleanupError of cleanupErrors) + reporter.say(`收尾错误(不覆盖主错误):${cleanupError}`); + running.failureFinished = true; + } } - } - if (cancellation.signal.aborted) - exit = interruptedExitCode(cancellation.signal.reason as NodeJS.Signals); + }; + await saveResult(); + if (await finishCancellation()) await saveResult(); reporter.print(); return exit; } diff --git a/scripts/verify-audio-post-corpus.ts b/packages/workspace-automation/scripts/verify-audio-post-corpus.ts similarity index 88% rename from scripts/verify-audio-post-corpus.ts rename to packages/workspace-automation/scripts/verify-audio-post-corpus.ts index f683523..902ea8f 100644 --- a/scripts/verify-audio-post-corpus.ts +++ b/packages/workspace-automation/scripts/verify-audio-post-corpus.ts @@ -8,15 +8,20 @@ import { prepareEnvironment, startEnvironment, stopEnvironment, -} from "../packages/environment/src/index.ts"; -import { loadControlledSourceIo } from "../packages/test-support/src/source-io.ts"; +} from "@digital/environment"; import { createProductClient, digestDirectory, - intakeDirectories, type JsonObject, type JsonValue, -} from "./preview-product.ts"; +} from "@digital/test-support/product-client"; +import { loadControlledSourceIo } from "@digital/test-support/source-io"; + +import { + configureAudioPostSources, + intakeAudioPostCase, +} from "./corpus-verification.ts"; +import { requireInput } from "./inputs.ts"; /** 资料调用方只提交普通目录和来源配置,通过正式 HTTP 核对独立预期。 */ type CorpusCase = { @@ -38,17 +43,21 @@ type CorpusCase = { }[]; }; -const workspacePath = path.resolve(import.meta.dirname, ".."); -const fixture = path.join(workspacePath, "fixtures/preview-corpus/audio-posts"); +const workspacePath = requireInput("DIGITAL_AUTOMATION_WORKSPACE"); +const fixture = requireInput("DIGITAL_AUTOMATION_SOURCE_DIRECTORY"); const environment = process.argv[2]; -const evidencePath = process.argv[3]; +const evidenceArgument = process.argv[3]; +const evidencePath = + evidenceArgument === undefined + ? undefined + : path.resolve(workspacePath, evidenceArgument); const port = Number(process.argv[4]); const controller = new AbortController(); for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort(new Error(`收到 ${signal}`))); assert( environment && evidencePath && Number.isInteger(port), - "用法:node scripts/verify-audio-post-corpus.ts <独立环境名> <证据文件> <空闲端口> [资料ID…]", + "用法:pnpm verify:audio-posts <独立环境名> <证据文件> <空闲端口> [资料ID…]", ); const all = JSON.parse( await readFile(path.join(fixture, "cases.json"), "utf8"), @@ -132,36 +141,11 @@ try { signal: controller.signal, }); assert.equal((await client.readiness()).ready, true); - if (cases.some((item) => item.kind === "post")) { - const browserCredential = JSON.parse( - await readFile( - path.join(fixture, "connections/synthetic-customudon.json"), - "utf8", - ), - ) as JsonObject; - const connection = await client.post("/settings/connections", { - source: "fanbox", - action: "configure", - creatorId: "customudon", - browserCredential, - }); - assert.equal(connection.status, 200); + await configureAudioPostSources(client, fixture, cases); + if (cases.some((item) => item.kind === "post")) evidence["sessionConfigured"] = true; - } for (const item of cases) { - const profiles = - item.kind === "audio" - ? ["dlsite-voice"] - : item.kind === "post" - ? ["fanbox"] - : ["dlsite-voice", "fanbox", "books", "anime", "movies"]; - const intake = await intakeDirectories(client, { - displayName: `轻量资料 ${item.id}`, - directories: [path.join(fixture, item.directory)], - recognitionProfileIds: profiles, - sourceIdentities: ["dlsite", "fanbox"], - workflowTimeoutMs: 300_000, - }); + const intake = await intakeAudioPostCase(client, fixture, item); const rootId = intake.observedRootIds[0]; assert(rootId); const observed = await client.get("/intake", { rootId }); diff --git a/scripts/verify-publication-screen-corpus.ts b/packages/workspace-automation/scripts/verify-publication-screen-corpus.ts similarity index 81% rename from scripts/verify-publication-screen-corpus.ts rename to packages/workspace-automation/scripts/verify-publication-screen-corpus.ts index 2bcd7fd..5f7987d 100644 --- a/scripts/verify-publication-screen-corpus.ts +++ b/packages/workspace-automation/scripts/verify-publication-screen-corpus.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { @@ -10,16 +11,21 @@ import { prepareEnvironment, startEnvironment, stopEnvironment, -} from "../packages/environment/src/index.ts"; -import { loadControlledSourceIo } from "../packages/test-support/src/source-io.ts"; +} from "@digital/environment"; import { createProductClient, digestDirectory, - intakeDirectories, type JsonObject, type JsonValue, type ProductClient, -} from "./preview-product.ts"; +} from "@digital/test-support/product-client"; +import { loadControlledSourceIo } from "@digital/test-support/source-io"; + +import { + configurePublicationScreenSources, + intakePublicationScreenCase, +} from "./corpus-verification.ts"; +import { requireInput } from "./inputs.ts"; type CorpusCase = { readonly id: string; @@ -57,17 +63,18 @@ type CorpusCase = { readonly tmdbOverview?: string; readonly omdbAwards?: string; }; -const workspacePath = path.resolve(import.meta.dirname, ".."); -const fixture = path.join( - workspacePath, - "fixtures/preview-corpus/publication-screen", -); +const workspacePath = requireInput("DIGITAL_AUTOMATION_WORKSPACE"); +const fixture = requireInput("DIGITAL_AUTOMATION_SOURCE_DIRECTORY"); const environment = process.argv[2]; -const evidencePath = process.argv[3]; +const evidenceArgument = process.argv[3]; +const evidencePath = + evidenceArgument === undefined + ? undefined + : path.resolve(workspacePath, evidenceArgument); const port = Number(process.argv[4]); assert( environment && evidencePath && Number.isInteger(port), - "用法:node scripts/verify-publication-screen-corpus.ts <独立环境名> <证据文件> <空闲端口> [资料ID…]", + "用法:pnpm verify:publication-screen <独立环境名> <证据文件> <空闲端口> [资料ID…]", ); const selected = process.argv.slice(5); const all = JSON.parse( @@ -116,51 +123,6 @@ const save = async (): Promise => { let owned = false; let passed = false; -async function configureSources(client: ProductClient): Promise { - const configured: JsonValue[] = []; - if (cases.some((c) => c.kind === "film")) { - for (const source of ["tmdb", "omdb"]) { - const result = await client.post("/settings/connections", { - source, - action: "configure", - secret: - source === "tmdb" ? "synthetic-tmdb-token" : "synthetic-omdb-key", - }); - assert.equal(result.status, 200); - configured.push({ source, inspection: result.body }); - } - } - if (cases.some((c) => c.kind === "anime")) { - assert.equal( - ( - await client.post("/settings/connections", { - source: "bangumi", - action: "configure-client", - clientId: "synthetic-client", - clientSecret: "synthetic-client-secret", - redirectUri: `http://127.0.0.1:${String(port)}/api/source-connections/bangumi/callback`, - }) - ).status, - 200, - ); - const authorization = await client.post( - "/source-connections/bangumi/authorize", - {}, - ); - assert.equal(authorization.status, 201); - const state = new URL( - string(object(authorization.body)["authorizationUrl"]), - ).searchParams.get("state"); - assert(state); - const callback = await client.get("/source-connections/bangumi/callback", { - code: "synthetic-code", - state, - }); - assert.equal(callback.status, 200); - configured.push({ source: "bangumi", inspection: callback.body }); - } - evidence["connections"] = configured; -} function acceptedItems(inspection: JsonObject): readonly JsonObject[] { return array(inspection["itemResults"]) .map(object) @@ -249,44 +211,28 @@ try { signal: controller.signal, }); assert.equal((await client.readiness()).ready, true); - await configureSources(client); + evidence["connections"] = await configurePublicationScreenSources( + client, + cases, + port, + ); for (const c of cases) { - const intake = await intakeDirectories(client, { - displayName: `出版物与影视资料 ${c.id}`, - directories: [path.join(fixture, c.directory)], - recognitionProfileIds: [ - c.kind === "anime" ? "anime" : c.kind === "film" ? "movies" : "books", - ], - sourceIdentities: - c.kind === "anime" - ? ["bangumi"] - : c.kind === "film" - ? ["tmdb", "omdb"] - : ["nbinet"], - workflowTimeoutMs: 300_000, - }); - const result: Record = { id: c.id, kind: c.kind, intake }; + const { intake, operations } = await intakePublicationScreenCase( + client, + fixture, + c, + ); + const result: Record = { + id: c.id, + kind: c.kind, + intake, + ...operations, + }; results.push(result); const rootId = intake.observedRootIds[0]; assert(rootId); - if (c.directed) { + if (c.directed) assert.equal(intake.assetIds.length, 0, "非规范收藏包不应自动制造资产"); - const directed = await client.post("/refresh", { - observedRootId: rootId, - relativeScope: ".", - directedSourceIdentity: "tmdb", - directedSourceInput: { movie: String(c.tmdb) }, - }); - result["directed"] = directed; - assert.equal(directed.status, 201); - const execution = await client.awaitExecution( - string(object(directed.body)["id"]), - 300_000, - ); - result["directedExecution"] = execution; - assert.equal(execution.status, "completed"); - assert.equal(execution.outcome, "accepted"); - } const observed = await client.get("/intake", { rootId }); assert.equal(observed.status, 200); const files = array(object(observed.body)["files"]).map(object); @@ -305,23 +251,6 @@ try { `${c.id} 的正式材料归属数量错误`, ); result["assetIds"] = assetIds; - if (c.official) { - const enrichment = await client.post("/source-enrichments", { - subjectId: assetIds[0]!, - sourceIdentity: "nbinet", - sourceInput: { isbn: c.official.isbn }, - assetLibraryId: intake.assetLibraryId, - }); - result["enrichment"] = enrichment; - assert.equal(enrichment.status, 201); - const execution = await client.awaitExecution( - string(object(enrichment.body)["id"]), - 300_000, - ); - result["enrichmentExecution"] = execution; - assert.equal(execution.status, "completed"); - assert.equal(execution.outcome, "accepted"); - } await save(); } // Web 与 CLI 顺序持有同一产品数据,沿正式扫描入口请求封面并读回格式和来源事实。 @@ -340,7 +269,7 @@ try { assetLibraryId: string; }; const args = [ - "apps/cli/dist/cli.js", + fileURLToPath(import.meta.resolve("@digital/cli/cli")), "scan", intake.observedRootIds[0]!, "--scope", diff --git a/packages/workspace-automation/tsconfig.json b/packages/workspace-automation/tsconfig.json new file mode 100644 index 0000000..ad5be0b --- /dev/null +++ b/packages/workspace-automation/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@digital/typescript-config/base.json", + "compilerOptions": { + "customConditions": ["digital-source"], + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "rewriteRelativeImportExtensions": true, + "target": "es2023", + "types": ["node"] + }, + "include": ["scripts/**/*.ts"] +} diff --git a/packages/workspace-automation/turbo.json b/packages/workspace-automation/turbo.json new file mode 100644 index 0000000..89aaa26 --- /dev/null +++ b/packages/workspace-automation/turbo.json @@ -0,0 +1,27 @@ +{ + "extends": ["//"], + "tags": ["composition-root"], + "tasks": { + "format:check": { + "tags": ["quality:light"], + "outputs": [] + }, + "format:write": { + "tags": ["work:repair"], + "cache": false, + "dependsOn": ["lint:fix"] + }, + "lint": { + "tags": ["quality:light"], + "outputs": [] + }, + "lint:fix": { + "tags": ["work:repair"], + "cache": false + }, + "typecheck": { + "tags": ["quality:light"], + "outputs": [] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b09f8af..2573546 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ catalogs: version: 4.3.3 '@types/node': specifier: ^24.13.3 - version: 24.13.3 + version: 24.19.1 '@valibot/to-json-schema': specifier: 1.7.1 version: 1.7.1 @@ -162,8 +162,8 @@ catalogs: specifier: ^4.3.2 version: 4.3.3 turbo: - specifier: 2.10.5 - version: 2.10.5 + specifier: 2.11.7 + version: 2.11.7 typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: 6.0.2 @@ -202,7 +202,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -214,7 +214,7 @@ importers: version: 7.0.2001 turbo: specifier: 'catalog:' - version: 2.10.5 + version: 2.11.7 typescript-7: specifier: 'catalog:' version: typescript@7.0.2 @@ -248,7 +248,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -263,7 +263,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) apps/web: dependencies: @@ -287,7 +287,7 @@ importers: version: 8.21.3(vue@3.5.40(@typescript/typescript6@6.0.2)) '@vikejs/hono': specifier: 'catalog:' - version: 0.2.1(hono@4.12.32)(srvx@0.12.4)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))) + version: 0.2.1(hono@4.12.32)(srvx@0.12.4)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))) '@vueuse/core': specifier: ^14.3.0 version: 14.3.0(vue@3.5.40(@typescript/typescript6@6.0.2)) @@ -311,10 +311,10 @@ importers: version: 3.6.0 vike: specifier: 'catalog:' - version: 0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) vike-vue: specifier: 'catalog:' - version: 0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)))(vue@3.5.40(@typescript/typescript6@6.0.2)) + version: 0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)))(vue@3.5.40(@typescript/typescript6@6.0.2)) vue: specifier: 'catalog:' version: 3.5.40(@typescript/typescript6@6.0.2) @@ -336,13 +336,13 @@ importers: version: 1.62.0 '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.3.3(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 '@vitejs/plugin-vue': specifier: 'catalog:' - version: 6.0.8(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.40(@typescript/typescript6@6.0.2)) + version: 6.0.8(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.40(@typescript/typescript6@6.0.2)) '@vue/tsconfig': specifier: 'catalog:' version: 0.9.1(@typescript/typescript6@6.0.2)(vue@3.5.40(@typescript/typescript6@6.0.2)) @@ -360,7 +360,7 @@ importers: version: 4.3.3 turbo: specifier: 'catalog:' - version: 2.10.5 + version: 2.11.7 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -369,10 +369,10 @@ importers: version: '@typescript/typescript6@6.0.2' vite: specifier: 'catalog:' - version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + version: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) vue-tsc: specifier: 'catalog:' version: 3.3.8(@typescript/typescript6@6.0.2) @@ -391,7 +391,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -406,7 +406,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/cli-delivery: dependencies: @@ -425,7 +425,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -440,7 +440,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/composition: dependencies: @@ -504,7 +504,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -519,7 +519,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/db-migrations: dependencies: @@ -538,7 +538,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -553,7 +553,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/domain: dependencies: @@ -566,7 +566,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -581,7 +581,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/environment: dependencies: @@ -600,7 +600,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -615,7 +615,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/filesystem: dependencies: @@ -631,7 +631,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -646,7 +646,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/playback-format: dependencies: @@ -659,7 +659,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -674,7 +674,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/presentation: dependencies: @@ -690,7 +690,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -705,7 +705,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/product-application: dependencies: @@ -721,7 +721,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -736,7 +736,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/product-data: dependencies: @@ -749,7 +749,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -764,7 +764,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/sqlite: dependencies: @@ -786,7 +786,7 @@ importers: version: 0.17.4 '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 drizzle-kit: specifier: 'catalog:' version: 1.0.0-rc.4 @@ -810,7 +810,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -825,7 +825,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages/typescript-config: devDependencies: @@ -853,7 +853,7 @@ importers: version: link:../typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -868,7 +868,38 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) + + packages/workspace-automation: + dependencies: + '@digital/cli': + specifier: workspace:* + version: link:../../apps/cli + '@digital/environment': + specifier: workspace:* + version: link:../environment + '@digital/test-support': + specifier: workspace:* + version: link:../test-support + devDependencies: + '@digital/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@types/node': + specifier: 'catalog:' + version: 24.19.1 + oxfmt: + specifier: 'catalog:' + version: 0.58.0 + oxlint: + specifier: 'catalog:' + version: 1.75.0(oxlint-tsgolint@7.0.2001) + oxlint-tsgolint: + specifier: 'catalog:' + version: 7.0.2001 + typescript-7: + specifier: 'catalog:' + version: typescript@7.0.2 sources/bangumi: dependencies: @@ -884,7 +915,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -899,7 +930,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/dlsite: dependencies: @@ -915,7 +946,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -930,7 +961,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/epub: dependencies: @@ -946,7 +977,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -961,7 +992,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/fanbox: dependencies: @@ -980,7 +1011,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -995,7 +1026,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/kadokawa-jp: dependencies: @@ -1014,7 +1045,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -1029,7 +1060,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/nbinet: dependencies: @@ -1045,7 +1076,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -1060,7 +1091,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/omdb: dependencies: @@ -1076,7 +1107,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -1091,7 +1122,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/pdf: dependencies: @@ -1116,7 +1147,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -1131,7 +1162,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/taiwan-kadokawa: dependencies: @@ -1150,7 +1181,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -1165,7 +1196,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) sources/tmdb: dependencies: @@ -1181,7 +1212,7 @@ importers: version: link:../../packages/typescript-config '@types/node': specifier: 'catalog:' - version: 24.13.3 + version: 24.19.1 oxfmt: specifier: 'catalog:' version: 0.58.0 @@ -1196,7 +1227,7 @@ importers: version: typescript@7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) packages: @@ -2386,33 +2417,33 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 - '@turbo/darwin-64@2.10.5': - resolution: {integrity: sha512-ENvPwy3x5yS7MwNYHeWjqOBXkwIMp39Pd+/zXC6PoiNzF8EIvvLZOZZ+ny6L9x4WgS5vxUii2LM5gM+zjPdnWw==} + '@turbo/darwin-64@2.11.7': + resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.10.5': - resolution: {integrity: sha512-rqROo9zsF/P9RqsdtbLD1nFJicjSrYyvQ9kNJC38AbxA3pAs6VAlATvtvOFx7bqOv6vicf20SP9kF33avJjy2w==} + '@turbo/darwin-arm64@2.11.7': + resolution: {integrity: sha512-Vhv8ybgIflosNDSSRD9fNXHlCFA60dsGyTqQmc7l28SEX5jOaweCCD4BBYNd8/p0WGkw3e7tZbfA9w6FsujGvw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.10.5': - resolution: {integrity: sha512-RoSSiNFUxi27zLJuM9F6GyWWjHgLch9t6nwD6K0FkXRirZkTLlzIj6IhFnK8H9++nefLtdFqylE4vGjZAv6AAA==} + '@turbo/linux-64@2.11.7': + resolution: {integrity: sha512-rAqRp3blQKk1+ylMGlZHAW0eJjVNuuGUm4ZrC6RU1Im8tLryvaO4a2zLQ/FQi1TRlmKqKV9YXBki2YeytlLbXA==} cpu: [x64] - os: [linux] + os: [android, linux] - '@turbo/linux-arm64@2.10.5': - resolution: {integrity: sha512-4ZComcpzmHGmVynQqvvi+iZOSq/tBvY1SltXB8g4NZRsrA01W8E+yRL8RNM+PLoyWsrCnJa8xa+DkWkv+xg4iQ==} + '@turbo/linux-arm64@2.11.7': + resolution: {integrity: sha512-thVYGov7S+llXAxrl3kBb/efalav0xhrjl5NHCR6AfKx14JkbqaetvbVwyss4FvZ9rt+Pv3xsTb+8SIYix8DTg==} cpu: [arm64] - os: [linux] + os: [android, linux] - '@turbo/windows-64@2.10.5': - resolution: {integrity: sha512-eL2Iyj4DbMINq1Sr1w0iAi6nAiZOF16KSlRGwCJpVh+IWZeY33MAsLHVOBMj1xoFtncVJXclCVpTPL2nBoYkFg==} + '@turbo/windows-64@2.11.7': + resolution: {integrity: sha512-ZIoKT7J7KutgYl4bGxmmgeUQjG+7E0nY8BF9dxUOiMH2vdTsM4JQ2uSwTYFkTR2Llbg6ghGjSmuZ8LQtkLEYjg==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.10.5': - resolution: {integrity: sha512-sog+wP+8YSJrdWZ/rUJg8xghVTrwoG+BrSlDQpnK5fzSgJHn1INRWXbVWRH0d3vX8dBI01E3yxXRre9Dn+OXQA==} + '@turbo/windows-arm64@2.11.7': + resolution: {integrity: sha512-88Xv2f7SLGVJ68lRRx8o375dKmnZYNdDtvlKcPA0kZpcF93eAQd8l96BKQ0/AMHG0bMJA0Z5QqoqtetxzNsC+g==} cpu: [arm64] os: [win32] @@ -2428,9 +2459,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - '@types/node@24.19.1': resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} @@ -3478,8 +3506,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo@2.10.5: - resolution: {integrity: sha512-07Y/C7OUp23l4P92PJoYtFNbHjLhftrZH5Ce7dbczS4kX2Re+wtbXvZLoxn/pUtzgsQaRCBaRuZPJp4zmAn0WQ==} + turbo@2.11.7: + resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==} hasBin: true tw-animate-css@1.4.0: @@ -3501,9 +3529,6 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -3608,7 +3633,6 @@ packages: '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -4464,12 +4488,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) '@tanstack/table-core@8.21.3': {} @@ -4485,22 +4509,22 @@ snapshots: '@tanstack/virtual-core': 3.17.6 vue: 3.5.40(@typescript/typescript6@6.0.2) - '@turbo/darwin-64@2.10.5': + '@turbo/darwin-64@2.11.7': optional: true - '@turbo/darwin-arm64@2.10.5': + '@turbo/darwin-arm64@2.11.7': optional: true - '@turbo/linux-64@2.10.5': + '@turbo/linux-64@2.11.7': optional: true - '@turbo/linux-arm64@2.10.5': + '@turbo/linux-arm64@2.11.7': optional: true - '@turbo/windows-64@2.10.5': + '@turbo/windows-64@2.11.7': optional: true - '@turbo/windows-arm64@2.10.5': + '@turbo/windows-arm64@2.11.7': optional: true '@tybys/wasm-util@0.10.3': @@ -4517,10 +4541,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@24.13.3': - dependencies: - undici-types: 7.18.2 - '@types/node@24.19.1': dependencies: undici-types: 7.24.6 @@ -4529,7 +4549,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.3 + '@types/node': 24.19.1 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -4595,20 +4615,20 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 - '@universal-deploy/netlify@0.2.2(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + '@universal-deploy/netlify@0.2.2(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@universal-deploy/store': 0.2.1(srvx@0.12.4) - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) transitivePeerDependencies: - srvx - '@universal-deploy/node@0.1.9(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + '@universal-deploy/node@0.1.9(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@universal-deploy/store': 0.2.1(srvx@0.11.22) magic-string: 0.30.21 srvx: 0.11.22 optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) '@universal-deploy/store@0.2.1(srvx@0.11.22)': dependencies: @@ -4622,16 +4642,16 @@ snapshots: optionalDependencies: srvx: 0.12.4 - '@universal-deploy/vite@0.1.10(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + '@universal-deploy/vite@0.1.10(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: - '@universal-deploy/netlify': 0.2.2(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) - '@universal-deploy/node': 0.1.9(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@universal-deploy/netlify': 0.2.2(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) + '@universal-deploy/node': 0.1.9(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) '@universal-deploy/store': 0.2.1(srvx@0.12.4) '@universal-middleware/express': 0.4.31(hono@4.12.32)(srvx@0.12.4) magic-string: 0.30.21 rou3: 0.8.1 optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) transitivePeerDependencies: - '@cloudflare/workers-types' - '@hattip/core' @@ -4698,11 +4718,11 @@ snapshots: dependencies: valibot: 1.4.2(typescript@7.0.2) - '@vikejs/hono@0.2.1(hono@4.12.32)(srvx@0.12.4)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)))': + '@vikejs/hono@0.2.1(hono@4.12.32)(srvx@0.12.4)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)))': dependencies: '@universal-middleware/hono': 0.4.21(hono@4.12.32)(srvx@0.12.4) hono: 4.12.32 - vike: 0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + vike: 0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@hattip/core' @@ -4713,10 +4733,10 @@ snapshots: - h3 - srvx - '@vitejs/plugin-vue@6.0.8(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.40(@typescript/typescript6@6.0.2))': + '@vitejs/plugin-vue@6.0.8(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))(vue@3.5.40(@typescript/typescript6@6.0.2))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) vue: 3.5.40(@typescript/typescript6@6.0.2) '@vitest/expect@4.1.10': @@ -4728,13 +4748,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -5426,14 +5446,14 @@ snapshots: tslib@2.8.1: {} - turbo@2.10.5: + turbo@2.11.7: optionalDependencies: - '@turbo/darwin-64': 2.10.5 - '@turbo/darwin-arm64': 2.10.5 - '@turbo/linux-64': 2.10.5 - '@turbo/linux-arm64': 2.10.5 - '@turbo/windows-64': 2.10.5 - '@turbo/windows-arm64': 2.10.5 + '@turbo/darwin-64': 2.11.7 + '@turbo/darwin-arm64': 2.11.7 + '@turbo/linux-64': 2.11.7 + '@turbo/linux-arm64': 2.11.7 + '@turbo/windows-64': 2.11.7 + '@turbo/windows-arm64': 2.11.7 tw-animate-css@1.4.0: {} @@ -5466,8 +5486,6 @@ snapshots: ufo@1.6.4: {} - undici-types@7.18.2: {} - undici-types@7.24.6: {} unplugin@2.3.11: @@ -5487,18 +5505,18 @@ snapshots: optionalDependencies: typescript: 7.0.2 - vike-vue@0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)))(vue@3.5.40(@typescript/typescript6@6.0.2)): + vike-vue@0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)))(vue@3.5.40(@typescript/typescript6@6.0.2)): dependencies: magic-string: 0.30.21 oxc-parser: 0.104.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) oxc-walker: 0.6.0(oxc-parser@0.104.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)) - vike: 0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + vike: 0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) vue: 3.5.40(@typescript/typescript6@6.0.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)): + vike@0.4.260(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 '@babel/types': 7.29.7 @@ -5507,7 +5525,7 @@ snapshots: '@brillout/picocolors': 1.0.31 '@brillout/vite-plugin-server-entry': 0.7.19 '@universal-deploy/store': 0.2.1(srvx@0.12.4) - '@universal-deploy/vite': 0.1.10(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@universal-deploy/vite': 0.1.10(hono@4.12.32)(srvx@0.12.4)(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) '@universal-middleware/core': 0.4.18(hono@4.12.32)(srvx@0.12.4) '@universal-middleware/node': 0.2.2(hono@4.12.32)(srvx@0.12.4) cac: 6.7.14 @@ -5521,9 +5539,9 @@ snapshots: sirv: 3.0.2 source-map-support: 0.5.21 tinyglobby: 0.2.17 - vite-plugin-wrapper: 0.1.1(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + vite-plugin-wrapper: 0.1.1(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) transitivePeerDependencies: - '@cloudflare/workers-types' - '@hattip/core' @@ -5536,11 +5554,11 @@ snapshots: - srvx - supports-color - vite-plugin-wrapper@0.1.1(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)): + vite-plugin-wrapper@0.1.1(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) - vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0): + vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -5548,15 +5566,15 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.13.3 + '@types/node': 24.19.1 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)): + vitest@4.1.10(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -5573,12 +5591,23 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.5(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.13.3 + '@types/node': 24.19.1 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml vscode-uri@3.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 14c3034..5d94be8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,7 +42,7 @@ catalog: "oxlint-tsgolint": ^7.0.2001 "srvx": ^0.12.1 "tailwindcss": ^4.3.2 - "turbo": 2.10.5 + "turbo": 2.11.7 "typescript": npm:@typescript/typescript6@^6.0.2 "typescript-7": npm:typescript@^7.0.2 "valibot": ^1.4.2 diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 5b5042d..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# 普通目录预览与开发更新 - -`scripts/preview-environment.ts` 是仓库唯一的普通目录准备与接入入口:一条命令把公开的环境生命周期 -(`@digital/environment` 的 `prepare / start / inspect / stop / rebuild / delete`)与正式的 -产品目录接入串起来,得到一个有资料、可浏览的预览。它不复制环境内部命令,也不自己拼进程、 -数据库或来源装配。 - -```bash -pnpm preview # 等价于 node scripts/preview-environment.ts(缺省命令 up):准备 + 启动 + 目录接入,服务保持运行并打印浏览地址 -pnpm preview:verify # 用一次性自建环境跑完预览场景断言并按归属收尾 -pnpm preview help # 用法说明(--help 等同) -node scripts/preview-environment.ts --help -``` - -`scripts/preview-product.ts` 是本入口使用的产品客户端:只走真实 HTTP 入口 -(`/api/ready`、`/api/settings`、`/api/settings/libraries`、`/api/refresh`、`/api/executions/:id`、 -`/api/intake`、`/api/catalogue`、`/api/correction-subjects/:id`、`/api/cover-choice/:id`、 -`/api/cover-decisions`、`/api/preview`、`/api/artifacts/:digest`、`/api/automation`、 -`/api/playback-interactions`),不接内部 Application、不灌库、不 mock 业务。 -取消信号在客户端创建时注入:已经排队或进行中的请求都会随取消立即失败,不会留下晚到操作。 - -## 三个阶段分别报告 - -1. **准备**:普通资料目录与运行配置被记录,产品数据世代建立。 -2. **服务就绪**:只采信环境自己给出的就绪结论(私有控制认证 + 签名探测),不由 - 「已准备」或「进程存活」推导。 -3. **接入完成**:按产品工作流的终态判断,绝不用就绪掩盖接入失败,也不按资产数量代产品宣布成功。 - -## 单独执行 - -`prepare / start / inspect / intake / stop / rebuild / delete` 各自可单独执行,每次都会报告 -实际地址、实际端口与产品日志位置。`help`(或 `--help`)打印用法说明后以 0 结束。命令写在选项 -之前(`preview up --port 52010`,不是 `preview --port 52010 up`),顺序不对时在请求阶段以 2 拒绝, -不触达任何环境操作。`--workspace` -必须是一份代码检出目录(默认本仓根),运行数据落在 `<工作区>/.digital/environments/<环境>`; -`--material` 与 `--manifest` 按字面值判定,必须是绝对路径,入口不按调用终端的工作目录猜测意图。 - -## 默认输入与来源 - -- 默认资料目录是票 06 的代表目录 `fixtures/controlled-source-io/material/RJ01668691`; - 同一素材集的子集或任何其它普通目录都可以用 `--material` 显式给出(可重复)。 -- 来源默认**受控**:受控清单由使用者显式选择(`--manifest`,绝对路径),入口会按内容身份 - 核验后交给环境,产品根再复核一次。 -- **真实来源默认关闭**:必须同时给 `--allow-real-source` 与至少一条 `--real-condition`, - 条件会被原样报告;缺条件即拒绝,不静默落回真实装配。 -- 相对清单、相对资料目录、区间外端口(只允许 52001—52999)都在请求阶段被拒绝。 -- 环境名默认 `completion-01-preview`(日常入口)与 `completion-01-verify`(verify),都可用 - `--environment` 覆盖。 - -## 归属与收尾 - -- **自建环境**(默认):成功后先保存证据,再停止并删除;任何断言失败或异常都改走失败保留分支—— - 先停止自有服务,再保留可重启的数据、日志与证据载体,不宣称已回收。 -- **恢复上次失败记录**:`failed` 记录(上次准备失败,带着它自己的日志与数据载体)按恢复处理—— - 本入口重新准备并拥有**本次启动的服务**,收尾只停止这个服务;那条记录与它的载体不属于本入口, - 成功也不删除,停止与删除仍归原使用者决定。只有本次运行之前完全没有记录(`unprepared`)才取得删除权。 -- **`--provided`**:环境由调用方提供,本入口绝不准备、启动、停止、重建或删除它——成功、失败 - 与中断都一样,只做只读查看和业务断言,之后仍可被提供方独立使用。 -- **`up` 的复用前提**:只有本次运行之前没有任何环境记录(`unprepared`)或上次准备失败 - (`failed`)时,`up` 才自建并取得归属;已有世代一律原样复用——就绪世代直接沿用它的运行地址, - 不重新准备也不重启,归属仍归原使用者,失败也只保留现状。配置不兼容以 3 拒绝,未被接管以 4 拒绝。 -- **verify 前提**:环境在本次运行之前已存在且未给 `--provided` 时,只有 `failed` 记录允许按恢复 - 运行,其余状态直接拒绝,绝不复用并删除他人状态。 -- 主错误与收尾错误始终分开打印,收尾错误不覆盖主错误;`SIGINT`/`SIGTERM` 会中止进行中的 - 等待与晚到操作,自有服务按归属停止、现场保留后才退出(130/143),提供方环境原样留下。 - -## 退出码 - -| 码 | 含义 | -| --- | ------------------------------------------ | -| 0 | 结论成功 | -| 1 | 环境或产品操作失败(主错误已打印) | -| 2 | 用法或来源选择不有效(未触达任何环境操作) | -| 3 | 配置不兼容,需要显式重建 | -| 4 | 环境不受管理 | -| 5 | 环境拒绝请求 | -| 6 | 服务已就绪,但目录接入或业务断言未通过 | -| 130 | 被 SIGINT 中断;资源按归属处理后才退出 | -| 143 | 被 SIGTERM 中断;资源按归属处理后才退出 | - -## 覆盖与后续 - -本入口取代了根上原来的便捷脚本 `prototype`(`pnpm --filter @digital/web dev`):那个入口 -只起前端开发服务,不准备环境、不建产品数据、不做目录接入,也没有归属与收尾约定,因此这些 -逻辑过去散落在各人的手工命令里。`pnpm preview` 现在承担同一职责,`verify` 子命令把 -「从未接入 → 浏览 / 刷新 / 读取封面 / 人工编辑 / 子集与多目录接入 / 未列入来源请求失败 → -改名、自动刷新、播放进度与人工封面在重启后保留 → 显式重建保持输入字节」做成可重复执行的断言。 -`apps/web` 自身的 `dev`(前端热更新开发服务)不在本票范围,保持不变。 - -票 10 只需扩展本入口的默认输入集合(默认目录、默认识别配置),不必新增第二套准备逻辑。 - -## 保留产品状态的代码更新 - -先通过普通预览完成准备和目录接入,再停止现有服务,进入同一个环境的代码更新会话: - -```bash -pnpm preview up --environment my-preview -pnpm preview stop --environment my-preview -pnpm preview:dev my-preview -``` - -开发入口要求已有、已停止的准备记录,不重复接入目录,也不重建产品数据。 -领域、应用、来源及服务端变更由 Turbo 的实际依赖图构建并替换服务,组件与样式沿用热更新。 -编译失败时旧服务停止,完整诊断留在开发日志中;恢复有效代码后会话自动恢复。 - -另一个终端可用 `pnpm preview inspect --environment my-preview` 查看实际地址、更新状态与日志。 -用 `pnpm preview stop --environment my-preview` 独立终结本次开发会话;停止后代码变化不再启动服务。 -终端中断开发入口也会停止自有服务并保留产品数据及日志。准备、启动、停止、重建和删除仍可单独使用; -普通启动缺省使用生产运行模式,切换模式须先显式停止。 diff --git a/scripts/build-development.ts b/scripts/build-development.ts deleted file mode 100644 index 3a6085d..0000000 --- a/scripts/build-development.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { spawn } from "node:child_process"; -import { rm } from "node:fs/promises"; -import path from "node:path"; - -import { - stopEnvironment, - updateDevelopmentEnvironment, -} from "../packages/environment/src/index.ts"; - -const workspacePath = process.env.DIGITAL_DEVELOPMENT_WORKSPACE; -const environment = process.env.DIGITAL_DEVELOPMENT_ENVIRONMENT; -const sessionId = process.env.DIGITAL_DEVELOPMENT_SESSION; -if ( - workspacePath === undefined || - environment === undefined || - sessionId === undefined -) - throw new Error("开发编译必须由环境开发入口启动"); -const stopped = await stopEnvironment({ - workspacePath, - environment, - developmentSession: sessionId, -}); -if (stopped.status !== "stopped" && stopped.status !== "already-stopped") - throw new Error( - stopped.failure?.message ?? "旧开发世代无法确认停止,拒绝覆盖其模块", - ); -const sourceOnly = process.argv.includes("--source"); -const directory = path.resolve(".turbo/development"); -if (!sourceOnly) await rm(directory, { recursive: true, force: true }); -const args = [ - "exec", - "tsc", - "-p", - sourceOnly ? "tsconfig.json" : "tsconfig.build.json", - "--customConditions", - "digital-development", - "--pretty", - "false", - ...(sourceOnly - ? ["--noEmit"] - : [ - "--incremental", - "--outDir", - path.join(directory, "dist"), - "--tsBuildInfoFile", - path.join(directory, "build.tsbuildinfo"), - ]), -]; -const child = spawn("pnpm", args, { stdio: "inherit" }); -const exit = await new Promise<{ code: number | null; signal: string | null }>( - (resolve, reject) => { - child.once("error", reject); - child.once("close", (code, signal) => resolve({ code, signal })); - }, -); -if (exit.code !== 0) { - await updateDevelopmentEnvironment({ - workspacePath, - environment, - sessionId, - update: "failed", - failure: `开发编译 ${path.relative(workspacePath, process.cwd())} 退出 ${String(exit.code)} / ${String(exit.signal)};完整首因见开发日志`, - }); -} -process.exitCode = exit.code ?? 1; diff --git a/scripts/build-production-workspace.ts b/scripts/build-production-workspace.ts deleted file mode 100644 index e6359f7..0000000 --- a/scripts/build-production-workspace.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { spawn } from "node:child_process"; -import { readFile, readdir, rm, stat } from "node:fs/promises"; -import { resolve } from "node:path"; - -type PackageManifest = { - readonly name: string; - readonly dependencies?: Readonly>; - readonly exports?: { - readonly "."?: { readonly default?: string }; - }; -}; - -type WorkspacePackage = { - readonly directory: string; - readonly manifest: PackageManifest; -}; - -const root = resolve(import.meta.dirname, ".."); -const roots = new Set(["@digital/web", "@digital/db-migrations"]); -const manifests = new Map(); -for (const group of ["apps", "packages", "sources"]) { - for (const entry of await readdir(resolve(root, group))) { - const directory = resolve(root, group, entry); - try { - const manifest = JSON.parse( - await readFile(resolve(directory, "package.json"), "utf8"), - ) as PackageManifest; - manifests.set(manifest.name, { directory, manifest }); - } catch {} - } -} -const closure = new Set(); -function visit(name: string): void { - if (closure.has(name)) return; - const item = manifests.get(name); - if (!item) throw new Error(`未知 workspace package: ${name}`); - closure.add(name); - for (const [dependency, version] of Object.entries( - item.manifest.dependencies ?? {}, - )) { - if (version === "workspace:*") visit(dependency); - } -} -for (const name of roots) visit(name); -const built = new Set(); -async function build(name: string): Promise { - if (built.has(name)) return; - const item = manifests.get(name); - if (item === undefined) throw new Error(`未知 workspace package: ${name}`); - for (const [dependency, version] of Object.entries( - item.manifest.dependencies ?? {}, - )) { - if (version === "workspace:*" && closure.has(dependency)) - await build(dependency); - } - // Docker 排除 `dist` 输出后,TypeScript 组合构建可能仍接受过期的构建信息文件。 - // 构建配置名决定默认构建信息文件名;移除它以确保重新实体化声明输出。 - await rm(resolve(item.directory, "tsconfig.build.tsbuildinfo"), { - force: true, - }); - await run("pnpm", ["--filter", name, "run", "build"]); - const sourceDist = resolve(item.directory, "dist"); - const sourceExport = item.manifest.exports?.["."]?.default; - if (!(await exists(sourceDist)) && !sourceExport?.startsWith("./src/")) { - throw new Error(`${name} build 未生成 dist`); - } - built.add(name); -} -for (const name of roots) await build(name); - -async function exists(path: string): Promise { - try { - await stat(path); - return true; - } catch { - return false; - } -} -async function run(command: string, args: readonly string[]): Promise { - await new Promise((ok, fail) => { - const child = spawn(command, args, { cwd: root, stdio: "inherit" }); - child.once("error", fail); - child.once("close", (code) => - code === 0 ? ok() : fail(new Error(`${command} failed: ${code}`)), - ); - }); -} diff --git a/scripts/check-toolchain-versions.ts b/scripts/check-toolchain-versions.ts new file mode 100644 index 0000000..49f2c9f --- /dev/null +++ b/scripts/check-toolchain-versions.ts @@ -0,0 +1,491 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; + +/** 与根 check 的其它入口一样,仓库根以 realpath 为准。 */ +const repositoryRoot = realpathSync.native(process.cwd()); + +/** 根形状门文法与 Foundation 加包接受的根声明一致。 */ +const exactNodeVersion = /^\d+\.\d+\.\d+$/u; +const exactPnpmPin = /^pnpm@\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u; +/** CI 里 pnpm --version 的输出形制,与根 pin 去掉工具名前缀后同一文法。 */ +const exactPnpmVersion = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u; + +/** 槽位位置只认识规划器声明的两种形制,检查器不解释文件格式或版本语义。 */ +type MirrorSlotLocation = + | { readonly kind: "json-pointer"; readonly pointer: string } + | { readonly kind: "text-anchor"; readonly name: string }; + +type MirrorSlot = { + readonly id: string; + readonly path: string; + readonly location: MirrorSlotLocation; +}; + +/** 槽位清单由规划器的镜像槽位声明投影(模板变量替换),本文件不维护第二份真源。 */ +const carriedMirrorSlots: unknown = JSON.parse( + `[ + { + "id": "node-version-build-arg", + "path": ".devcontainer/devcontainer.json", + "location": { + "kind": "json-pointer", + "pointer": "/build/args/NODE_VERSION" + } + }, + { + "id": "web-node", + "path": "apps/web/Dockerfile", + "location": { + "kind": "text-anchor", + "name": "NODE_VERSION" + } + }, + { + "id": "web-node.playwright-client", + "path": "apps/web/Dockerfile.playwright-client", + "location": { + "kind": "text-anchor", + "name": "NODE_VERSION" + } + } +]`, +); + +type SlotReading = + | { readonly status: "explained"; readonly observed: string } + | { readonly status: "missing"; readonly reason: string } + | { readonly status: "unexplainable"; readonly reason: string }; + +/** 读取位置的展示名,供诊断点名槽位所在位置。 */ +function locationLabel(location: MirrorSlotLocation): string { + return location.kind === "json-pointer" + ? `JSON 指针 ${location.pointer}` + : `锚点 @template-mirror ${location.name}`; +} + +function isMirrorSlot(value: unknown): value is MirrorSlot { + if (typeof value !== "object" || value === null) { + return false; + } + const slot = value as Record; + if (typeof slot.id !== "string" || typeof slot.path !== "string") { + return false; + } + const location = slot.location; + if (typeof location !== "object" || location === null) { + return false; + } + const fields = location as Record; + if (fields.kind === "json-pointer") { + return typeof fields.pointer === "string"; + } + + return fields.kind === "text-anchor" && typeof fields.name === "string"; +} + +/** 携带清单形状非法时只报告这一条:清单本身是被检对象,不能静默跳过槽位。 */ +function readCarriedMirrorSlots(): { + readonly diagnostics: readonly string[]; + readonly slots: readonly MirrorSlot[]; +} { + if (!Array.isArray(carriedMirrorSlots)) { + return { + diagnostics: ["检查器携带的槽位清单不是数组:模板变量替换结果非法"], + slots: [], + }; + } + const diagnostics: string[] = []; + const slots: MirrorSlot[] = []; + for (const [index, entry] of carriedMirrorSlots.entries()) { + if (isMirrorSlot(entry)) { + slots.push(entry); + continue; + } + diagnostics.push( + `检查器携带的槽位清单第 ${index + 1} 项形状非法:${JSON.stringify(entry)}`, + ); + } + + return { diagnostics, slots }; +} + +/** 根版本形状门同时核对精确 Node 版本与 pnpm pin。 */ +type RootToolchain = { + readonly nodeVersion: string; + readonly packageManagerPin: string; +}; + +/** 根声明读取:形状门失败时只报告形状问题,不再报告副本漂移或运行事实。 */ +function readRootToolchain(): { + readonly diagnostics: readonly string[]; + readonly root: RootToolchain | undefined; +} { + let manifest: unknown; + try { + manifest = JSON.parse( + readFileSync(path.join(repositoryRoot, "package.json"), "utf8"), + ) as unknown; + } catch (error) { + return { + diagnostics: [ + `根 package.json 无法解析:${error instanceof Error ? error.message : String(error)};根真源不可读时无法核对任何静态副本,请先修复根 manifest`, + ], + root: undefined, + }; + } + const fields = ( + typeof manifest === "object" && manifest !== null ? manifest : {} + ) as Record; + const engines = fields.engines; + const nodeDeclaration = + typeof engines === "object" && engines !== null + ? (engines as Record).node + : undefined; + const packageManagerDeclaration = fields.packageManager; + const diagnostics: string[] = []; + const isExactNode = + typeof nodeDeclaration === "string" && + exactNodeVersion.test(nodeDeclaration); + if (!isExactNode) { + diagnostics.push( + `根 package.json 的 engines.node 必须是精确三段 Node 版本(形如 "24.16.0"),当前为 ${JSON.stringify(nodeDeclaration ?? null)};静态镜像槽位按根声明协调,请把根写成已测的精确 patch 版本`, + ); + } + const isExactPin = + typeof packageManagerDeclaration === "string" && + exactPnpmPin.test(packageManagerDeclaration); + if (!isExactPin) { + diagnostics.push( + `根 package.json 的 packageManager 必须是单个精确 pnpm pin(形如 "pnpm@11.21.0",不带范围),当前为 ${JSON.stringify(packageManagerDeclaration ?? null)};请先修正根声明`, + ); + } + if (diagnostics.length > 0 || !isExactNode || !isExactPin) { + return { diagnostics, root: undefined }; + } + + return { + diagnostics: [], + root: { + nodeVersion: nodeDeclaration, + packageManagerPin: packageManagerDeclaration, + }, + }; +} + +function parseJsonPointer(pointer: string): readonly string[] { + if (pointer === "") { + throw new Error("镜像槽位不能定位到文档根本身"); + } + if (!pointer.startsWith("/")) { + throw new Error("JSON Pointer 必须以 '/' 开头"); + } + + return pointer + .slice(1) + .split("/") + .map((token) => { + if (/~(?:[^01]|$)/u.test(token)) { + throw new Error("JSON Pointer 只允许 '~0' 与 '~1' 转义"); + } + + return token.replaceAll("~1", "/").replaceAll("~0", "~"); + }); +} + +const missingValue = Symbol("missing"); + +/** RFC 6901 的有限取值:位置不存在时返回 missingValue 哨兵,与类型不符分开报告。 */ +function readJsonPointer( + document: unknown, + segments: readonly string[], +): unknown { + let current: unknown = document; + for (const segment of segments) { + if (typeof current !== "object" || current === null) { + return missingValue; + } + const container = current as Record; + if (!Object.hasOwn(container, segment)) { + return missingValue; + } + current = container[segment]; + } + + return current; +} + +/** 结构化槽位读取:解析 JSON 后按位置取字符串标量,缩进与键序差异不影响。 */ +function readStructuredSlot(filePath: string, pointer: string): SlotReading { + let document: unknown; + try { + document = JSON.parse(readFileSync(filePath, "utf8")) as unknown; + } catch (error) { + return { + reason: `文件不是合法 JSON:${error instanceof Error ? error.message : String(error)}`, + status: "unexplainable", + }; + } + let segments: readonly string[]; + try { + segments = parseJsonPointer(pointer); + } catch (error) { + return { + reason: error instanceof Error ? error.message : String(error), + status: "unexplainable", + }; + } + const value = readJsonPointer(document, segments); + if (value === missingValue) { + return { reason: "声明位置在文件中不存在", status: "missing" }; + } + if (typeof value !== "string") { + return { + reason: `位置的值必须是字符串标量,实际为 ${JSON.stringify(value)}`, + status: "unexplainable", + }; + } + + return { observed: value, status: "explained" }; +} + +type MirrorMarker = + | { readonly kind: "start"; readonly name: string } + | { readonly kind: "end" } + | { readonly kind: "content" }; + +/** 与 Core 投影一致的成对锚点文法:剥掉注释标点,只认标记本身。 */ +function mirrorMarker(line: string): MirrorMarker { + const stripped = line + .trim() + .replace(/^[#;%*/-]+/u, "") + .trim(); + if (stripped === "@end-template-mirror") { + return { kind: "end" }; + } + const start = /^@template-mirror\s+(\S+)$/u.exec(stripped); + if (start?.[1] !== undefined) { + return { kind: "start", name: start[1] }; + } + + return { kind: "content" }; +} + +/** 锚点区域必须唯一且恰好承载一行,否则无法定位。 */ +function locateAnchorCarrier( + lines: readonly string[], + name: string, +): string | undefined { + const starts: number[] = []; + for (const [index, line] of lines.entries()) { + const marker = mirrorMarker(line); + if (marker.kind === "start" && marker.name === name) { + starts.push(index); + } + } + if (starts.length !== 1) { + return undefined; + } + const start = starts[0]!; + for (let index = start + 1; index < lines.length; index += 1) { + const marker = mirrorMarker(lines[index]!); + if (marker.kind === "start") { + return undefined; + } + if (marker.kind !== "end") { + continue; + } + + return index - start === 2 ? lines[start + 1] : undefined; + } + + return undefined; +} + +function escapeToken(value: string): string { + return value.replaceAll(/[.$()*?[\]{}+/|^\\]/gu, String.raw`\$&`); +} + +/** 标记不可定位时,承载赋值或引用是否仍在:区分破坏与消费者整体退出该机制。 */ +function usesSlotName(text: string, name: string): boolean { + const token = escapeToken(name); + + return ( + new RegExp(`(?:^|[^\\w$])${token}\\s*=`, "u").test(text) || + new RegExp(String.raw`\$\{\s*${token}\s*\}`, "u").test(text) + ); +} + +function readArgDefault(carrier: string, name: string): string | undefined { + const match = new RegExp( + `^ARG\\s+${escapeToken(name)}\\s*=\\s*"([^"\\r\\n]*)"\\s*$`, + "u", + ).exec(carrier.trim()); + + return match?.[1]; +} + +/** 文本锚点槽位读取:只比较区域内那一行 ARG 的默认值 token。 */ +function readTextSlot(name: string, text: string): SlotReading { + const carrier = locateAnchorCarrier(text.split(/\r?\n/u), name); + if (carrier === undefined) { + return usesSlotName(text, name) + ? { + reason: + "锚点区域缺失、重复、不成对或含额外正文,但承载声明或引用仍在", + status: "unexplainable", + } + : { reason: "锚点与承载声明都不在文件中", status: "missing" }; + } + const observed = readArgDefault(carrier, name); + if (observed === undefined) { + return { + reason: `锚点区域内的那一行不是 \`ARG ${name}="..."\` 默认值形式:${carrier.trim()}`, + status: "unexplainable", + }; + } + + return { observed, status: "explained" }; +} + +function checkMirrorSlot( + slot: MirrorSlot, + root: RootToolchain, +): readonly string[] { + const sourceLabel = "根 engines.node"; + const expected = root.nodeVersion; + const filePath = path.join(repositoryRoot, slot.path); + let contents: string; + try { + contents = readFileSync(filePath, "utf8"); + } catch { + return [ + `静态镜像槽位 ${slot.id} 缺失:${slot.path} 不存在;${sourceLabel} 真源为 ${JSON.stringify(expected)};请按根声明补齐该文件`, + ]; + } + const reading = + slot.location.kind === "json-pointer" + ? readStructuredSlot(filePath, slot.location.pointer) + : readTextSlot(slot.location.name, contents); + const position = locationLabel(slot.location); + if (reading.status === "missing") { + return [ + `静态镜像槽位 ${slot.id} 缺失:${slot.path} 的 ${position} 不在文件中(${reading.reason});${sourceLabel} 真源为 ${JSON.stringify(expected)};请按根声明补齐该位置`, + ]; + } + if (reading.status === "unexplainable") { + return [ + `静态镜像槽位 ${slot.id} 不可解释:${slot.path} 的 ${position} ${reading.reason};${sourceLabel} 真源为 ${JSON.stringify(expected)};请恢复该位置的声明形状(合法 JSON 字符串值,或成对 @template-mirror 锚点内恰好一行 ARG 默认值)`, + ]; + } + if (reading.observed === expected) { + return []; + } + + return [ + `静态镜像槽位 ${slot.id} 漂移:${slot.path} 的 ${position} 观测为 ${JSON.stringify(reading.observed)},${sourceLabel} 真源为 ${JSON.stringify(expected)};请把这个副本改回根声明,或先修改根声明并同步受管镜像,不要只改副本`, + ]; +} + +/** + * 运行事实只在既有 CI 边界核对:工作流按根声明安装 Node 与 pnpm,因此不匹配是真实异常。 + * 非 CI 的开发宿主版本不参与判断,合法的静态配置不因宿主差异被拒。 + */ +function ciRuntimeBoundaryApplies(): boolean { + return /^(?:1|true)$/iu.test(process.env.CI ?? ""); +} + +type RuntimeVersionFact = + | { readonly observed: string; readonly status: "explained" } + | { readonly reason: string; readonly status: "unreadable" }; + +/** 只读取当前环境里 pnpm 自述的版本,不安装、不切换版本、不联网查询。 */ +function readPnpmRuntimeVersion(): RuntimeVersionFact { + const result = spawnSync("pnpm", ["--version"], { encoding: "utf8" }); + if (result.error !== undefined) { + return { + reason: `调用 \`pnpm --version\` 失败:${result.error.message}`, + status: "unreadable", + }; + } + if (result.status !== 0) { + const detail = (result.stderr ?? "").trim(); + return { + reason: `\`pnpm --version\` 以退出码 ${String(result.status)} 失败:${detail === "" ? "(无 stderr 输出)" : detail}`, + status: "unreadable", + }; + } + const observed = (result.stdout ?? "").trim(); + if (!exactPnpmVersion.test(observed)) { + return { + reason: `\`pnpm --version\` 的输出不是单个精确版本:${JSON.stringify(observed)}`, + status: "unreadable", + }; + } + + return { observed, status: "explained" }; +} + +function checkRuntimeToolchainFacts(root: RootToolchain): readonly string[] { + const diagnostics: string[] = []; + const expectedNode = `v${root.nodeVersion}`; + if (process.version !== expectedNode) { + diagnostics.push( + `Node 运行版本不匹配:CI 里 process.version 观测为 ${JSON.stringify(process.version)},根 engines.node 真源为 ${JSON.stringify(root.nodeVersion)};工作流应按根声明安装 Node(setup-node 的 node-version-file 指向 package.json),请修正 CI 的安装来源或先改根声明,不要在脚本里另写版本`, + ); + } + const pinnedVersion = root.packageManagerPin + .slice("pnpm@".length) + .split("+")[0]; + const pnpmFact = readPnpmRuntimeVersion(); + if (pnpmFact.status === "unreadable") { + diagnostics.push( + `pnpm 运行版本获取失败:${pnpmFact.reason};根 packageManager 真源为 ${JSON.stringify(root.packageManagerPin)};请恢复 CI 环境中可用的 pnpm(action-setup 按根 packageManager 安装),本核对不能静默跳过`, + ); + } else if (pnpmFact.observed !== pinnedVersion) { + diagnostics.push( + `pnpm 运行版本不匹配:CI 里 \`pnpm --version\` 观测为 ${JSON.stringify(pnpmFact.observed)},根 packageManager 真源为 ${JSON.stringify(root.packageManagerPin)};请让 CI 用根 pin 的 pnpm 执行本任务,不要放宽根声明迁就环境`, + ); + } + + return diagnostics; +} + +async function runToolchainMirrorCheck(): Promise { + const carried = readCarriedMirrorSlots(); + if (carried.diagnostics.length > 0) { + return carried.diagnostics; + } + const toolchain = readRootToolchain(); + if (toolchain.root === undefined) { + return toolchain.diagnostics; + } + const root = toolchain.root; + const diagnostics: string[] = []; + for (const slot of carried.slots) { + diagnostics.push(...checkMirrorSlot(slot, root)); + } + if (diagnostics.length === 0) { + process.stdout.write( + `工具链静态镜像槽位一致:根 Node ${root.nodeVersion},清单 ${carried.slots.length} 项\n`, + ); + } + if (ciRuntimeBoundaryApplies()) { + const runtimeDiagnostics = checkRuntimeToolchainFacts(root); + diagnostics.push(...runtimeDiagnostics); + if (runtimeDiagnostics.length === 0) { + process.stdout.write( + `工具链运行事实一致:CI 按根 Node ${root.nodeVersion} 与 ${root.packageManagerPin} 核对了当前进程\n`, + ); + } + } + + return diagnostics; +} + +const diagnosticsFound = await runToolchainMirrorCheck(); + +if (diagnosticsFound.length > 0) { + process.stderr.write(`${diagnosticsFound.join("\n")}\n`); + process.exitCode = 1; +} diff --git a/scripts/preview-product.ts b/scripts/preview-product.ts deleted file mode 100644 index 523ed7c..0000000 --- a/scripts/preview-product.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 仓库级预览入口复用共享的正式 HTTP 产品客户端。 */ -export * from "../packages/test-support/src/product-client.ts"; diff --git a/scripts/run-turbo-boundaries.ts b/scripts/run-turbo-boundaries.ts new file mode 100644 index 0000000..9050d95 --- /dev/null +++ b/scripts/run-turbo-boundaries.ts @@ -0,0 +1,74 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +type InterruptSignal = "SIGINT" | "SIGTERM"; + +function exitStatus( + code: number | null, + signal: NodeJS.Signals | null, + interruptedBy: InterruptSignal | undefined, + spawnFailed: boolean, +): number { + if (interruptedBy !== undefined) { + return interruptedBy === "SIGINT" ? 130 : 143; + } + if (spawnFailed) return 1; + if (code !== null) return code; + if (signal === "SIGINT") return 130; + if (signal === "SIGTERM") return 143; + return 1; +} + +export async function runTurboBoundaries(cwd = process.cwd()): Promise { + const turbo = spawn("pnpm", ["exec", "turbo", "boundaries", "--no-color"], { + cwd, + stdio: "inherit", + detached: process.platform !== "win32", + }); + let interruptedBy: InterruptSignal | undefined; + let spawnFailed = false; + + const forwardSignal = (signal: InterruptSignal): void => { + if (turbo.exitCode !== null || turbo.signalCode !== null) return; + + interruptedBy ??= signal; + if (turbo.pid === undefined) return; + try { + if (process.platform !== "win32") { + process.kill(-turbo.pid, signal); + } else { + turbo.kill(signal); + } + } catch { + turbo.kill(signal); + } + }; + const forwardInterrupt = (): void => forwardSignal("SIGINT"); + const forwardTerminate = (): void => forwardSignal("SIGTERM"); + + process.on("SIGINT", forwardInterrupt); + process.on("SIGTERM", forwardTerminate); + + return await new Promise((resolveStatus) => { + turbo.once("spawn", () => { + if (interruptedBy !== undefined) forwardSignal(interruptedBy); + }); + turbo.once("error", (error) => { + spawnFailed = true; + process.stderr.write(`无法运行 Turbo boundaries:${error.message}\n`); + }); + turbo.once("close", (code, signal) => { + process.off("SIGINT", forwardInterrupt); + process.off("SIGTERM", forwardTerminate); + resolveStatus(exitStatus(code, signal, interruptedBy, spawnFailed)); + }); + }); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + process.exitCode = await runTurboBoundaries(); +} diff --git a/scripts/task-gates.ts b/scripts/task-gates.ts new file mode 100644 index 0000000..4ee790d --- /dev/null +++ b/scripts/task-gates.ts @@ -0,0 +1,590 @@ +import { spawn, spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export type TaskGate = "check" | "check:heavy" | "check:full" | "fix"; + +type ScriptRole = + | "quality:light" + | "quality:heavy" + | "quality:conditional" + | "quality:entry" + | "work:development" + | "work:repair" + | "work:release" + | "work:preparation" + | "work:lifecycle" + | "work:on-demand"; + +type TurboTask = { + readonly name: string; + readonly fullName: string; + readonly packageName: string; + readonly tags: readonly string[]; + readonly executable: boolean; + readonly role?: ScriptRole; +}; + +type GateLayer = { + readonly role: "quality:light" | "quality:heavy" | "work:repair"; + readonly label: string; +}; + +type CommandResult = { + readonly status: number; + readonly stdout: string; + readonly stderr: string; + readonly error: Error | undefined; +}; + +const turboTaskQuery = + "{ packages { items { name tasks { items { name fullName tags script command } } } } }"; + +const validRoles = new Set([ + "quality:light", + "quality:heavy", + "quality:conditional", + "quality:entry", + "work:development", + "work:repair", + "work:release", + "work:preparation", + "work:lifecycle", + "work:on-demand", +]); + +const publicGateNames = new Set([ + "check", + "check:heavy", + "check:full", + "fix", +]); + +const maxTurboOutputBytes = 16 * 1024 * 1024; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function report(message: string): void { + process.stderr.write(`${message}\n`); +} + +function runCaptured(cwd: string, args: readonly string[]): CommandResult { + const result = spawnSync("pnpm", ["exec", "turbo", ...args], { + cwd, + encoding: "utf8", + maxBuffer: maxTurboOutputBytes, + }); + + return { + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + error: result.error, + }; +} + +function runVisible(cwd: string, args: readonly string[]): Promise { + return new Promise((resolveStatus) => { + const command = + process.platform === "win32" + ? "pnpm" + : resolve(cwd, "node_modules/.bin/turbo"); + const commandArgs = + process.platform === "win32" ? ["exec", "turbo", ...args] : args; + const child = spawn(command, commandArgs, { + cwd, + detached: process.platform !== "win32", + stdio: "inherit", + }); + let interruptedBy: NodeJS.Signals | undefined; + const forward = (signal: NodeJS.Signals): void => { + if (child.exitCode === null && child.signalCode === null) { + interruptedBy = signal; + const turboSignal = signal === "SIGTERM" ? "SIGINT" : signal; + if (process.platform !== "win32" && child.pid !== undefined) { + try { + process.kill(-child.pid, turboSignal); + } catch { + child.kill(turboSignal); + } + } else { + child.kill(turboSignal); + } + } + }; + const forwardInterrupt = (): void => forward("SIGINT"); + const forwardTerminate = (): void => forward("SIGTERM"); + const removeSignalHandlers = (): void => { + process.off("SIGINT", forwardInterrupt); + process.off("SIGTERM", forwardTerminate); + }; + + process.on("SIGINT", forwardInterrupt); + process.on("SIGTERM", forwardTerminate); + child.once("error", (error) => { + removeSignalHandlers(); + report(`无法启动 Turbo 执行 ${args.join(" ")}:${error.message}`); + resolveStatus(1); + }); + child.once("close", (code, signal) => { + removeSignalHandlers(); + if (interruptedBy !== undefined) { + resolveStatus(interruptedBy === "SIGINT" ? 130 : 143); + } else if (code !== null) { + resolveStatus(code); + } else { + resolveStatus( + signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1, + ); + } + }); + }); +} + +function commandFailure(label: string, result: CommandResult): string { + const details = result.stderr.trim(); + if (result.error !== undefined) { + return `无法运行 Turbo ${label}:${result.error.message}`; + } + return `Turbo ${label} 失败(退出码 ${String(result.status)})${details === "" ? "" : `:\n${details}`}`; +} + +function roleTags(tags: readonly string[]): readonly string[] { + return tags.filter( + (tag) => tag.startsWith("quality:") || tag.startsWith("work:"), + ); +} + +function publicGateTask(task: { + readonly name: string; + readonly packageName: string; +}): boolean { + return ( + task.packageName === "//" && publicGateNames.has(task.name as TaskGate) + ); +} + +function protectedVerificationTask(task: TurboTask): boolean { + return ( + task.name === "check" || + task.name.startsWith("check:") || + task.name === "test" || + task.name.startsWith("test:") || + task.name === "lint" || + task.name === "typecheck" || + task.name === "format:check" + ); +} + +function assignRole( + task: Omit, + diagnostics: string[], +): ScriptRole | undefined { + const roles = roleTags(task.tags); + const isPublicGate = publicGateTask(task); + if (roles.length === 0) { + diagnostics.push( + `${task.fullName} 缺少唯一职责标签;请在 Turbo 任务标签中声明质量范围或工作用途`, + ); + return undefined; + } + if (roles.length > 1) { + diagnostics.push( + `${task.fullName} 有多个职责标签:${roles.join("、")};每个可执行任务只能声明一个职责`, + ); + return undefined; + } + + const role = roles[0]!; + if (!validRoles.has(role as ScriptRole)) { + diagnostics.push(`${task.fullName} 使用未知职责标签 ${role}`); + return undefined; + } + const scriptRole = role as ScriptRole; + const publicRole = task.name === "fix" ? "work:repair" : "quality:entry"; + if (isPublicGate && scriptRole !== publicRole) { + diagnostics.push( + `${task.fullName} 是公共任务入口,职责必须为 ${publicRole},当前为 ${scriptRole}`, + ); + return undefined; + } + if (!isPublicGate && scriptRole === "quality:entry") { + diagnostics.push( + `${task.fullName} 不能使用 quality:entry;该职责只允许根 check、check:heavy、check:full`, + ); + return undefined; + } + if (protectedVerificationTask(task) && !scriptRole.startsWith("quality:")) { + diagnostics.push( + `${task.fullName} 是保留验收任务,不能归入非验收职责 ${scriptRole}`, + ); + return undefined; + } + + return scriptRole; +} + +function readTaskDirectory( + cwd: string, + entryCommand: string, +): { + readonly tasks: readonly TurboTask[]; + readonly diagnostics: readonly string[]; +} { + const result = runCaptured(cwd, ["query", turboTaskQuery]); + if (result.error !== undefined || result.status !== 0) { + return { + diagnostics: [commandFailure("原生任务目录查询", result)], + tasks: [], + }; + } + + let response: unknown; + try { + response = JSON.parse(result.stdout) as unknown; + } catch (error) { + return { + diagnostics: [ + `Turbo 原生任务目录不是有效 JSON:${error instanceof Error ? error.message : String(error)}`, + ], + tasks: [], + }; + } + + const packages = + isRecord(response) && isRecord(response.data) + ? response.data.packages + : undefined; + const packageItems = isRecord(packages) ? packages.items : undefined; + if (!Array.isArray(packageItems)) { + return { + diagnostics: ["Turbo 原生任务目录缺少 packages.items"], + tasks: [], + }; + } + + const diagnostics: string[] = []; + const tasks: TurboTask[] = []; + for (const packageItem of packageItems) { + if (!isRecord(packageItem) || !isNonEmptyString(packageItem.name)) { + diagnostics.push("Turbo 原生任务目录包含无法识别的工作区包"); + continue; + } + const taskConnection = packageItem.tasks; + const taskItems = isRecord(taskConnection) + ? taskConnection.items + : undefined; + if (!Array.isArray(taskItems)) { + diagnostics.push(`${packageItem.name} 的 Turbo 任务目录缺少 tasks.items`); + continue; + } + + for (const taskItem of taskItems) { + if (!isRecord(taskItem)) { + diagnostics.push(`${packageItem.name} 的 Turbo 任务目录包含无效任务`); + continue; + } + const { name, fullName, tags, script, command } = taskItem; + if ( + !isNonEmptyString(name) || + !isNonEmptyString(fullName) || + !Array.isArray(tags) || + !tags.every((tag) => typeof tag === "string") + ) { + diagnostics.push(`${packageItem.name} 的 Turbo 任务条目缺少名称或标签`); + continue; + } + + const executable = isNonEmptyString(script) || isNonEmptyString(command); + const taskWithoutRole = { + name, + fullName, + packageName: packageItem.name, + tags, + executable, + }; + if (publicGateTask(taskWithoutRole)) { + const expectedCommand = `${entryCommand} ${name}`; + if (script !== expectedCommand || command !== expectedCommand) { + diagnostics.push( + `${fullName} 公共入口接线错误;package.json script 和有效 command 必须为 ${expectedCommand}`, + ); + } + } + if (!executable && roleTags(tags).length === 0) { + tasks.push(taskWithoutRole); + continue; + } + + const role = assignRole(taskWithoutRole, diagnostics); + tasks.push( + role === undefined ? taskWithoutRole : { ...taskWithoutRole, role }, + ); + } + } + + for (const name of publicGateNames) { + if ( + !tasks.some((task) => task.packageName === "//" && task.name === name) + ) { + diagnostics.push(`缺少根公共任务入口 ${name}`); + } + } + + return { diagnostics, tasks }; +} + +function parseDryRun( + cwd: string, + tasks: readonly TurboTask[], +): { + readonly taskIds: readonly string[]; + readonly diagnostics: readonly string[]; +} { + if (tasks.length === 0) { + return { diagnostics: [], taskIds: [] }; + } + const result = runCaptured(cwd, [ + "run", + ...tasks.map((task) => task.fullName), + "--dry=json", + ]); + if (result.error !== undefined || result.status !== 0) { + return { + diagnostics: [commandFailure("执行图预览", result)], + taskIds: [], + }; + } + + let response: unknown; + try { + response = JSON.parse(result.stdout) as unknown; + } catch (error) { + return { + diagnostics: [ + `Turbo 执行图预览不是有效 JSON:${error instanceof Error ? error.message : String(error)}`, + ], + taskIds: [], + }; + } + if (!isRecord(response) || !Array.isArray(response.tasks)) { + return { + diagnostics: ["Turbo 执行图预览缺少 tasks"], + taskIds: [], + }; + } + + const taskIds: string[] = []; + const diagnostics: string[] = []; + for (const task of response.tasks) { + if (!isRecord(task) || !isNonEmptyString(task.taskId)) { + diagnostics.push("Turbo 执行图预览包含无法识别的任务标识"); + continue; + } + taskIds.push(task.taskId); + } + + return { diagnostics, taskIds }; +} + +function validateLayerClosure( + layer: GateLayer, + taskIds: readonly string[], + tasksByName: ReadonlyMap, +): readonly string[] { + const diagnostics: string[] = []; + for (const taskId of taskIds) { + const dependency = tasksByName.get(taskId); + if (dependency === undefined) { + diagnostics.push( + `${layer.label} 执行图包含任务目录中不存在的任务 ${taskId}`, + ); + continue; + } + if (publicGateTask(dependency)) { + diagnostics.push( + `${layer.label} 执行图包含 ${dependency.fullName}:叶任务不能依赖公共聚合入口`, + ); + } else if ( + dependency.role !== undefined && + dependency.role !== layer.role && + dependency.role !== "work:preparation" && + !(layer.role !== "quality:light" && dependency.role === "quality:light") + ) { + const scope = + layer.role === "quality:light" && dependency.role === "quality:heavy" + ? "轻检查不能间接启动重验收" + : dependency.role === "quality:conditional" + ? "常规验收不能间接启动条件验收" + : `不允许间接启动职责 ${dependency.role}`; + diagnostics.push( + `${layer.label} 执行图包含 ${dependency.fullName}:${scope}`, + ); + } + } + + return diagnostics; +} + +function layerFor(role: GateLayer["role"]): GateLayer { + return { + role, + label: + role === "quality:light" + ? "轻检查" + : role === "quality:heavy" + ? "重验收" + : "修复", + }; +} + +function layersFor(gate: TaskGate): readonly GateLayer[] { + if (gate === "check") { + return [layerFor("quality:light")]; + } + if (gate === "check:heavy") { + return [layerFor("quality:heavy")]; + } + if (gate === "fix") { + return [layerFor("work:repair")]; + } + + return [layerFor("quality:light"), layerFor("quality:heavy")]; +} + +export async function runTaskGate( + gate: TaskGate, + cwd = process.cwd(), + entryCommand = "node --conditions=digital-source scripts/task-gates.ts", +): Promise { + if (!publicGateNames.has(gate)) { + report(`未知公共任务入口:${String(gate)}`); + return 1; + } + + const directory = readTaskDirectory(cwd, entryCommand); + if (directory.diagnostics.length > 0) { + report(directory.diagnostics.join("\n")); + return 1; + } + const tasksByName = new Map( + directory.tasks.map((task) => [task.fullName, task]), + ); + const layers = layersFor(gate); + const plans: { + readonly layer: GateLayer; + readonly tasks: readonly TurboTask[]; + }[] = []; + const diagnostics: string[] = []; + + for (const task of directory.tasks) { + if (task.executable || task.role === undefined) { + continue; + } + const dryRun = parseDryRun(cwd, [task]); + diagnostics.push(...dryRun.diagnostics); + if ( + dryRun.diagnostics.length === 0 && + !dryRun.taskIds.some( + (taskId) => tasksByName.get(taskId)?.executable === true, + ) + ) { + diagnostics.push( + `${task.fullName} 声明悬空职责标签 ${task.role}:无命令节点的原生执行图没有可执行工作`, + ); + } + } + + for (const layer of layers) { + const selected = directory.tasks.filter( + (task) => task.role === layer.role && !publicGateTask(task), + ); + const dryRun = parseDryRun(cwd, selected); + diagnostics.push(...dryRun.diagnostics); + diagnostics.push( + ...validateLayerClosure(layer, dryRun.taskIds, tasksByName), + ); + if ( + layer.role === "quality:light" && + !dryRun.taskIds.some( + (taskId) => tasksByName.get(taskId)?.executable === true, + ) + ) { + diagnostics.push( + "轻检查没有选中有效任务;请为实际验收任务声明 quality:light", + ); + } + plans.push({ layer, tasks: selected }); + } + + if (diagnostics.length > 0) { + report(diagnostics.join("\n")); + return 1; + } + + const conditionalTasks = directory.tasks.filter( + (task) => task.role === "quality:conditional", + ); + if (gate !== "fix" && conditionalTasks.length > 0) { + report( + `条件验收未执行:${conditionalTasks.map((task) => task.fullName).join("、")}`, + ); + } + + for (const plan of plans) { + if (plan.tasks.length === 0) { + report(`${plan.layer.label}没有选中任务,跳过执行`); + continue; + } + const status = await runVisible(cwd, [ + "run", + ...plan.tasks.map((task) => task.fullName), + "--continue=dependencies-successful", + gate === "fix" ? "--output-logs=full" : "--output-logs=errors-only", + "--log-order=grouped", + "--log-prefix=task", + ]); + if (status !== 0) { + return status; + } + } + + return 0; +} + +const mainFile = process.argv[1]; +if ( + mainFile !== undefined && + pathToFileURL(resolve(mainFile)).href === import.meta.url +) { + const gate = process.argv[2]; + if ( + process.argv.length !== 3 || + (gate !== "check" && + gate !== "check:heavy" && + gate !== "check:full" && + gate !== "fix") + ) { + report( + "用法:node scripts/task-gates.ts ", + ); + process.exitCode = 2; + } else { + void runTaskGate(gate) + .then((status) => { + process.exitCode = status; + }) + .catch((error: unknown) => { + report( + `公共任务入口失败:${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + }); + } +} diff --git a/scripts/test/task-gates.test.ts b/scripts/test/task-gates.test.ts new file mode 100644 index 0000000..28f4beb --- /dev/null +++ b/scripts/test/task-gates.test.ts @@ -0,0 +1,274 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const repository = path.resolve(import.meta.dirname, "../.."); + +await test("公共门禁按职责执行并在错误配置产生副作用前拒绝", async (context) => { + const workspace = await mkdtemp(path.join(tmpdir(), "digital-task-gate-")); + context.after(() => rm(workspace, { recursive: true, force: true })); + await mkdir(path.join(workspace, "scripts")); + await copyFile( + path.join(repository, "scripts/task-gates.ts"), + path.join(workspace, "scripts/task-gates.ts"), + ); + await mkdir(path.join(workspace, "node_modules/.bin"), { recursive: true }); + await symlink( + path.join(repository, "node_modules/.bin/turbo"), + path.join(workspace, "node_modules/.bin/turbo"), + ); + await mkdir(path.join(workspace, "packages/fixture"), { recursive: true }); + await writeFile( + path.join(workspace, "packages/fixture/package.json"), + JSON.stringify({ name: "fixture", private: true }), + ); + const packageManager = ( + JSON.parse( + await readFile(path.join(repository, "package.json"), "utf8"), + ) as { packageManager: string } + ).packageManager; + await writeFile( + path.join(workspace, "package.json"), + JSON.stringify({ + name: "gate-contract", + private: true, + type: "module", + packageManager, + scripts: { + check: "node --conditions=digital-source scripts/task-gates.ts check", + "check:heavy": + "node --conditions=digital-source scripts/task-gates.ts check:heavy", + "check:full": + "node --conditions=digital-source scripts/task-gates.ts check:full", + fix: "node --conditions=digital-source scripts/task-gates.ts fix", + "test:light": "node scripts/record.ts light", + "test:heavy": "node scripts/record.ts heavy", + "test:conditional": "node scripts/record.ts conditional", + }, + }), + ); + await writeFile( + path.join(workspace, "scripts/record.ts"), + 'import { appendFileSync } from "node:fs"; appendFileSync("ran.txt", process.argv[2] + "\\n"); if (process.argv[3] === "fail") process.exitCode = 7;\n', + ); + await writeFile( + path.join(workspace, "pnpm-workspace.yaml"), + "packages: ['packages/*']\nverifyDepsBeforeRun: false\n", + ); + await writeFile( + path.join(workspace, "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\nimporters:\n .: {}\n", + ); + await writeFile( + path.join(workspace, "turbo.json"), + JSON.stringify({ + tasks: { + "//#check": { tags: ["quality:entry"] }, + "//#check:heavy": { tags: ["quality:entry"] }, + "//#check:full": { tags: ["quality:entry"] }, + "//#fix": { tags: ["work:repair"] }, + "//#test:light": { tags: ["quality:light"], cache: false }, + "//#test:heavy": { tags: ["quality:heavy"], cache: false }, + "//#test:conditional": { tags: ["quality:conditional"], cache: false }, + }, + }), + ); + const result = spawnSync("pnpm", ["run", "check"], { + cwd: workspace, + encoding: "utf8", + timeout: 30_000, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal( + await readFile(path.join(workspace, "ran.txt"), "utf8"), + "light\n", + ); + assert.match(result.stderr, /条件验收未执行.*test:conditional/u); + const invalid = spawnSync("pnpm", ["run", "check", "--unsupported"], { + cwd: workspace, + encoding: "utf8", + timeout: 30_000, + }); + assert.equal(invalid.status, 2, invalid.stdout + invalid.stderr); + assert.equal( + await readFile(path.join(workspace, "ran.txt"), "utf8"), + "light\n", + ); + + type Task = { + tags?: string[]; + cache?: boolean; + dependsOn?: string[]; + command?: string[]; + }; + const baseManifest = JSON.parse( + await readFile(path.join(workspace, "package.json"), "utf8"), + ) as { scripts: Record }; + const baseConfiguration = JSON.parse( + await readFile(path.join(workspace, "turbo.json"), "utf8"), + ) as { tasks: Record }; + + const scenarios: { + name: string; + gate: string; + configure: ( + manifest: typeof baseManifest, + configuration: typeof baseConfiguration, + ) => void; + expected: string; + success: boolean; + }[] = [ + { + name: "完整门禁先轻后重,条件项保持未执行", + gate: "check:full", + configure: () => {}, + expected: "light\nheavy\n", + success: true, + }, + { + name: "重门禁执行必要准备而不选择无关轻任务", + gate: "check:heavy", + configure: (manifest, configuration) => { + manifest.scripts["prepare"] = "node scripts/record.ts prepared"; + configuration.tasks["//#prepare"] = { + tags: ["work:preparation"], + cache: false, + }; + configuration.tasks["//#test:heavy"]!.dependsOn = ["//#prepare"]; + }, + expected: "prepared\nheavy\n", + success: true, + }, + { + name: "修复入口只选择修复任务", + gate: "fix", + configure: (manifest, configuration) => { + manifest.scripts["lint:fix"] = "node scripts/record.ts repaired"; + configuration.tasks["//#lint:fix"] = { + tags: ["work:repair"], + cache: false, + }; + }, + expected: "repaired\n", + success: true, + }, + { + name: "轻层失败阻止重层启动", + gate: "check:full", + configure: (manifest) => { + manifest.scripts["test:light"] = "node scripts/record.ts light fail"; + }, + expected: "light\n", + success: false, + }, + ...[ + { name: "缺失职责", tags: [] }, + { name: "未知职责", tags: ["quality:unknown"] }, + { name: "多重职责", tags: ["quality:light", "quality:heavy"] }, + { name: "验收伪装为按需工作", tags: ["work:on-demand"] }, + ].map(({ name, tags }) => ({ + name, + gate: "check", + configure: ( + _manifest: typeof baseManifest, + configuration: typeof baseConfiguration, + ) => { + configuration.tasks["//#test:light"]!.tags = tags; + }, + expected: "", + success: false, + })), + { + name: "公共命令接线错误", + gate: "check", + configure: (manifest) => { + manifest.scripts["check:full"] = "node scripts/record.ts bypassed"; + }, + expected: "", + success: false, + }, + { + name: "带职责的空聚合", + gate: "check", + configure: (_manifest, configuration) => { + configuration.tasks["//#verify:empty"] = { tags: ["quality:light"] }; + }, + expected: "", + success: false, + }, + { + name: "轻层依赖重层", + gate: "check", + configure: (_manifest, configuration) => { + configuration.tasks["//#test:light"]!.dependsOn = ["//#test:heavy"]; + }, + expected: "", + success: false, + }, + { + name: "完整门禁先预检重层,非法条件依赖使轻层也不启动", + gate: "check:full", + configure: (_manifest, configuration) => { + configuration.tasks["//#test:heavy"]!.dependsOn = [ + "//#test:conditional", + ]; + }, + expected: "", + success: false, + }, + { + name: "任务闭包回流公共聚合入口", + gate: "check", + configure: (_manifest, configuration) => { + configuration.tasks["//#test:light"]!.dependsOn = ["//#check"]; + }, + expected: "", + success: false, + }, + ]; + for (const scenario of scenarios) { + await context.test(scenario.name, async () => { + const manifest = structuredClone(baseManifest); + const configuration = structuredClone(baseConfiguration); + scenario.configure(manifest, configuration); + await writeFile( + path.join(workspace, "package.json"), + JSON.stringify(manifest), + ); + await writeFile( + path.join(workspace, "turbo.json"), + JSON.stringify(configuration), + ); + await writeFile(path.join(workspace, "ran.txt"), ""); + const execution = spawnSync("pnpm", ["run", scenario.gate], { + cwd: workspace, + encoding: "utf8", + timeout: 30_000, + }); + assert.equal(execution.error, undefined); + if (scenario.success) + assert.equal(execution.status, 0, execution.stdout + execution.stderr); + else + assert.notEqual( + execution.status, + 0, + execution.stdout + execution.stderr, + ); + assert.equal( + await readFile(path.join(workspace, "ran.txt"), "utf8"), + scenario.expected, + ); + }); + } +}); diff --git a/sources/bangumi/package.json b/sources/bangumi/package.json index b1e3fd1..a4cf916 100644 --- a/sources/bangumi/package.json +++ b/sources/bangumi/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/bangumi", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/bangumi/test/capabilities.test.ts b/sources/bangumi/test/capabilities.test.ts index b5563b2..08d87ad 100644 --- a/sources/bangumi/test/capabilities.test.ts +++ b/sources/bangumi/test/capabilities.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { BangumiCollector, @@ -84,6 +84,7 @@ describe("Bangumi episodic directory convention", () => { "Season 1/葬送的芙莉莲 - S01E03 - 第 2 集.mkv", ]), ); + assert("workItems" in plan); // S01E03 标第 2 集是错误中文集号,被拒绝而不退化成技术标签;`- NCOP` 是有效横线技术标签。 expect(plan.workItems.map((item) => item.key)).toEqual([ 'bangumi:episode:[".",1,1]', @@ -111,6 +112,7 @@ describe("Bangumi episodic directory convention", () => { const interpreter = new BangumiEpisodicDirectoryInterpreter(); const media = "Season 1/葬送的芙莉莲 - S01E01 - 第 1 集.mp4"; const clean = interpreter.interpret(snapshot([media])); + assert("workItems" in clean); const withSidecars = interpreter.interpret( snapshot([ media, @@ -121,6 +123,7 @@ describe("Bangumi episodic directory convention", () => { "Season 1/葬送的芙莉莲 - S01E01 - 第 1 集.nfo", ]), ); + assert("workItems" in withSidecars); expect(withSidecars.workItems).toHaveLength(1); expect(withSidecars.workItems[0]).toEqual(clean.workItems[0]); @@ -141,6 +144,7 @@ describe("Bangumi episodic directory convention", () => { const plan = new BangumiEpisodicDirectoryInterpreter().interpret( snapshot(["Season 2/葬送的芙莉莲 - S02E01 - 第 1 集.mkv"]), ); + assert("workItems" in plan); const evidence = plan.workItems[0]!.evidence; const records = [ record("s1", "bangumi.subject:400602", { @@ -192,6 +196,7 @@ describe("Bangumi episodic directory convention", () => { evidence, sourceRecords: records, }); + assert.isDefined(batch); const proposal = new BangumiEpisodicRecognizer().recognize({ evidence, basis: { kind: "source-interpretation", interpretation: batch }, @@ -265,9 +270,11 @@ describe("Bangumi episodic directory convention", () => { }); it("projects every official relation along one unique compatible Season 3 path", () => { - const evidence = new BangumiEpisodicDirectoryInterpreter().interpret( + const plannedWork1 = new BangumiEpisodicDirectoryInterpreter().interpret( snapshot(["Season 3/葬送的芙莉莲 - S03E01.mkv"]), - ).workItems[0]!.evidence; + ); + assert("workItems" in plannedWork1); + const evidence = plannedWork1.workItems[0]!.evidence; const records = [ record("s1", "bangumi.subject:400602", { id: 400602, @@ -326,6 +333,7 @@ describe("Bangumi episodic directory convention", () => { evidence, sourceRecords: records, }); + assert.isDefined(batch); expect(batch.relationships).toEqual( expect.arrayContaining([ @@ -340,9 +348,11 @@ describe("Bangumi episodic directory convention", () => { }); it("leaves a branching sequel path unresolved instead of using response order", () => { - const evidence = new BangumiEpisodicDirectoryInterpreter().interpret( + const plannedWork2 = new BangumiEpisodicDirectoryInterpreter().interpret( snapshot(["Season 2/葬送的芙莉莲 - S02E01.mkv"]), - ).workItems[0]!.evidence; + ); + assert("workItems" in plannedWork2); + const evidence = plannedWork2.workItems[0]!.evidence; const records = [ record("s1", "bangumi.subject:400602", { id: 400602, @@ -366,9 +376,11 @@ describe("Bangumi episodic directory convention", () => { }); it("filters incompatible captured sequel candidates before requiring one path", () => { - const evidence = new BangumiEpisodicDirectoryInterpreter().interpret( + const plannedWork3 = new BangumiEpisodicDirectoryInterpreter().interpret( snapshot(["Season 2/葬送的芙莉莲 - S02E01.mkv"]), - ).workItems[0]!.evidence; + ); + assert("workItems" in plannedWork3); + const evidence = plannedWork3.workItems[0]!.evidence; const records = [ record("s1", "bangumi.subject:400602", { id: 400602, @@ -461,10 +473,12 @@ describe("Bangumi episodic directory convention", () => { return httpResponse(JSON.stringify(body), "application/json"); }, }); - const evidence = new BangumiEpisodicDirectoryInterpreter().interpret({ + const plannedWork4 = new BangumiEpisodicDirectoryInterpreter().interpret({ ...snapshot(["Season 1/终将成为你 - S01E01.mkv"]), root: "/corpus/终将成为你 (2018)", - }).workItems[0]!.evidence[0]!; + }); + assert("workItems" in plannedWork4); + const evidence = plannedWork4.workItems[0]!.evidence[0]!; const result = await collector.collect(evidence); expect(result.failures).toEqual([]); expect(requested).toEqual( @@ -546,9 +560,11 @@ describe("Bangumi episodic directory convention", () => { return httpResponse(JSON.stringify(body), "application/json"); }, }); - const evidence = new BangumiEpisodicDirectoryInterpreter().interpret( + const plannedWork5 = new BangumiEpisodicDirectoryInterpreter().interpret( snapshot(["Season 3/葬送的芙莉莲 - S03E01.mkv"]), - ).workItems[0]!.evidence[0]!; + ); + assert("workItems" in plannedWork5); + const evidence = plannedWork5.workItems[0]!.evidence[0]!; const result = await collector.collect(evidence); @@ -621,9 +637,11 @@ describe("Bangumi episodic directory convention", () => { return httpResponse(JSON.stringify(body), "application/json"); }, }); - const evidence = new BangumiEpisodicDirectoryInterpreter().interpret( + const plannedWork6 = new BangumiEpisodicDirectoryInterpreter().interpret( snapshot(["Season 4/葬送的芙莉莲 - S04E01.mkv"]), - ).workItems[0]!.evidence[0]!; + ); + assert("workItems" in plannedWork6); + const evidence = plannedWork6.workItems[0]!.evidence[0]!; const result = await collector.collect(evidence); @@ -721,10 +739,12 @@ describe("Bangumi episodic directory convention", () => { return httpResponse(JSON.stringify(body), "application/json"); }, }); - const evidence = new BangumiEpisodicDirectoryInterpreter().interpret({ + const plannedWork7 = new BangumiEpisodicDirectoryInterpreter().interpret({ ...snapshot(["Season 1/终将成为你 - S01E01.mkv"]), root: "/corpus/终将成为你 (2018)", - }).workItems[0]!.evidence[0]!; + }); + assert("workItems" in plannedWork7); + const evidence = plannedWork7.workItems[0]!.evidence[0]!; const result = await collector.collect(evidence); expect(result.records.map((item) => item.coverage.id)).toEqual([ "bangumi.subject-search", diff --git a/sources/bangumi/tsconfig.json b/sources/bangumi/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/bangumi/tsconfig.json +++ b/sources/bangumi/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/bangumi/turbo.json b/sources/bangumi/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/bangumi/turbo.json +++ b/sources/bangumi/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/dlsite/package.json b/sources/dlsite/package.json index 4da6697..615c8b8 100644 --- a/sources/dlsite/package.json +++ b/sources/dlsite/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/dlsite", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/dlsite/test/capabilities.test.ts b/sources/dlsite/test/capabilities.test.ts index 7a52e52..2a34127 100644 --- a/sources/dlsite/test/capabilities.test.ts +++ b/sources/dlsite/test/capabilities.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { DlsiteArtifactAcquirer, @@ -453,6 +453,7 @@ describe("DLsite collection and interpretation", () => { evidence: [evidence], sourceRecords: [json, html], }); + assert.isDefined(batch); const proposal = new DlsiteExactRecognizer().recognize({ evidence: [evidence], basis: { kind: "source-interpretation", interpretation: batch }, @@ -635,6 +636,7 @@ describe("DLsite collection and interpretation", () => { evidence: [evidence], sourceRecords: [json, html], }); + assert.isDefined(batch); expect(batch.metadata).toContainEqual( expect.objectContaining({ @@ -722,6 +724,7 @@ describe("DLsite collection and interpretation", () => { evidence: [evidence], sourceRecords: [json, html], }); + assert.isDefined(batch); expect(batch.metadata).toEqual( expect.arrayContaining([ @@ -821,6 +824,7 @@ describe("DLsite collection and interpretation", () => { evidence: [evidence], sourceRecords: [json, html], }); + assert.isDefined(batch); expect(batch.entities).toEqual([]); expect(batch.entityMentions).toEqual([]); @@ -1025,6 +1029,7 @@ describe("DLsite collection and interpretation", () => { }, ], }); + assert.isDefined(batch); expect( batch.entities?.find( (entity) => entity.identityNamespace === "dlsite.circle", diff --git a/sources/dlsite/tsconfig.json b/sources/dlsite/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/dlsite/tsconfig.json +++ b/sources/dlsite/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/dlsite/turbo.json b/sources/dlsite/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/dlsite/turbo.json +++ b/sources/dlsite/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/epub/package.json b/sources/epub/package.json index 789ace1..3d81261 100644 --- a/sources/epub/package.json +++ b/sources/epub/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/epub", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/epub/test/epub.test.ts b/sources/epub/test/epub.test.ts index 788ea68..f9c3e58 100644 --- a/sources/epub/test/epub.test.ts +++ b/sources/epub/test/epub.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { deflateRawSync } from "node:zlib"; import type { CapabilityCoordinate } from "@digital/domain"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { EpubEmbeddedCoverAcquirer, @@ -14,6 +14,14 @@ import { function byteSource(bytes: Uint8Array, logicalLocation: string) { return { logicalLocation, + byteLength: bytes.byteLength, + modifiedAt: "2026-07-29T00:00:00.000Z", + async readChunk(offset: number, maximumBytes: number) { + return { + bytes: bytes.slice(offset, offset + maximumBytes), + done: offset + maximumBytes >= bytes.byteLength, + }; + }, async read() { return bytes; }, @@ -124,6 +132,7 @@ describe("EPUB publication inspection", () => { const prefixed = await inspect( `ISBN-13: 978-0-306-40615-7`, ); + assert.isDefined(prefixed); const garbage = await inspect( `garbage9780306406157suffix`, ); @@ -150,7 +159,9 @@ describe("EPUB publication inspection", () => { conflictIsbnFirst, conflictIsbnLast, ]) { + assert.isDefined(rejected); expect(rejected.inspection?.identifiers).toEqual([]); + assert.isDefined(rejected); expect( new EpubLocalPublicationRecognizer().canRecognize( observedContext(rejected), @@ -292,10 +303,12 @@ describe("EPUB publication inspection", () => { observedArtifactReference: "revision-epub2", byteSource: byteSource(epub2, "publication.epub"), }); + assert.isDefined(epub2Evidence); const [epub3Evidence] = await inspector.inspect({ observedArtifactReference: "revision-epub3", byteSource: byteSource(epub3, "publication.epub"), }); + assert.isDefined(epub3Evidence); expect(epub2Evidence?.inspection?.metadata).toEqual( expect.arrayContaining([ @@ -407,7 +420,9 @@ describe("EPUB publication inspection", () => { }); const epub2Part = epub2Evidence.inspection!.parts[0]; + assert.isDefined(epub2Part); const epub3Part = epub3Evidence.inspection!.parts[0]; + assert.isDefined(epub3Part); expect(epub2Part.locator).toBe( "epub:OPS/book.opf#/metadata/meta[@name='cover'][@content='cover2']->manifest/item[@id='cover2']->entry:OPS/images/cover.jpg", ); @@ -530,6 +545,7 @@ describe("EPUB publication inspection", () => { byteSource: byteSource(fixture(""), "local.epub"), }) )[0]; + assert.isDefined(noIsbn); const withIsbn = ( await inspector.inspect({ observedArtifactReference: "revision-official", @@ -541,6 +557,7 @@ describe("EPUB publication inspection", () => { ), }) )[0]; + assert.isDefined(withIsbn); const conflicting = ( await inspector.inspect({ observedArtifactReference: "revision-conflict", @@ -552,6 +569,7 @@ describe("EPUB publication inspection", () => { ), }) )[0]; + assert.isDefined(conflicting); const recognizer = new EpubLocalPublicationRecognizer(); const localContext = observedContext(noIsbn); @@ -586,6 +604,7 @@ describe("EPUB publication inspection", () => { proposal.ruleResults.some((rule) => "identities" in rule), ).toBe(false); } + assert.isDefined(localContext.basis.artifactRevisions[0]); for (const forged of forgedCoordinates(noIsbn.producer)) { expect( recognizer.canRecognize({ diff --git a/sources/epub/tsconfig.json b/sources/epub/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/epub/tsconfig.json +++ b/sources/epub/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/epub/turbo.json b/sources/epub/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/epub/turbo.json +++ b/sources/epub/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/fanbox/package.json b/sources/fanbox/package.json index 184840c..0091420 100644 --- a/sources/fanbox/package.json +++ b/sources/fanbox/package.json @@ -28,7 +28,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/fanbox", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/fanbox/test/browser-product-assembly.test.ts b/sources/fanbox/test/browser-product-assembly.test.ts index b10ca4b..b54d17e 100644 --- a/sources/fanbox/test/browser-product-assembly.test.ts +++ b/sources/fanbox/test/browser-product-assembly.test.ts @@ -2,7 +2,7 @@ import { rm, stat } from "node:fs/promises"; import { setImmediate as yieldToFilesystem } from "node:timers/promises"; import { knownSourceAssociationEvidenceCapability } from "@digital/application"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { assert, beforeEach, describe, expect, it, vi } from "vitest"; const renderer = vi.hoisted(() => ({ launches: 0, @@ -156,43 +156,37 @@ vi.mock("playwright", () => ({ ) { const previousFetch = globalThis.fetch; let reads = 0; - globalThis.fetch = (async () => ({ - status: 403, - headers: { - get: (name: string) => - name.toLowerCase() === "content-type" - ? "text/html" - : null, - }, - body: { - async cancel() { - renderer.challengeCancels += 1; - }, - getReader: () => ({ - async read() { - reads += 1; - if (reads === 1) - return { - done: false, - value: new TextEncoder().encode( - renderer.challengeMarker, - ), - }; - if (reads === 2) - return { - done: false, - value: new TextEncoder().encode( - "body-must-not-cross-seam", - ), - }; - return { done: true, value: undefined }; - }, - async cancel() { - renderer.challengeCancels += 1; + globalThis.fetch = async () => + new Response( + new ReadableStream( + { + pull(controller) { + reads += 1; + if (reads === 1) + controller.enqueue( + new TextEncoder().encode( + renderer.challengeMarker, + ), + ); + else if (reads === 2) + controller.enqueue( + new TextEncoder().encode( + "body-must-not-cross-seam", + ), + ); + else controller.close(); + }, + cancel() { + renderer.challengeCancels += 1; + }, }, - }), - }, - })) as typeof fetch; + { highWaterMark: 0 }, + ), + { + status: 403, + headers: { "content-type": "text/html" }, + }, + ); try { return await callback(input); } finally { @@ -598,6 +592,7 @@ describe("FANBOX browser product assembly", () => { }), ).rejects.toThrow("FANBOX 来源连接验证失败"); expect(renderer.serverKills).toBe(1); + assert.isDefined(renderer.launchOptions!.env.HOME); await expect(stat(renderer.launchOptions!.env.HOME)).rejects.toMatchObject({ code: "ENOENT", }); diff --git a/sources/fanbox/test/capabilities.test.ts b/sources/fanbox/test/capabilities.test.ts index a498c97..6d0daf9 100644 --- a/sources/fanbox/test/capabilities.test.ts +++ b/sources/fanbox/test/capabilities.test.ts @@ -3,7 +3,7 @@ import { type SourceCollectionExecutionContext, type SourceCollector, } from "@digital/application"; -import { describe, expect, it, vi } from "vitest"; +import { assert, describe, expect, it, vi } from "vitest"; import { FanboxSessionTransportFault, @@ -267,12 +267,14 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); expect(result.failures[0]).toMatchObject({ failureCode: "transport-failure", }); + assert.isDefined(result.failures[0]); expect( decodeFanboxCollectionFailureCode({ capability: result.failures[0].capability, @@ -380,6 +382,7 @@ describe("FANBOX official collection", () => { }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); expect(result.failures).toEqual([]); @@ -388,6 +391,7 @@ describe("FANBOX official collection", () => { "fanbox.post-detail", ]); expect(result.records).toHaveLength(2); + assert.isDefined(result.records[1]); expect(JSON.parse(result.records[1].body)).toMatchObject({ body: { post: { @@ -465,6 +469,7 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); await expect(collector.collect(evidence)).resolves.toMatchObject({ records: [], @@ -505,6 +510,7 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -529,6 +535,7 @@ describe("FANBOX official collection", () => { encodeURIComponent(secret), ]; const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); for (const reflected of representations) { const collector = scriptedCollector({ @@ -570,6 +577,7 @@ describe("FANBOX official collection", () => { const evidence = directoryEvidence( "/library/customudon/25.01.01 post", )[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -598,6 +606,7 @@ describe("FANBOX official collection", () => { }), }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); await expect(collector.collect(evidence)).resolves.toMatchObject({ records: [], @@ -816,6 +825,7 @@ describe("FANBOX official collection", () => { ), }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -951,6 +961,7 @@ describe("FANBOX official collection", () => { evidence: [evidence], sourceRecords: result.records, }); + assert.isDefined(batch); expect(result.failures).toEqual([]); expect(result.records).toHaveLength(2); @@ -1073,6 +1084,7 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1125,6 +1137,7 @@ describe("FANBOX official collection", () => { it("retains a valid summary but no fabricated detail record when the protected endpoint is structurally malformed", async () => { const details = [{ body: null }]; const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); for (const detail of details) { const collector = scriptedCollector({ @@ -1191,6 +1204,7 @@ describe("FANBOX official collection", () => { }, ]; const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); for (const detail of details) { const collector = scriptedCollector({ @@ -1258,6 +1272,7 @@ describe("FANBOX official collection", () => { ), }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1306,6 +1321,7 @@ describe("FANBOX official collection", () => { ), }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1353,6 +1369,7 @@ describe("FANBOX official collection", () => { ), }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1621,6 +1638,7 @@ describe("FANBOX official collection", () => { ), }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1665,6 +1683,7 @@ describe("FANBOX official collection", () => { }), }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1685,6 +1704,7 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1740,12 +1760,14 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/24.03.07 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); const [batch] = new FanboxSourceInterpreter().interpret({ evidence: [evidence], sourceRecords: result.records, }); + assert.isDefined(batch); expect(requests).toBe(2); expect(result.failures).toEqual([]); @@ -1801,6 +1823,7 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -1863,6 +1886,7 @@ describe("FANBOX official collection", () => { }, }); const evidence = directoryEvidence("/library/customudon/25.01.01 post")[0]; + assert.isDefined(evidence); const result = await collector.collect(evidence); @@ -2094,10 +2118,12 @@ describe("FANBOX interpretation", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); const [reinterpreted] = new FanboxSourceInterpreter().interpret({ evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(reinterpreted); expect( batch.metadata @@ -2219,6 +2245,7 @@ describe("FANBOX interpretation", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); return batch.sourceArtifacts.map((item) => [ item.selectionEvidence?.suggestedFilename, item.selectionEvidence?.sourceOrdinal, @@ -2251,6 +2278,7 @@ describe("FANBOX interpretation", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); return { ordinals: batch.sourceArtifacts.map((item) => [ item.selectionEvidence?.suggestedFilename, @@ -2478,6 +2506,7 @@ describe("FANBOX interpretation", () => { true, ); const [batch] = interpreter.interpret({ evidence, sourceRecords: records }); + assert.isDefined(batch); const proposal = new FanboxExactRecognizer().recognize({ evidence, basis: { kind: "source-interpretation", interpretation: batch }, @@ -2555,6 +2584,7 @@ describe("FANBOX interpretation", () => { evidence, sourceRecords: records, }); + assert.isDefined(batch); const proposal = new FanboxExactRecognizer().recognize({ evidence, basis: { kind: "source-interpretation", interpretation: batch }, @@ -2667,6 +2697,7 @@ describe("FANBOX interpretation", () => { const evidence = directoryEvidence("/library/customudon/20.12.29 post"); const [batch] = interpreter.interpret({ evidence, sourceRecords: records }); + assert.isDefined(batch); expect(batch.sourceArtifacts).toEqual([ expect.objectContaining({ @@ -2719,6 +2750,7 @@ describe("FANBOX interpretation", () => { const evidence = directoryEvidence("/library/customudon/25.01.01 post"); const [batch] = interpreter.interpret({ evidence, sourceRecords: records }); + assert.isDefined(batch); expect( batch.sourceArtifacts.some((item) => item.role === "asset.primary-cover"), @@ -2753,6 +2785,7 @@ describe("FANBOX interpretation", () => { const evidence = directoryEvidence("/library/customudon/25.01.01 post"); const [batch] = interpreter.interpret({ evidence, sourceRecords: records }); + assert.isDefined(batch); expect(batch.metadata).toEqual([]); }); @@ -2779,6 +2812,7 @@ describe("FANBOX interpretation", () => { const evidence = directoryEvidence("/library/customudon/20.12.29 post"); const [batch] = interpreter.interpret({ evidence, sourceRecords: records }); + assert.isDefined(batch); expect( batch.metadata.some((item) => item.property === "asset.summary"), diff --git a/sources/fanbox/test/tsconfig.json b/sources/fanbox/test/tsconfig.json deleted file mode 100644 index e516b7f..0000000 --- a/sources/fanbox/test/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["**/*.ts"] -} diff --git a/sources/fanbox/tsconfig.json b/sources/fanbox/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/fanbox/tsconfig.json +++ b/sources/fanbox/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/fanbox/turbo.json b/sources/fanbox/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/fanbox/turbo.json +++ b/sources/fanbox/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/kadokawa-jp/package.json b/sources/kadokawa-jp/package.json index b373181..31d0036 100644 --- a/sources/kadokawa-jp/package.json +++ b/sources/kadokawa-jp/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/kadokawa-jp", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/kadokawa-jp/test/capabilities.test.ts b/sources/kadokawa-jp/test/capabilities.test.ts index c9a24ce..4ba7e4e 100644 --- a/sources/kadokawa-jp/test/capabilities.test.ts +++ b/sources/kadokawa-jp/test/capabilities.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createKadokawaJapanSource } from "../src/index.ts"; @@ -119,6 +119,8 @@ describe("KADOKAWA 日本来源", () => { `https://www.kadokawa.co.jp/product/${first}/`, `https://www.kadokawa.co.jp/product/${second}/`, ]); + assert.isDefined(collected.records[2]); + assert.isDefined(batches[1]); expect(batches[1].metadata).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -366,6 +368,7 @@ describe("KADOKAWA 日本来源", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); expect( source.associationInterpreter.canInterpret({ evidence, @@ -373,6 +376,7 @@ describe("KADOKAWA 日本来源", () => { }), ).toBe(true); const candidate = batch.sourceArtifacts[0]; + assert.isDefined(candidate); await expect( source.artifactAcquirer.acquire(candidate), ).resolves.toMatchObject({ mediaType: "image/webp" }); @@ -411,6 +415,7 @@ describe("KADOKAWA 日本来源", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); expect(batch.metadata).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -456,6 +461,7 @@ describe("KADOKAWA 日本来源", () => { evidence: [driftEvidence], sourceRecords: driftCollected.records, }); + assert.isDefined(driftBatch); expect(driftBatch.metadata.map((fact) => fact.property)).not.toEqual( expect.arrayContaining(["book.contributor", "book.contributor-role"]), ); @@ -479,6 +485,7 @@ describe("KADOKAWA 日本来源", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); const directed = collector.sourceAddressing.normalize({ isbn }); if (directed.status !== "accepted") throw new Error("expected ISBN input"); expect( @@ -523,6 +530,7 @@ describe("KADOKAWA 日本来源", () => { }), ).toBe(false); const candidate = batch.sourceArtifacts[0]; + assert.isDefined(candidate); for (const failed of [ response("", candidate.acquisitionReference, "image/webp", 500), response("", candidate.acquisitionReference, "image/webp"), @@ -563,6 +571,7 @@ describe("KADOKAWA 日本来源", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); expect(batch.metadata).toEqual( expect.arrayContaining([ expect.objectContaining({ property: "book.isbn-13", value: isbn }), @@ -592,6 +601,8 @@ describe("KADOKAWA 日本来源", () => { metadata: [ { property: "book.isbn-13", + sourceSlot: "book.isbn-13", + target: { kind: "entity" as const, entityId: "asset" }, value: "9789577435064", sourceRecordIds: ["existing"], }, diff --git a/sources/kadokawa-jp/tsconfig.json b/sources/kadokawa-jp/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/kadokawa-jp/tsconfig.json +++ b/sources/kadokawa-jp/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/kadokawa-jp/turbo.json b/sources/kadokawa-jp/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/kadokawa-jp/turbo.json +++ b/sources/kadokawa-jp/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/nbinet/package.json b/sources/nbinet/package.json index 1beb63e..2323475 100644 --- a/sources/nbinet/package.json +++ b/sources/nbinet/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/nbinet", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/nbinet/test/nbinet.test.ts b/sources/nbinet/test/nbinet.test.ts index b051993..3fcd560 100644 --- a/sources/nbinet/test/nbinet.test.ts +++ b/sources/nbinet/test/nbinet.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import type { SourceEnrichmentInput } from "@digital/application"; +import type { RecognitionEvidence } from "@digital/domain"; +import { assert, describe, expect, it } from "vitest"; import { NbinetCollector, @@ -52,7 +54,7 @@ const evidence = [ ], }, }, -]; +] satisfies [RecognitionEvidence]; describe("NBINet exact ISBN source", () => { it("declares and normalizes explicit ISBN input without EPUB evidence", () => { @@ -101,7 +103,8 @@ describe("NBINet exact ISBN source", () => { requested.push(address); return response({ docs: [] }); }); - const conflict = { + assert.isDefined(evidence[0]); + const conflict: RecognitionEvidence = { ...evidence[0], inspection: { ...evidence[0].inspection, @@ -120,6 +123,7 @@ describe("NBINet exact ISBN source", () => { }, }; + assert.isDefined(evidence[0]); expect(collector.canCollect(evidence[0])).toBe(true); expect(collector.canCollect(conflict)).toBe(false); await expect(collector.collect(conflict)).resolves.toMatchObject({ @@ -127,6 +131,7 @@ describe("NBINet exact ISBN source", () => { failures: [{ reason: expect.stringContaining("唯一有效 ISBN") }], }); expect(requested).toEqual([]); + assert.isDefined(evidence[0]); await collector.collect(evidence[0]); expect(requested).toEqual([ expect.stringMatching( @@ -150,7 +155,7 @@ describe("NBINet exact ISBN source", () => { metadata: [], sourceArtifacts: [], }; - const subject = { + const subject: SourceEnrichmentInput = { subjectId: "asset", recognitionDecisionId: "decision", sourceAssociations: [], @@ -189,6 +194,7 @@ describe("NBINet exact ISBN source", () => { interpretation, }), ).toMatchObject({ status: "accepted" }); + assert.isDefined(subject.metadata[0]); // 现行契约:只有明确指向实体的身份证据参与冲突判断,修订版本地的同值事实不冒充实体冲突。 expect( association.interpret({ @@ -213,6 +219,7 @@ describe("NBINet exact ISBN source", () => { it("keeps a matching identifier boundary separate from another local inspection", () => { const recognizer = new NbinetExactIsbnRecognizer(); + assert.isDefined(evidence[0]); const proposal = recognizer.recognize({ evidence: [ evidence[0], @@ -245,6 +252,7 @@ describe("NBINet exact ISBN source", () => { }, }, }); + assert.isDefined(proposal.ruleResults[0]); expect(proposal.ruleResults[0].evidence).toEqual([evidence[0]]); }); @@ -268,12 +276,14 @@ describe("NBINet exact ISBN source", () => { info: { total: 1 }, }), ); + assert.isDefined(evidence[0]); const official = await collector.collect(evidence[0]); const interpreter = new NbinetSourceInterpreter(); const [batch] = interpreter.interpret({ evidence, sourceRecords: official.records, }); + assert.isDefined(batch); const proposal = new NbinetExactIsbnRecognizer().recognize({ evidence, basis: { kind: "source-interpretation", interpretation: batch }, @@ -327,9 +337,11 @@ describe("NBINet exact ISBN source", () => { expect(proposal.ruleResults[0]).toMatchObject({ outcome: "satisfied" }); expect(Object.keys(proposal).toSorted()).toEqual(["id", "ruleResults"]); expect(batch.sourceArtifacts).toEqual([]); + const officialRecord = official.records[0]; + assert.isDefined(officialRecord); expect( batch.metadata.every( - (item) => item.sourceRecordIds[0] === official.records[0].id, + (item) => item.sourceRecordIds[0] === officialRecord.id, ), ).toBe(true); }); @@ -351,6 +363,8 @@ describe("NBINet exact ISBN source", () => { info: { total: 1 }, }), ); + assert.isDefined(evidence[0]); + assert.isDefined(evidence[0].inspection.identifiers[0]); const isbnEvidence = { ...evidence[0], inspection: { @@ -364,10 +378,12 @@ describe("NBINet exact ISBN source", () => { }, }; const [record] = (await collector.collect(isbnEvidence)).records; + assert.isDefined(record); const [batch] = new NbinetSourceInterpreter().interpret({ evidence: [isbnEvidence], sourceRecords: [record], }); + assert.isDefined(batch); expect( batch.metadata @@ -425,10 +441,12 @@ describe("NBINet exact ISBN source", () => { it("rejects incomplete or forged producer coordinates at every NBINet trust boundary", () => { const collector = new NbinetCollector(async () => response({ docs: [] })); + assert.isDefined(evidence[0]); const { implementationVersion: _missingImplementation, ...missingImplementation } = evidence[0].producer; + assert.isDefined(evidence[0]); expect( collector.canCollect({ ...evidence[0], @@ -447,6 +465,7 @@ describe("NBINet exact ISBN source", () => { ...record, producer: { ...record.producer, contractVersion: "forged" }, }; + assert.isDefined(evidence[0]); const { interpretationContractVersions: _missingInterpretationContract, ...missingInterpretationContract @@ -472,6 +491,7 @@ describe("NBINet exact ISBN source", () => { evidence, sourceRecords: [record], }); + assert.isDefined(batch); const recognizer = new NbinetExactIsbnRecognizer(); const forgedInterpretation = { ...batch, diff --git a/sources/nbinet/tsconfig.json b/sources/nbinet/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/nbinet/tsconfig.json +++ b/sources/nbinet/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/nbinet/turbo.json b/sources/nbinet/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/nbinet/turbo.json +++ b/sources/nbinet/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/omdb/package.json b/sources/omdb/package.json index 9a44506..0daf380 100644 --- a/sources/omdb/package.json +++ b/sources/omdb/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/omdb", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/omdb/test/capabilities.test.ts b/sources/omdb/test/capabilities.test.ts index e681a6a..0bbeaa5 100644 --- a/sources/omdb/test/capabilities.test.ts +++ b/sources/omdb/test/capabilities.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { prerequisiteUnavailableFailure } from "@digital/application"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { OmdbArtifactAcquirer, @@ -47,18 +47,21 @@ const input = { sourceSlot: "catalogue-a.imdb-id", value: "tt1234567", sourceRecordIds: ["catalogue-a-record"], + target: { kind: "entity", entityId: "asset-1" }, }, { property: "asset.title", sourceSlot: "catalogue-a.title", value: "Example Film", sourceRecordIds: ["catalogue-a-record"], + target: { kind: "entity", entityId: "asset-1" }, }, { property: "asset.release-date", sourceSlot: "catalogue-a.release-date", value: "2004-01-01", sourceRecordIds: ["catalogue-a-record"], + target: { kind: "entity", entityId: "asset-1" }, }, ], informationRequirement: { @@ -99,6 +102,7 @@ async function associationOutcome( evidence: [evidence], sourceRecords: collection.records, }); + assert.isDefined(interpretation); return new OmdbSourceAssociationInterpreter().interpret({ subject, evidence, @@ -108,9 +112,12 @@ async function associationOutcome( describe("OMDb film source enrichment", () => { it("does not propose or validate enrichment without a recognition source", () => { + const { + recognitionSourceAssociation: _recognitionSourceAssociation, + ...observedInput + } = input; const observedSubject = { - ...input, - recognitionSourceAssociation: undefined, + ...observedInput, sourceAssociations: [], }; const evidence = { @@ -219,6 +226,7 @@ describe("OMDb film source enrichment", () => { sourceRecords: collection.records, }); + assert.isDefined(interpretation); expect( associationInterpreter.interpret({ subject: input, @@ -296,6 +304,7 @@ describe("OMDb film source enrichment", () => { evidence: [evidence], sourceRecords: result.records, }); + assert.isDefined(interpretation); expect(result.failures).toEqual([]); expect(evidence).toMatchObject({ @@ -394,6 +403,7 @@ describe("OMDb film source enrichment", () => { sourceRecords: result.records, }); + assert.isDefined(interpretation); expect(result.records).toHaveLength(1); expect(result.failures).toEqual([]); expect( @@ -464,6 +474,7 @@ describe("OMDb film source enrichment", () => { expect(result.failures).toEqual([]); expect(result.records).toHaveLength(1); const record = result.records[0]; + assert.isDefined(record); expect(record.body).toContain("[REDACTED]"); expect(record.body).not.toContain(apiKey); expect(record.mediaType).toBe("application/json"); @@ -555,6 +566,7 @@ describe("OMDb film source enrichment", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(interpretation); expect(collected.failures).toEqual([]); expect(interpretation).toMatchObject({ diff --git a/sources/omdb/tsconfig.json b/sources/omdb/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/omdb/tsconfig.json +++ b/sources/omdb/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/omdb/turbo.json b/sources/omdb/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/omdb/turbo.json +++ b/sources/omdb/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/pdf/package.json b/sources/pdf/package.json index 8384bb6..73606cf 100644 --- a/sources/pdf/package.json +++ b/sources/pdf/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/pdf", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/pdf/test/pdf.test.ts b/sources/pdf/test/pdf.test.ts index 63532ce..023635d 100644 --- a/sources/pdf/test/pdf.test.ts +++ b/sources/pdf/test/pdf.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { createCanvas, loadImage } from "@napi-rs/canvas"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createPdfRecognitionCapabilities } from "../src/index.ts"; @@ -323,6 +323,7 @@ describe("PDF 本地格式能力", () => { "single.pdf", ), }); + assert.isDefined(single); const [multiple] = await formatInspectors[0].inspect({ observedArtifactReference: "multiple-isbn", byteSource: byteSource( @@ -334,6 +335,7 @@ describe("PDF 本地格式能力", () => { "multiple.pdf", ), }); + assert.isDefined(multiple); const [infoOnly] = await formatInspectors[0].inspect({ observedArtifactReference: "info-title", byteSource: byteSource( @@ -345,6 +347,7 @@ describe("PDF 本地格式能力", () => { "info-title.pdf", ), }); + assert.isDefined(infoOnly); const [matchingTitles] = await formatInspectors[0].inspect({ observedArtifactReference: "matching-title", byteSource: byteSource( @@ -357,6 +360,7 @@ describe("PDF 本地格式能力", () => { "matching-title.pdf", ), }); + assert.isDefined(matchingTitles); expect(single.inspection!.identifiers).toEqual([ { @@ -429,6 +433,7 @@ describe("PDF 本地格式能力", () => { "structured.pdf", ), }); + assert.isDefined(evidence); const [directTitle] = await formatInspectors[0].inspect({ observedArtifactReference: "direct-title", byteSource: byteSource( @@ -441,6 +446,7 @@ describe("PDF 本地格式能力", () => { "direct-title.pdf", ), }); + assert.isDefined(directTitle); expect(evidence.inspection!.identifiers).toEqual([ { @@ -475,7 +481,9 @@ describe("PDF 本地格式能力", () => { observedArtifactReference: "cover.pdf", byteSource: byteSource(bytes, "cover.pdf"), }); + assert.isDefined(evidence); const part = evidence.inspection!.parts[0]; + assert.isDefined(part); const candidate = { ...part, id: "pdf-cover", @@ -524,6 +532,7 @@ describe("PDF 本地格式能力", () => { function byteSource(bytes: Uint8Array, logicalLocation: string) { return { byteLength: bytes.byteLength, + modifiedAt: "2026-07-29T00:00:00.000Z", logicalLocation, readChunk: async (offset: number, maximumBytes: number) => ({ bytes: bytes.slice(offset, offset + maximumBytes), @@ -655,6 +664,7 @@ function serializePdf( ): Uint8Array { const chunks: Uint8Array[] = [ascii("%PDF-1.7\n")]; const offsets = [0]; + assert.isDefined(chunks[0]); let length = chunks[0].byteLength; for (const [index, object] of objects.entries()) { offsets.push(length); diff --git a/sources/pdf/tsconfig.json b/sources/pdf/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/pdf/tsconfig.json +++ b/sources/pdf/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/pdf/turbo.json b/sources/pdf/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/pdf/turbo.json +++ b/sources/pdf/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/taiwan-kadokawa/package.json b/sources/taiwan-kadokawa/package.json index a2736c6..d4b4052 100644 --- a/sources/taiwan-kadokawa/package.json +++ b/sources/taiwan-kadokawa/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/taiwan-kadokawa", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/taiwan-kadokawa/test/capabilities.test.ts b/sources/taiwan-kadokawa/test/capabilities.test.ts index b5d1c40..7493077 100644 --- a/sources/taiwan-kadokawa/test/capabilities.test.ts +++ b/sources/taiwan-kadokawa/test/capabilities.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createTaiwanKadokawaSource } from "../src/index.ts"; @@ -67,10 +67,7 @@ function response( }, arrayBuffer: async () => { if (options.bodyReadFails) throw new Error("讀取中斷"); - return bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ); + return bytes.slice().buffer; }, }; } @@ -233,6 +230,7 @@ describe("台灣角川 ISBN 產品頁來源", () => { page({ products: [{ ...product, sku: "" }] }), page().replace('{"@type":"WebSite","name":"台灣角川"}', "{"), ]; + assert.isDefined(record); for (const body of invalidBodies) expect(() => source.interpreter.interpret({ @@ -252,10 +250,13 @@ describe("台灣角川 ISBN 產品頁來源", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); + assert.isDefined(collected.records[0]); expect(batch).toMatchObject({ sourceAddress: address, sourceRecordIds: [collected.records[0].id], }); + assert.isDefined(collected.records[0]); expect(batch.metadata).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -304,6 +305,7 @@ describe("台灣角川 ISBN 產品頁來源", () => { }), ]), ); + assert.isDefined(collected.records[0]); expect(batch.sourceArtifacts).toEqual([ expect.objectContaining({ role: "asset.primary-cover", @@ -329,6 +331,7 @@ describe("台灣角川 ISBN 產品頁來源", () => { }, ], }); + assert.isDefined(withoutDerived); expect(malformed.records).toHaveLength(1); expect(withoutDerived.metadata.map((fact) => fact.property)).not.toEqual( expect.arrayContaining([ @@ -355,6 +358,7 @@ describe("台灣角川 ISBN 產品頁來源", () => { }, ], }); + assert.isDefined(withoutDescription); expect(withoutDescription.metadata).toEqual( expect.arrayContaining([ expect.objectContaining({ property: "book.isbn-13", value: isbn }), @@ -417,6 +421,7 @@ describe("台灣角川 ISBN 產品頁來源", () => { evidence: [normalized.evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); expect(batch).toMatchObject({ sourceAddress: expectedAddress }); expect(batch.metadata).toEqual( expect.arrayContaining([ @@ -452,6 +457,7 @@ describe("台灣角川 ISBN 產品頁來源", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); const subject = { subjectId: "asset", recognitionDecisionId: "decision", @@ -479,6 +485,8 @@ describe("台灣角川 ISBN 產品頁來源", () => { metadata: [ { property: "book.isbn-13", + sourceSlot: "book.isbn-13", + target: { kind: "entity", entityId: "asset" }, value: isbn, sourceRecordIds: ["local"], }, @@ -495,6 +503,8 @@ describe("台灣角川 ISBN 產品頁來源", () => { metadata: [ { property: "book.isbn-13", + sourceSlot: "book.isbn-13", + target: { kind: "entity", entityId: "asset" }, value: "9789577439680", sourceRecordIds: ["local"], }, @@ -518,6 +528,8 @@ describe("台灣角川 ISBN 產品頁來源", () => { metadata: [ { property: "book.isbn-13", + sourceSlot: "book.isbn-13", + target: { kind: "entity", entityId: "asset" }, value: "9784049131291", sourceRecordIds: ["local"], }, @@ -541,6 +553,7 @@ describe("台灣角川 ISBN 產品頁來源", () => { }), ).toBe(false); const candidate = batch.sourceArtifacts[0]; + assert.isDefined(candidate); await expect( source.artifactAcquirer.acquire(candidate), ).resolves.toMatchObject({ @@ -577,7 +590,9 @@ describe("台灣角川 ISBN 產品頁來源", () => { evidence: [evidence], sourceRecords: collected.records, }); + assert.isDefined(batch); const candidate = batch.sourceArtifacts[0]; + assert.isDefined(candidate); for (const failedResponse of [ response(new Uint8Array([1]), { status: 404, diff --git a/sources/taiwan-kadokawa/tsconfig.json b/sources/taiwan-kadokawa/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/taiwan-kadokawa/tsconfig.json +++ b/sources/taiwan-kadokawa/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/taiwan-kadokawa/turbo.json b/sources/taiwan-kadokawa/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/taiwan-kadokawa/turbo.json +++ b/sources/taiwan-kadokawa/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/sources/tmdb/package.json b/sources/tmdb/package.json index 2dfbd8a..ca4e777 100644 --- a/sources/tmdb/package.json +++ b/sources/tmdb/package.json @@ -22,7 +22,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", "test": "vitest run --reporter=verbose", "build": "tsc -b tsconfig.build.json --force --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "dev:build": "pnpm --workspace-root run development:build @digital/tmdb", + "dev:compile": "rm -rf .turbo/development && tsc -p tsconfig.build.json --customConditions digital-development --pretty false --incremental --outDir .turbo/development/dist --tsBuildInfoFile .turbo/development/build.tsbuildinfo" }, "dependencies": { "@digital/application": "workspace:*", diff --git a/sources/tmdb/test/capabilities.test.ts b/sources/tmdb/test/capabilities.test.ts index fa822dc..593eab6 100644 --- a/sources/tmdb/test/capabilities.test.ts +++ b/sources/tmdb/test/capabilities.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { prerequisiteUnavailableFailure } from "@digital/application"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { TmdbArtifactAcquirer, @@ -258,11 +258,13 @@ describe("TMDB collection and interpretation", () => { }); const evidence = filmEvidence("/library/恐怖直播 (2013)"); + assert.isDefined(evidence[0]); const collected = await collector.collect(evidence[0]); const [interpreted] = new TmdbSourceInterpreter().interpret({ evidence, sourceRecords: collected.records, }); + assert.isDefined(interpreted); expect(collected.failures).toEqual([]); expect(collected.records.map((record) => record.coverage.id)).toEqual([ @@ -320,11 +322,13 @@ describe("TMDB collection and interpretation", () => { }); const evidence = filmEvidence("/library/恐怖直播 (2013)"); + assert.isDefined(evidence[0]); const collection = await collector.collect(evidence[0]); const [interpreted] = new TmdbSourceInterpreter().interpret({ evidence, sourceRecords: collection.records, }); + assert.isDefined(interpreted); const translation = collection.records.find( (record) => record.coverage.id === "tmdb.translations", @@ -380,11 +384,13 @@ describe("TMDB collection and interpretation", () => { }); const evidence = filmEvidence("/library/恐怖直播 (2013)"); + assert.isDefined(evidence[0]); const collection = await collector.collect(evidence[0]); const [interpreted] = new TmdbSourceInterpreter().interpret({ evidence, sourceRecords: collection.records, }); + assert.isDefined(interpreted); const rejectedTranslation = collection.records.find( (record) => record.coverage.id === "tmdb.translations.subject-mismatch", ); @@ -440,6 +446,7 @@ describe("TMDB collection and interpretation", () => { }); const evidence = filmEvidence("/library/恐怖直播 (2013)"); + assert.isDefined(evidence[0]); const collected = await collector.collect(evidence[0]); expect( @@ -468,6 +475,7 @@ describe("TMDB collection and interpretation", () => { }); const evidence = filmEvidence("/library/不存在的作品 (2013)"); + assert.isDefined(evidence[0]); const collected = await collector.collect(evidence[0]); expect(collected.failures).toEqual([]); @@ -512,6 +520,7 @@ describe("TMDB collection and interpretation", () => { }); const evidence = filmEvidence("/library/恐怖直播 (2013)"); + assert.isDefined(evidence[0]); const collected = await collector.collect(evidence[0]); expect(collected.records.map((record) => record.coverage.id)).toEqual([ @@ -547,6 +556,7 @@ describe("TMDB collection and interpretation", () => { }); const evidence = filmEvidence("/library/恐怖直播 (2013)"); + assert.isDefined(evidence[0]); const collected = await collector.collect(evidence[0]); expect(collected.records.map((record) => record.coverage.id)).toEqual([ @@ -644,11 +654,14 @@ describe("TMDB collection and interpretation", () => { }, }); const evidence = filmEvidence("/library/恐怖直播 (2013)"); + assert.isDefined(evidence[0]); const collected = await collector.collect(evidence[0]); const [interpreted] = new TmdbSourceInterpreter().interpret({ evidence, sourceRecords: collected.records, }); + assert.isDefined(interpreted); + assert.isDefined(interpreted); const proposal = new TmdbStrictFilmRecognizer().recognize({ evidence, basis: { kind: "source-interpretation", interpretation: interpreted }, @@ -752,6 +765,11 @@ describe("TMDB collection and interpretation", () => { creditsRecord, translationsRecord, ] = collected.records; + assert.isDefined(searchRecord); + assert.isDefined(translationsRecord); + assert.isDefined(creditsRecord); + assert.isDefined(directorsRecord); + assert.isDefined(detailRecord); expect(interpreted.sourceRecordIds).toEqual([ detailRecord.id, directorsRecord.id, @@ -809,6 +827,7 @@ describe("TMDB collection and interpretation", () => { evidence, sourceRecords: collected.records, }); + assert.isDefined(interpreted); const proposal = new TmdbStrictFilmRecognizer().recognize({ evidence, basis: { kind: "source-interpretation", interpretation: interpreted }, @@ -825,6 +844,7 @@ describe("TMDB collection and interpretation", () => { ruleId: "exact-directed-official-address", outcome: "satisfied", }); + assert.isDefined(collected.records[0]); expect(proposal.ruleResults[0]?.evidenceReferences).toEqual([ { kind: "source-record", @@ -944,6 +964,7 @@ describe("TMDB collection and interpretation", () => { it("does not copy an Authorization-bearing request failure into its public collection result", async () => { const evidence = filmEvidence("/library/Private Failure Film (2004)")[0]; + assert.isDefined(evidence); const result = await new TmdbCollector({ accessToken: "tmdb-error-token", delay: async () => {}, @@ -1096,6 +1117,7 @@ describe("TMDB collection and interpretation", () => { evidence, sourceRecords: [detail, directorCredits, credits], }); + assert.isDefined(interpreted); expect(interpreted.sourceArtifacts).toContainEqual( expect.objectContaining({ @@ -1175,6 +1197,7 @@ describe("TMDB collection and interpretation", () => { evidence, sourceRecords: [detail, directorCredits, credits], }); + assert.isDefined(interpreted); expect(detail.body).toBe(detailBody); expect(directorCredits.body).toBe(creditsBody); diff --git a/sources/tmdb/tsconfig.json b/sources/tmdb/tsconfig.json index 5420f5e..814d105 100644 --- a/sources/tmdb/tsconfig.json +++ b/sources/tmdb/tsconfig.json @@ -10,5 +10,5 @@ "target": "es2023", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/sources/tmdb/turbo.json b/sources/tmdb/turbo.json index 011f2ed..204cb1c 100644 --- a/sources/tmdb/turbo.json +++ b/sources/tmdb/turbo.json @@ -1 +1,64 @@ -{ "extends": ["//"], "tags": ["integration"] } +{ + "extends": ["//"], + "tags": ["integration"], + "tasks": { + "format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "cache": false, + "tags": ["work:repair"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + }, + "test": { + "dependsOn": ["build"], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/fixtures/controlled-source-io/**", + "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", + "$TURBO_ROOT$/fixtures/live-corpus/**" + ], + "outputs": [], + "tags": ["quality:light"] + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "dev:build": { + "dependsOn": ["^dev:build"], + "cache": false, + "inputs": [ + "src/**", + "tsconfig*.json", + "package.json", + "$TURBO_ROOT$/packages/workspace-automation/scripts/build-development.ts" + ], + "outputs": [".turbo/development/**"], + "tags": ["work:development"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "dev:compile": { + "tags": ["work:development"], + "cache": false, + "outputs": [".turbo/development/**"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + } + } +} diff --git a/tsconfig.json b/tsconfig.json index ed03b78..fb35443 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.config.json", - "include": ["oxfmt.config.ts", "oxlint.config.ts", "scripts/**/*.ts"] + "include": ["*.config.ts", "scripts/**/*.ts"] } diff --git a/turbo.json b/turbo.json index af7344a..bba0881 100644 --- a/turbo.json +++ b/turbo.json @@ -98,144 +98,122 @@ } }, "tasks": { - "//#format:check": { - "outputs": [] + "//#boundaries": { + "tags": ["quality:light"], + "cache": false, + "passThroughEnv": ["CI"] }, - "//#lint": { - "outputs": [] + "//#db:migrate": { + "tags": ["work:preparation"], + "cache": false }, - "//#format:write": { - "dependsOn": ["//#lint:fix"], + "//#environment": { + "tags": ["work:on-demand"], "cache": false }, - "//#lint:fix": { + "//#check": { + "tags": ["quality:entry"], "cache": false }, - "//#typecheck": { - "outputs": [] - }, - "format:check": { - "outputs": [] - }, - "lint": { - "outputs": [] - }, - "typecheck": { - "dependsOn": ["^typecheck"], - "outputs": [] - }, - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**"] - }, - "test": { - "dependsOn": ["build"], - "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], - "inputs": [ - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/fixtures/controlled-source-io/**", - "$TURBO_ROOT$/fixtures/controlled-fanbox-session/**", - "$TURBO_ROOT$/fixtures/live-corpus/**" - ], - "outputs": [] + "//#fix": { + "tags": ["work:repair"], + "cache": false }, - "test:e2e": { - "dependsOn": ["build"], + "//#format:check": { + "outputs": [], + "tags": ["quality:light"] + }, + "//#format:write": { + "dependsOn": ["//#lint:fix"], "cache": false, - "passThroughEnv": ["TMPDIR", "TMP", "TEMP"] + "tags": ["work:repair"] }, - "test:corpus-real": { - "dependsOn": ["build"], + "//#lint": { + "outputs": [], + "tags": ["quality:light"] + }, + "//#lint:fix": { "cache": false, - "passThroughEnv": [ - "TMPDIR", - "TMP", - "TEMP", - "DIGITAL_TMDB_ACCESS_TOKEN", - "DIGITAL_OMDB_API_KEY", - "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", - "DIGITAL_BANGUMI_CLIENT_ID", - "DIGITAL_BANGUMI_CLIENT_SECRET", - "DIGITAL_BANGUMI_REDIRECT_URI", - "DIGITAL_FANBOX_LIVE_FAILURE_EVIDENCE_PATH" - ] - }, - "test:e2e:tmdb-omdb-live": { - "dependsOn": ["build"], + "tags": ["work:repair"] + }, + "//#preview": { + "tags": ["work:development"], + "cache": false + }, + "//#preview:verify": { + "tags": ["quality:heavy"], "cache": false, - "passThroughEnv": ["DIGITAL_TMDB_ACCESS_TOKEN", "DIGITAL_OMDB_API_KEY"] + "dependsOn": [ + "@digital/web#build", + "@digital/cli#build", + "@digital/db-migrations#build" + ], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "DIGITAL_CI_EVIDENCE"] }, - "test:e2e:bangumi-live": { - "dependsOn": ["build"], + "//#typecheck": { + "outputs": [], + "tags": ["quality:light"] + }, + "//#preview:dev": { + "tags": ["work:development"], "cache": false, - "passThroughEnv": [ - "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", - "DIGITAL_BANGUMI_CLIENT_ID", - "DIGITAL_BANGUMI_CLIENT_SECRET", - "DIGITAL_BANGUMI_REDIRECT_URI" - ] - }, - "test:e2e:managed-service": { - "dependsOn": ["@digital/web#build", "@digital/environment#build"], + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] + }, + "//#check:heavy": { + "tags": ["quality:entry"], + "cache": false + }, + "//#check:full": { + "tags": ["quality:entry"], + "cache": false + }, + "//#test": { + "tags": ["quality:light"], "cache": false, - "passThroughEnv": [ - "DIGITAL_ACCEPTANCE_LEDGER", - "DIGITAL_ACCEPTANCE_ARTIFACTS" - ] + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"] }, - "test:e2e:provided-service": { - "dependsOn": ["@digital/web#build"], + "//#development:build": { + "tags": ["work:development"], "cache": false, - "passThroughEnv": [ - "DIGITAL_ACCEPTANCE_BASE_URL", - "DIGITAL_ACCEPTANCE_LEDGER", - "DIGITAL_ACCEPTANCE_ARTIFACTS" - ] + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] }, - "deployment": { + "//#development:serve": { + "tags": ["work:development"], "cache": false, - "passThroughEnv": [ - "DIGITAL_DEPLOYMENT_*", - "DIGITAL_COMPOSE_CHECK_*", - "DIGITAL_DEPLOYMENT_PLAYWRIGHT_CLIENT", - "PLAYWRIGHT_BROWSERS_PATH" - ] - }, - "format:write": { - "dependsOn": ["lint:fix"], - "cache": false + "passThroughEnv": ["DIGITAL_DEVELOPMENT_*"] }, - "lint:fix": { - "cache": false + "//#acceptance:prepare": { + "tags": ["work:preparation"], + "cache": false, + "passThroughEnv": ["DIGITAL_CI_EVIDENCE"] }, - "dev:build": { - "dependsOn": ["^dev:build"], + "//#verify:audio-posts": { + "tags": ["quality:conditional"], "cache": false, - "inputs": [ - "src/**", - "tsconfig*.json", - "package.json", - "$TURBO_ROOT$/scripts/build-development.ts" + "dependsOn": [ + "@digital/web#build", + "@digital/cli#build", + "@digital/db-migrations#build" ], - "outputs": [".turbo/development/**"] + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"] }, - "dev:serve": { - "dependsOn": ["^dev:build"], + "//#verify:publication-screen": { + "tags": ["quality:conditional"], "cache": false, - "inputs": [ - "package.json", - "vite.config.ts", - "+server.ts", - "scripts/start-development.ts", - "server/**", - "pages/**/+*.ts", - "$TURBO_ROOT$/scripts/development-task.ts" + "dependsOn": [ + "@digital/web#build", + "@digital/cli#build", + "@digital/db-migrations#build" ], - "passThroughEnv": [ - "DIGITAL_DEVELOPMENT_SESSION", - "DIGITAL_DEVELOPMENT_WORKSPACE", - "DIGITAL_DEVELOPMENT_ENVIRONMENT" - ] + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"] + }, + "//#fixture:source-io": { + "tags": ["work:on-demand"], + "cache": false + }, + "//#fixture:fanbox-session": { + "tags": ["work:on-demand"], + "cache": false } } } From b593d80699b9a8e93f26a7be0a8fb9a3941d8f6e Mon Sep 17 00:00:00 2001 From: YKDZ <3070799584@QQ.COM> Date: Fri, 16 Oct 2026 03:19:56 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E6=AD=A3=E7=A1=AE=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=20Docker=20=E5=B7=B2=E5=88=A0=E9=99=A4=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源码树与本地 main 提交 872e149db63251de449a00d92302a55590bd45a1 完全一致。 --- .../src/internal/container-operation.ts | 5 +++- .../test/container-environment.test.ts | 25 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/environment/src/internal/container-operation.ts b/packages/environment/src/internal/container-operation.ts index 593f398..658df84 100644 --- a/packages/environment/src/internal/container-operation.ts +++ b/packages/environment/src/internal/container-operation.ts @@ -275,7 +275,10 @@ export async function runContainerOperation( ) || (object.kind === "network" && diagnostic === - `Error response from daemon: network ${object.id ?? object.name} not found`) + `Error response from daemon: network ${object.id ?? object.name} not found`) || + (object.kind === "volume" && + diagnostic === + `Error response from daemon: get ${object.name}: no such volume`) ) { object.state = "absent"; object.running = false; diff --git a/packages/environment/test/container-environment.test.ts b/packages/environment/test/container-environment.test.ts index d53d998..b954c6b 100644 --- a/packages/environment/test/container-environment.test.ts +++ b/packages/environment/test/container-environment.test.ts @@ -8,8 +8,8 @@ import { manageContainerEnvironment } from "../src/index.ts"; /** 仅在 Docker 命令外部边界模拟应答丢失;对象状态和收尾结果由公开操作观察。 */ test.each(["legacy", "current"])( - "创建应答丢失及 %s 网络不存在应答:保留数据和首因,只收尾自有资源", - async (missingNetworkDiagnostic) => { + "创建应答丢失及 %s 对象不存在应答:保留数据和首因,只收尾自有资源", + async (missingObjectDiagnostic) => { const root = await mkdtemp( path.join(tmpdir(), "digital-container-response-loss-"), ); @@ -19,7 +19,7 @@ test.each(["legacy", "current"])( await writeFile( state, JSON.stringify({ - missingNetworkDiagnostic, + missingObjectDiagnostic, containers: [ { Id: "external-id", @@ -52,7 +52,7 @@ else if(args[1]==='create'&&args[0]!=='container'){ const name=args[args.indexOf('--name')+1],o={Id:'owned-id',Name:'/'+name,Config:{Labels:labels()},Image:'sha256:image-one',State:{Running:true,Pid:42,ExitCode:0,OOMKilled:false},NetworkSettings:{Ports:{}}};state.containers.push(o);fs.writeFileSync(file,JSON.stringify(state));console.error('creation response lost');process.exit(77); }else if(args[1]==='ls')output=list(args[0]).filter(matches).map(o=>args[0]==='volume'?o.Name:o.Id).join('\\n'); else if(args[1]==='inspect'){ - const o=find(args[0]);if(!o){console.error(args[0]==='network'&&state.missingNetworkDiagnostic==='current'?'Error response from daemon: network '+args.at(-1)+' not found':'Error: No such '+args[0]+': '+args.at(-1));process.exit(1)}output=JSON.stringify([o]); + const o=find(args[0]);if(!o){console.error(state.missingObjectDiagnostic==='current'&&args[0]==='network'?'Error response from daemon: network '+args.at(-1)+' not found':state.missingObjectDiagnostic==='current'&&args[0]==='volume'?'Error response from daemon: get '+args.at(-1)+': no such volume':'Error: No such '+args[0]+': '+args.at(-1));process.exit(1)}output=JSON.stringify([o]); }else if(args[0]==='container'&&args[1]==='stop'){ const o=find('container');if(!o)throw Error('missing');o.State={Running:false,Pid:0,ExitCode:143,OOMKilled:false};output=o.Id; }else if(args[0]==='container'&&args[1]==='logs')output='full diagnostic'; @@ -117,6 +117,23 @@ fs.writeFileSync(file,JSON.stringify(state));console.log(output); (object) => object.kind === "volume", ), ).toMatchObject({ state: "present" }); + const withoutNetwork = JSON.parse(await readFile(state, "utf8")) as { + volumes: unknown[]; + }; + await writeFile( + state, + JSON.stringify({ ...withoutNetwork, volumes: [] }), + ); + const volumeRemoved = await manageContainerEnvironment({ + ...reference, + action: "inspect", + }); + expect(volumeRemoved.status).toBe("ok"); + expect( + volumeRemoved.observation?.objects.find( + (object) => object.kind === "volume", + ), + ).toMatchObject({ state: "absent" }); } finally { await rm(root, { recursive: true, force: true }); } From caf90398c8f038a603231432e8b9e645457b8570 Mon Sep 17 00:00:00 2001 From: YKDZ <3070799584@QQ.COM> Date: Fri, 16 Oct 2026 03:30:49 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=E5=9C=A8=E5=8F=97=E6=8E=A7?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E6=8E=A5=E7=BC=9D=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=20FANBOX=20=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源码树与本地 main 提交 0ed96babc3e0da70c0672d979f998ad6cb98019b 完全一致。 --- .../test/browser-product-assembly.test.ts | 40 +++++++++++++++++++ sources/fanbox/test/capabilities.test.ts | 31 -------------- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/sources/fanbox/test/browser-product-assembly.test.ts b/sources/fanbox/test/browser-product-assembly.test.ts index b54d17e..7685453 100644 --- a/sources/fanbox/test/browser-product-assembly.test.ts +++ b/sources/fanbox/test/browser-product-assembly.test.ts @@ -346,6 +346,46 @@ beforeEach(() => { }); describe("FANBOX browser product assembly", () => { + it("生产连接验证不经 Node fetch 发出端点请求", async () => { + const nodeFetch = vi.fn(async () => { + throw new Error("Node transport must be unreachable"); + }); + vi.stubGlobal("fetch", nodeFetch); + renderer.connectedChallenge = true; + const assembly = createFanboxProductAssembly(); + try { + await expect( + assembly.validatedBrowserCredential({ + creatorId: "customudon", + browserCredential: { + userAgent: "test-browser/1", + cookies: [ + { + name: "FANBOXSESSID", + value: "test-private-session", + domain: ".fanbox.cc", + path: "/", + }, + ], + }, + }), + ).rejects.toThrow("FANBOX 来源连接验证失败"); + expect(nodeFetch).not.toHaveBeenCalled(); + expect(renderer.launches).toBe(1); + expect(renderer.operations).toContainEqual({ + context: 1, + pathname: "/post.info", + credentials: "include", + }); + } finally { + try { + await assembly.close(); + } finally { + vi.unstubAllGlobals(); + } + } + }, 30_000); + it("验证与采集各用 fresh context,并在同一采集 session 只追加一次 public detail", async () => { const assembly = createFanboxProductAssembly(); const privateConfiguration = await assembly.validatedBrowserCredential({ diff --git a/sources/fanbox/test/capabilities.test.ts b/sources/fanbox/test/capabilities.test.ts index 6d0daf9..731e410 100644 --- a/sources/fanbox/test/capabilities.test.ts +++ b/sources/fanbox/test/capabilities.test.ts @@ -14,7 +14,6 @@ import { FanboxDateDirectoryInterpreter, FanboxExactRecognizer, FanboxSourceInterpreter, - createFanboxProductAssembly, decodeFanboxCollectionFailureCode, fanboxCreatorIdFromEvidence, fanboxOfficialCollectionCapability, @@ -29,36 +28,6 @@ describe("FANBOX 产品浏览器连接", () => { expect(fanboxPublic).not.toHaveProperty("verifyFanboxStoredCredential"); }); - it("生产连接验证不经 Node fetch 发出端点请求", async () => { - const nodeFetch = vi.fn(async () => { - throw new Error("Node transport must be unreachable"); - }); - vi.stubGlobal("fetch", nodeFetch); - const assembly = createFanboxProductAssembly(); - try { - await expect( - assembly.validatedBrowserCredential({ - creatorId: "customudon", - browserCredential: { - userAgent: "test-browser/1", - cookies: [ - { - name: "FANBOXSESSID", - value: "test-private-session", - domain: ".fanbox.cc", - path: "/", - }, - ], - }, - }), - ).rejects.toThrow("FANBOX 来源连接验证失败"); - expect(nodeFetch).not.toHaveBeenCalled(); - } finally { - await assembly.close().catch(() => undefined); - vi.unstubAllGlobals(); - } - }, 30_000); - it("把浏览器导出的 cookie 属性规范化为唯一 V2 私密串", async () => { const assembly = createScriptedFanboxProductAssembly( scriptedClosedGet(async (address) => From 9bbc7cb7b82d7bb0a3219a5d5165f42bb35b4051 Mon Sep 17 00:00:00 2001 From: YKDZ <3070799584@QQ.COM> Date: Fri, 16 Oct 2026 03:40:25 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E4=B8=BA=E5=BC=80=E5=8F=91=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E5=90=AF=E7=94=A8=E5=AD=A4=E5=84=BF=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源码树与本地 main 提交 48ab8f69b5aca507380d13c7e8f7a55bf41d9080 完全一致。 --- .devcontainer/devcontainer.json | 1 + packages/workspace-automation/README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e581dd2..5ac4d03 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,6 @@ { "name": "digital", + "init": true, "build": { "dockerfile": "Dockerfile", "args": { diff --git a/packages/workspace-automation/README.md b/packages/workspace-automation/README.md index 709d49d..725155d 100644 --- a/packages/workspace-automation/README.md +++ b/packages/workspace-automation/README.md @@ -215,7 +215,7 @@ pnpm exec turbo run deployment --filter=@digital/web 构建网络由 Docker 默认配置提供。宿主网络可达而桥接网络无法访问依赖仓库的开发环境,可显式设置 `DIGITAL_DEPLOYMENT_BUILD_NETWORK=host`,向同一正式构建传入 Docker 的原生网络选项;CI 沿用 runner 的默认网络。 -仓库开发容器使用 Unix Docker 宿主的网络,并将工作区挂载到与宿主相同的绝对路径,使发布端口、素材和证据目录在验收进程与 daemon 两侧都可访问。更新这项配置后须重建开发容器;既有会话仍使用原来的路径与网络。 +仓库开发容器启用 init 回收孤儿进程,使用 Unix Docker 宿主的网络,并将工作区挂载到与宿主相同的绝对路径,使发布端口、素材和证据目录在验收进程与 daemon 两侧都可访问。更新这项配置后须重建开发容器;既有会话仍使用原来的进程回收方式、路径与网络。 检查默认选择空闲端口,仍可通过 `DIGITAL_DEPLOYMENT_PORT` 与 `DIGITAL_COMPOSE_CHECK_PORT` 指定独占端口。环境及证据使用本次唯一身份;成功先保存证据再回收自有资源,包括已经验证的预期失败反例,真实失败保留诊断和数据。 From deaabef889812b9e7820b502d462df773135af66 Mon Sep 17 00:00:00 2001 From: YKDZ <3070799584@QQ.COM> Date: Fri, 16 Oct 2026 04:52:21 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E7=BC=A9=E7=9F=AD=20CI=20=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E6=A0=B9=E4=BB=A5=E6=94=AF=E6=8C=81=20Unix=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收快照对应本地 main 7f32a9877184a199065bbb499b8fa4a764839c8f;源码树完全一致。 --- .github/workflows/check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9650fb8..6457e37 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -39,8 +39,8 @@ jobs: cache: true - name: 准备本作业临时目录 run: | - ci_tmp="$RUNNER_TEMP/digital-check-${{ github.run_id }}-${{ github.run_attempt }}" - mkdir -m 700 -p "$ci_tmp" + # 私有控制通道使用 Unix socket,作业临时根保持短路径。 + ci_tmp="$(mktemp -d /tmp/digital-ci.XXXXXXXX)" printf 'TMPDIR=%s\n' "$ci_tmp" >> "$GITHUB_ENV" - name: 安装锁定依赖 run: pnpm install --frozen-lockfile From a3a6eabacf3a20e268d763ced70bc6262fd4c4de Mon Sep 17 00:00:00 2001 From: YKDZ <3070799584@QQ.COM> Date: Fri, 16 Oct 2026 04:57:57 +0800 Subject: [PATCH 6/6] =?UTF-8?q?ci:=20=E6=8F=90=E4=BE=9B=E5=8F=AF=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E4=B8=8B=E8=BD=BD=E7=9A=84=E4=BA=A7=E5=93=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E5=AE=8C=E6=95=B4=E6=97=A5=E5=BF=97=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收快照对应本地 main d90a70fc1c78910b0e5a1a8cabecb72735a2d6b1;源码树完全一致。 --- .github/workflows/check.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6457e37..e24c82b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -64,9 +64,24 @@ jobs: include-hidden-files: true if-no-files-found: error retention-days: 7 + - name: 上传可独立下载的产品数据与完整日志 + id: product_upload + if: always() + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 + with: + name: ci-product-data-and-logs-${{ github.run_id }}-${{ github.run_attempt }} + # 完整包保留容器文件系统快照;此包只排除这些大快照。 + path: | + ${{ env.DIGITAL_CI_EVIDENCE }} + !${{ env.DIGITAL_CI_EVIDENCE }}/**/*-stopped-container.tar + !${{ env.DIGITAL_CI_EVIDENCE }}/**/stopped-web.tar + !${{ env.DIGITAL_CI_EVIDENCE }}/**/browser-client-diagnostic.tar + include-hidden-files: true + if-no-files-found: error + retention-days: 7 - name: 核实上传产物后显式删除自有环境 id: deletion - if: always() && steps.collection.outcome == 'success' && steps.upload.outcome == 'success' + if: always() && steps.collection.outcome == 'success' && steps.upload.outcome == 'success' && steps.product_upload.outcome == 'success' env: GITHUB_TOKEN: ${{ github.token }} DIGITAL_CI_ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} @@ -77,6 +92,6 @@ jobs: env: DIGITAL_CI_EXECUTION_OUTCOME: ${{ steps.execution.outcome }} DIGITAL_CI_COLLECTION_OUTCOME: ${{ steps.collection.outcome }} - DIGITAL_CI_UPLOAD_OUTCOME: ${{ steps.upload.outcome }} + DIGITAL_CI_UPLOAD_OUTCOME: ${{ steps.upload.outcome == 'success' && steps.product_upload.outcome == 'success' && 'success' || 'failure' }} DIGITAL_CI_DELETION_OUTCOME: ${{ steps.deletion.outcome }} run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts result