diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..add40e1 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,5 @@ +{ + "access": "public", + "baseBranch": "main", + "fixed": [["@ykdz/cli-contract", "@ykdz/cli-contract-testing"]] +} diff --git a/.changeset/next-round-release.md b/.changeset/next-round-release.md new file mode 100644 index 0000000..8aa7870 --- /dev/null +++ b/.changeset/next-round-release.md @@ -0,0 +1,5 @@ +--- +"@ykdz/cli-contract": minor +--- + +为命令提供独立的输出方式、契约发现与可验证的调用示例,并将契约清单升级到 5、结构化线协议升级到 3。最低运行环境升至 Node 26。 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3ab065c..9056cda 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,22 +2,35 @@ 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 PATH="/root/.local/share/pnpm/bin:/home/node/.local/share/pnpm/bin:$PATH" +# @template-mirror pnpm-bootstrap +COPY --from=ghcr.io/pnpm/pnpm:12.9.1 /opt/pnpm /opt/pnpm +# @end-template-mirror RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git \ + && 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" + && for bin in pnpm pn pnpx pnx; do ln -s "/opt/pnpm/pnpm" "/usr/local/bin/$bin"; done \ + && install -d /pnpm/store/root /root/.local/share/pnpm \ + && install -d -o node -g node /pnpm/store/node /home/node/.local/share/pnpm \ + && pnpm config set store-dir /pnpm/store/root --global \ + && su -s /bin/sh node -c 'pnpm config set store-dir /pnpm/store/node --global' -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && install -m 0755 -d /etc/apt/keyrings \ +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 \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ad0893d..31d20fa 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,9 +1,6 @@ { "build": { - "args": { - "NODE_VERSION": "24", - "PACKAGE_MANAGER_PIN": "pnpm@12.1.0" - }, + "args": { "NODE_VERSION": "26.13.0" }, "dockerfile": "Dockerfile" }, "customizations": { diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d448c8f..365a51f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -24,16 +24,18 @@ jobs: with: persist-credentials: false fetch-depth: 1 - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version-file: package.json - package-manager-cache: false - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - name: 读取 Node 版本 + id: node-runtime + run: echo "runtime=node@$(jq -er '.engines.node' package.json)" >> "$GITHUB_OUTPUT" + - name: 安装 pnpm 与 Node.js + uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2 with: + runtime: ${{ steps.node-runtime.outputs.runtime }} + install: false cache: true - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run Root Check - run: pnpm run check + - name: Run Light Check + run: node --conditions=source scripts/task-gates.ts check + - name: Run Heavy Check + run: node --conditions=source scripts/task-gates.ts check:heavy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37f671e..cd1741b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,70 +1,86 @@ -name: Release +name: 发布 npm 包 on: workflow_dispatch: -permissions: - contents: write - id-token: write +permissions: {} concurrency: - group: release + group: npm-publication-${{ github.repository }} cancel-in-progress: false + queue: max jobs: - publish: - name: Publish verified release candidate + pack: + name: 验收并打包 + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 120 + permissions: + contents: read + outputs: + pack-dir-artifact-id: ${{ steps.pack.outputs.pack-dir-artifact-id }} steps: - - name: Checkout event commit + - name: 检出源码 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - ref: ${{ github.sha }} - - name: Set up pnpm - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: 读取 Node 版本 + id: node-runtime + run: echo "runtime=node@$(jq -er '.engines.node' package.json)" >> "$GITHUB_OUTPUT" + - name: 安装 pnpm 与 Node.js + uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2 with: - cache: pnpm - node-version: 24 - - name: Install dependencies + runtime: ${{ steps.node-runtime.outputs.runtime }} + install: false + cache: true + - name: 安装依赖 run: pnpm install --frozen-lockfile - - name: Verify release tag and package versions - env: - EVENT_SHA: ${{ github.sha }} - RELEASE_REF: ${{ github.ref }} - run: node --conditions=source scripts/validate-release-tag.ts "$RELEASE_REF" "$EVENT_SHA" - - name: Install supported npm - run: npm install --global npm@11.5.1 - - name: Show npm version - run: npm --version - - name: Verify release candidate - id: candidate - shell: bash - run: | - candidate_directory="$(mktemp -d)" - candidate_result="$(pnpm --silent candidate:verify -- --output-dir "$candidate_directory")" - core_tarball="$(jq --raw-output '.coreTarball' <<<"$candidate_result")" - testing_tarball="$(jq --raw-output '.testingTarball' <<<"$candidate_result")" - test -f "$core_tarball" - test -f "$testing_tarball" - printf 'core_tarball=%s\n' "$core_tarball" >> "$GITHUB_OUTPUT" - printf 'testing_tarball=%s\n' "$testing_tarball" >> "$GITHUB_OUTPUT" - - name: Publish core package - env: - CORE_TARBALL: ${{ steps.candidate.outputs.core_tarball }} - run: npm publish "$CORE_TARBALL" --access public --tag latest --registry https://registry.npmjs.org - - name: Publish testing package - env: - TESTING_TARBALL: ${{ steps.candidate.outputs.testing_tarball }} - run: npm publish "$TESTING_TARBALL" --access public --tag latest --registry https://registry.npmjs.org - - name: Create GitHub Release - env: - CORE_TARBALL: ${{ steps.candidate.outputs.core_tarball }} - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - TESTING_TARBALL: ${{ steps.candidate.outputs.testing_tarball }} - run: gh release create "$RELEASE_TAG" "$CORE_TARBALL" "$TESTING_TARBALL" --verify-tag + - name: 检查源码 + run: pnpm run check + - name: 按依赖顺序构建 + run: pnpm exec turbo run build + - name: 打包未发布版本 + id: pack + uses: changesets/action/pack@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + - name: 下载同批制品进行消费验收 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ steps.pack.outputs.pack-dir-artifact-id }} + path: ${{ runner.temp }}/changesets-pack + - name: 验收 Changesets 原生制品 + run: pnpm product:verify -- --pack-dir "$RUNNER_TEMP/changesets-pack" + + publish: + name: 发布 npm 包和 GitHub Release + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + needs: pack + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: npm + permissions: + contents: write + id-token: write + steps: + - name: 检出源码 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: 读取 Node 版本 + id: node-runtime + run: echo "runtime=node@$(jq -er '.engines.node' package.json)" >> "$GITHUB_OUTPUT" + - name: 安装 pnpm 与 Node.js + uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2 + with: + runtime: ${{ steps.node-runtime.outputs.runtime }} + install: false + cache: false + - name: 安装依赖 + run: pnpm install --frozen-lockfile --ignore-scripts + - name: 发布已验收制品 + uses: changesets/action/publish@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + pack-dir-artifact-id: ${{ needs.pack.outputs.pack-dir-artifact-id }} + create-github-releases: true + push-git-tags: true diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml new file mode 100644 index 0000000..8fb02b5 --- /dev/null +++ b/.github/workflows/version.yml @@ -0,0 +1,43 @@ +name: Changesets 版本 PR + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + version: + name: 生成版本 PR + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: 检出主分支 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: 读取 Node 版本 + id: node-runtime + run: echo "runtime=node@$(jq -er '.engines.node' package.json)" >> "$GITHUB_OUTPUT" + - name: 安装 pnpm 与 Node.js + uses: pnpm/setup@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2 + with: + runtime: ${{ steps.node-runtime.outputs.runtime }} + install: false + cache: true + - name: 安装依赖 + run: pnpm install --frozen-lockfile + - name: 计算版本并生成版本 PR + uses: changesets/action/version@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: pnpm exec changeset version + pr-base-branch: main diff --git a/oxfmt.config.ts b/oxfmt.config.ts index 9cd0c95..7c2ddc5 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -1,3 +1 @@ -import { defineConfig } from "oxfmt"; - -export default defineConfig({ printWidth: 80, sortImports: true }); +export { default } from "@cli-contract/tooling-config/oxfmt"; diff --git a/oxlint.config.ts b/oxlint.config.ts index e6a02af..d99aa16 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,8 +1,4 @@ +import lintConfiguration from "@cli-contract/tooling-config/oxlint"; import { defineConfig } from "oxlint"; -// OXC's current erasing-op rule does not reject enum, runtime namespace, or -// parameter properties. TypeScript 7 erasableSyntaxOnly is authoritative. -export default defineConfig({ - options: { typeAware: true }, - categories: { correctness: "error", suspicious: "warn" }, -}); +export default defineConfig({ extends: [lintConfiguration] }); diff --git a/package.json b/package.json index e3c015f..ddb5b31 100644 --- a/package.json +++ b/package.json @@ -3,21 +3,24 @@ "private": true, "type": "module", "scripts": { - "boundaries": "node --conditions=source scripts/check-boundaries.ts", - "candidate:pack": "node --conditions=source scripts/create-pack-candidate.ts", - "candidate:verify": "node --conditions=source scripts/verify-release-candidate.ts", - "consumer:types": "node --conditions=source scripts/verify-typescript-consumer-matrix.ts", - "check": "node --conditions=source scripts/verify-release-candidate.ts --check", + "boundaries": "turbo boundaries", + "check": "node --conditions=source scripts/task-gates.ts check", + "check:full": "node --conditions=source scripts/task-gates.ts check:full", + "check:heavy": "node --conditions=source scripts/task-gates.ts check:heavy", "check:workspace": "turbo run boundaries format:check lint typecheck build test test:e2e --continue=dependencies-successful --output-logs=errors-only --log-order=grouped --log-prefix=task", - "example:published": "node scripts/verify-example.ts --published", - "fix": "turbo run lint:fix format:write --continue=dependencies-successful --output-logs=full --log-order=grouped --log-prefix=task", - "format:check": "node --conditions=source scripts/run-root-owned-task.ts format:check", - "format:write": "node --conditions=source scripts/run-root-owned-task.ts format:write", - "lint": "node --conditions=source scripts/run-root-owned-task.ts lint", - "lint:fix": "node --conditions=source scripts/run-root-owned-task.ts lint:fix", - "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + "consumer:types": "node --conditions=source scripts/verify-typescript-consumer-matrix.ts", + "example:published": "pnpm --filter @cli-contract/example run verify:published", + "fix": "node --conditions=source scripts/task-gates.ts fix", + "format:check": "oxfmt --list-different package.json README.md pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig*.json *.config.ts .gitignore .vscode .github .changeset/config.json .devcontainer scripts", + "format:write": "oxfmt --write package.json README.md pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig*.json *.config.ts .gitignore .vscode .github .changeset/config.json .devcontainer scripts", + "lint": "oxlint --quiet --format=unix *.config.ts scripts", + "lint:fix": "oxlint --format=unix *.config.ts scripts --fix", + "product:verify": "node --conditions=source scripts/verify-packed-release.ts", + "typecheck": "tsc --build --noEmit --pretty false" }, "devDependencies": { + "@changesets/cli": "3.0.3", + "@cli-contract/tooling-config": "workspace:*", "@types/node": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", @@ -26,7 +29,7 @@ "typescript-7": "catalog:" }, "engines": { - "node": "24" + "node": "26.13.0" }, - "packageManager": "pnpm@12.1.0" + "packageManager": "pnpm@12.9.1" } diff --git a/packages/example/package.json b/packages/example/package.json index 647e654..8a59f35 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -15,22 +15,25 @@ } }, "scripts": { + "authoring:fixture:example": "cat src/index.ts", "build": "tsc -p tsconfig.build.json --pretty false", - "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", - "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", + "format:check": "oxfmt --list-different .", + "format:write": "oxfmt --write .", + "lint": "oxlint --quiet --format=unix --ignore-pattern node_modules .", + "lint:fix": "oxlint --format=unix . --fix", + "release:verify": "node --conditions=source scripts/verify-consumer.ts", "start": "node src/main.ts", "test": "node --test test/*.test.ts", "test:e2e": "node --test test/*.e2e.ts", - "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", + "verify:published": "node --conditions=source scripts/verify-consumer.ts --published" }, "dependencies": { "@ykdz/cli-contract": "workspace:*", "zod": "catalog:" }, "devDependencies": { - "@cli-contract/typescript-config": "workspace:*", + "@cli-contract/tooling-config": "workspace:*", "@types/node": "catalog:", "@ykdz/cli-contract-testing": "workspace:*", "oxfmt": "catalog:", @@ -39,6 +42,6 @@ "typescript-7": "catalog:" }, "engines": { - "node": "24" + "node": "^26.13.0" } } diff --git a/scripts/verify-example.ts b/packages/example/scripts/verify-consumer.ts similarity index 62% rename from scripts/verify-example.ts rename to packages/example/scripts/verify-consumer.ts index 37898ab..9c7cf60 100644 --- a/scripts/verify-example.ts +++ b/packages/example/scripts/verify-consumer.ts @@ -1,14 +1,14 @@ import { spawnSync } from "node:child_process"; import { cp, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { installedRuntimeDevDependencies, - repositoryRoot, runCommand, - verifyInstalledRuntime, withInstalledConsumer, -} from "./installed-consumer.ts"; +} from "@cli-contract/tooling-config/release-consumer"; +import coreMetadata from "@ykdz/cli-contract/package.json" with { type: "json" }; interface Manifest { name: string; @@ -19,56 +19,29 @@ interface Manifest { devDependencies?: Record; } -export interface CandidateTarballs { +export interface PackedTarballs { readonly coreTarball: string; readonly testingTarball: string; } -const exampleRoot = resolve(repositoryRoot, "packages/example"); -const configPackage = "@cli-contract/typescript-config"; +const exampleRoot = resolve(import.meta.dirname, ".."); +const configPackage = "@cli-contract/tooling-config"; +const corePackage = "@ykdz/cli-contract"; +const testingPackage = "@ykdz/cli-contract-testing"; export async function verifyExample( - candidate?: CandidateTarballs, + packed?: PackedTarballs, argv?: readonly string[], ): Promise { const example = await manifestAt(exampleRoot); if (!example.scripts) throw new TypeError("示例包缺少 scripts"); - const core = await manifestAt(resolve(repositoryRoot, "packages/lib")); - const testing = await manifestAt(resolve(repositoryRoot, "packages/testing")); - if (!core.version || core.version !== testing.version) - throw new Error("公共包版本必须一致"); - if (!candidate) { - const tag = runCommand("git", [ - "describe", - "--exact-match", - "--tags", - "HEAD", - ]).trim(); - if (tag !== `v${core.version}`) - throw new Error("请在对应发布 tag 上运行 npm 示例"); - const modified = runCommand("git", [ - "status", - "--porcelain", - "--", - "packages/example", - "packages/lib/package.json", - "packages/testing/package.json", - "packages/typescript-config", - "pnpm-lock.yaml", - "scripts/verify-example.ts", - "scripts/installed-consumer.ts", - "scripts/fixtures/installed-runtime.ts", - ]); - if (modified.trim()) - throw new Error( - "发布版本体验要求示例及其依赖配置与 tag 一致;请先保存或撤回相关修改", - ); - } + const coreVersion = coreMetadata.version; + if (typeof coreVersion !== "string") + throw new Error("公共 core 包元数据缺少版本"); + if (packed === undefined) verifyPublishedSource(coreVersion); const replacements: Record = { - [core.name]: candidate ? `file:${candidate.coreTarball}` : core.version, - [testing.name]: candidate - ? `file:${candidate.testingTarball}` - : core.version, + [corePackage]: packed ? `file:${packed.coreTarball}` : coreVersion, + [testingPackage]: packed ? `file:${packed.testingTarball}` : coreVersion, }; const manifest = { ...example, @@ -87,30 +60,42 @@ export async function verifyExample( recursive: true, }); } - // 展平共享配置,让临时项目保持相同严格性,同时脱离 workspace。 + // 展平 Node 配置,让临时项目与 workspace 使用相同编译选项。 const config = JSON.parse( await readFile(resolve(exampleRoot, "tsconfig.json"), "utf8"), ); - const base = JSON.parse( + const baseConfig = JSON.parse( await readFile( - resolve(exampleRoot, "node_modules", configPackage, "base.json"), + fileURLToPath(import.meta.resolve(`${configPackage}/base.json`)), "utf8", ), ); + const nodeConfig = JSON.parse( + await readFile( + fileURLToPath(import.meta.resolve(`${configPackage}/node.json`)), + "utf8", + ), + ); + const { extends: _baseExtends, ...base } = baseConfig; + const { extends: _nodeExtends, ...node } = nodeConfig; const { extends: _extends, ...local } = config; await writeFile( resolve(project, "tsconfig.json"), JSON.stringify({ ...base, + ...node, ...local, - compilerOptions: { ...base.compilerOptions, ...local.compilerOptions }, + compilerOptions: { + ...base.compilerOptions, + ...node.compilerOptions, + ...local.compilerOptions, + }, }), ); await cp( resolve(exampleRoot, "tsconfig.build.json"), resolve(project, "tsconfig.build.json"), ); - await verifyInstalledRuntime(project); for (const task of ["typecheck", "build", "test", "test:e2e"]) { process.stderr.write( runCommand("npm", ["run", "--silent", task], project), @@ -122,11 +107,41 @@ export async function verifyExample( stdio: "inherit", }); if (result.error) throw result.error; - process.exitCode = result.status ?? 1; + if (result.status !== 0) + throw new Error(`示例进程以状态 ${result.status ?? "unknown"} 退出`); } }); } +function verifyPublishedSource(version: string): void { + const modifiedExample = runCommand( + "git", + ["status", "--porcelain", "--untracked-files=all", "--", "."], + exampleRoot, + ); + if (modifiedExample.trim().length > 0) { + throw new Error( + "发布后示例验收要求示例包与发布源码一致;请从对应 Changesets 原生包 tag 的干净工作区运行", + ); + } + + const tagsAtHead = new Set( + runCommand("git", ["tag", "--points-at", "HEAD"], exampleRoot) + .split("\n") + .filter((tag) => tag.length > 0), + ); + const requiredTags = [ + `${corePackage}@${version}`, + `${testingPackage}@${version}`, + ]; + const missingTags = requiredTags.filter((tag) => !tagsAtHead.has(tag)); + if (missingTags.length > 0) { + throw new Error( + `发布后示例验收要求 HEAD 位于对应 Changesets 原生包 tag:${missingTags.join("、")}`, + ); + } +} + async function manifestAt(directory: string): Promise { const value: unknown = JSON.parse( await readFile(resolve(directory, "package.json"), "utf8"), @@ -196,10 +211,12 @@ async function installedDependencies( } if (import.meta.main) { - const args = process.argv - .slice(2) - .filter((arg, index) => !(index === 0 && arg === "--")); - const candidate = + const rawArgs = process.argv.slice(2); + const values = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + const separator = values.indexOf("--"); + const args = separator === -1 ? values : values.slice(0, separator); + const cliArgs = separator === -1 ? [] : values.slice(separator + 1); + const packed = args.length === 4 && args[0] === "--core" && args[2] === "--testing" && @@ -208,10 +225,9 @@ if (import.meta.main) { ? { coreTarball: resolve(args[1]), testingTarball: resolve(args[3]) } : undefined; const published = args[0] === "--published"; - if (!candidate && !published) + if ((!packed && !published) || (published && args.length !== 1)) throw new Error( - "用法:verify-example --core --testing 或 --published [CLI 参数]", + "用法:release:verify --core --testing 或 verify:published [CLI 参数]", ); - const cliArgs = args[1] === "--" ? args.slice(2) : args.slice(1); - await verifyExample(candidate, published ? cliArgs : undefined); + await verifyExample(packed, published ? cliArgs : undefined); } diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 2329d40..79dc73a 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,13 +1,21 @@ import { defineCli, helpCapability, - outputCapability, + schemaCapability, text, + versionCapability, } from "@ykdz/cli-contract"; +import coreMetadata from "@ykdz/cli-contract/package.json" with { type: "json" }; import { z } from "zod"; const users = new Map([["alice", { name: "Alice" }]]); const logSchema = z.object({ message: z.string() }); +const userSchema = z.object({ name: z.string() }); +const unavailableUserSchema = z.object({ id: z.string() }); +const emailMismatchSchema = z.object({ + email: z.email(), + domain: z.string().min(1), +}); export interface Dependencies { readLogs: () => AsyncIterable>; @@ -15,71 +23,162 @@ export interface Dependencies { const define = defineCli(); +const lookupInput = z.object({ id: z.string().min(1) }); +const lookupSuccess = { + kind: "data" as const, + variants: { + found: { + description: "找到用户", + schema: userSchema, + exitCode: 0, + }, + }, +}; +const lookupFailures = { + notFound: { + description: "用户不存在", + schema: unavailableUserSchema, + exitCode: 1, + }, +}; const lookup = define.command("lookup")({ kind: "command", parent: "users", name: "get", description: "查询用户", fields: { id: { kind: "positional", description: "用户标识" } }, - input: z.object({ id: z.string().min(1) }), - // 业务判断集中在 handler,便于文本和 JSON 保持一致;呈现器的措辞仍需核对。 - success: { - kind: "data", - variants: { - found: { - description: "找到用户", - schema: z.object({ name: z.string() }), - exitCode: 0, - text: (user) => text.lines(["用户信息", user.name]), - }, - }, - }, - failures: { - // 按调用方需要采取的处理方式区分失败,保留判断所需的证据。 - // 类型检查能约束已声明的失败;业务上还缺哪些失败,需要作者判断。 - notFound: { - description: "用户不存在", - schema: z.object({ id: z.string() }), - exitCode: 1, - text: ({ id }) => text.lines([`用户 ${id} 不存在`, "", "请检查用户标识"]), + input: lookupInput, + examples: [ + { input: { id: "alice" } }, + { input: { id: "alice" }, outputMode: "structured" }, + ], + outputs: define.outputModes("lookup", lookupInput, (mode) => ({ + default: "text", + modes: { + structured: mode.atomic({ + description: "供程序读取的用户资料", + format: "structured", + success: lookupSuccess, + failures: lookupFailures, + handler({ input, outcome }) { + const user = users.get(input.id); + return user + ? outcome.data.found(user) + : outcome.failure.notFound({ id: input.id }); + }, + }), + text: mode.atomic({ + description: "供人阅读的用户资料", + format: "text", + success: { + kind: "data", + variants: { + found: { + ...lookupSuccess.variants.found, + text: (user) => text.lines(["用户信息", user.name]), + }, + }, + }, + failures: { + notFound: { + ...lookupFailures.notFound, + text: ({ id }) => + text.lines([`用户 ${id} 不存在`, "", "请检查用户标识"]), + }, + }, + handler({ input, outcome }) { + const user = users.get(input.id); + return user + ? outcome.data.found(user) + : outcome.failure.notFound({ id: input.id }); + }, + }), }, - }, - handler({ input, outcome }) { - const user = users.get(input.id); - return user - ? outcome.data.found(user) - : outcome.failure.notFound({ id: input.id }); - }, + })), }); +const logsInput = z.object({}); const logs = define.command("logs")({ kind: "command", parent: "users", name: "logs", description: "实时输出日志", fields: {}, - input: z.object({}), - success: { - kind: "stream", - records: { - entry: { - description: "日志记录", - schema: logSchema, - text: ({ message }) => text.fragment(`${message}\n`), - }, + input: logsInput, + outputs: define.outputModes("logs", logsInput, (mode) => ({ + default: "text", + modes: { + structured: mode.stream({ + description: "按 NDJSON 输出日志记录与终态", + format: "structured", + success: { + kind: "stream", + records: { + entry: { + description: "日志记录", + schema: logSchema, + }, + }, + }, + failures: {}, + async *handler({ dependencies, outcome }) { + for await (const entry of dependencies.readLogs()) { + yield outcome.record.entry(entry); + } + return outcome.streamSuccess(); + }, + }), + text: mode.stream({ + description: "逐行输出日志文本", + format: "text", + success: { + kind: "stream", + records: { + entry: { + description: "日志记录", + schema: logSchema, + text: ({ message }) => text.fragment(`${message}\n`), + }, + }, + text: () => text.silent, + }, + failures: {}, + async *handler({ dependencies, outcome }) { + for await (const entry of dependencies.readLogs()) { + yield outcome.record.entry(entry); + } + return outcome.streamSuccess(); + }, + }), }, - text: () => text.silent, - }, - failures: {}, - async *handler({ dependencies, outcome }) { - for await (const entry of dependencies.readLogs()) { - yield outcome.record.entry(entry); - } - // NDJSON 读取方收到成功终态后,才能确认日志流已完整结束。 - return outcome.streamSuccess(); - }, + })), }); +const validateInput = z.object({ + id: z.string().min(1).optional(), + email: z.email().optional(), + domain: z.string().min(1).optional(), +}); +const validateFailures = { + emailDomainMismatch: { + description: "邮箱不属于指定域", + schema: emailMismatchSchema, + exitCode: 1, + }, +}; +function validateUser(input: z.output) { + if (input.email !== undefined && input.domain !== undefined) { + const emailDomain = input.email.slice(input.email.lastIndexOf("@") + 1); + if (emailDomain.toLowerCase() !== input.domain.toLowerCase()) { + return { + kind: "emailDomainMismatch" as const, + email: input.email, + domain: input.domain, + }; + } + } + return { kind: "valid" as const }; +} const validate = define.command("validate")({ kind: "command", parent: "users", @@ -98,12 +197,7 @@ const validate = define.command("validate")({ description: "邮箱域名", }, }, - input: z.object({ - id: z.string().min(1).optional(), - email: z.email().optional(), - domain: z.string().min(1).optional(), - }), - // 模式分别校验字段格式;数量约束选择一种标识,域名条件只允许用于邮箱。 + input: validateInput, usageConstraints: [ { kind: "count", fields: ["id", "email"], min: 1, max: 1 }, { @@ -112,31 +206,44 @@ const validate = define.command("validate")({ onMatch: { require: ["email"] }, }, ], - success: { kind: "completion", text: () => text.silent }, - failures: { - emailDomainMismatch: { - description: "邮箱不属于指定域", - schema: z.object({ - email: z.email(), - domain: z.string().min(1), + outputs: define.outputModes("validate", validateInput, (mode) => ({ + default: "text", + modes: { + structured: mode.atomic({ + description: "提供可由程序读取的校验失败", + format: "structured", + success: { kind: "completion" }, + failures: validateFailures, + handler({ input, outcome }) { + const result = validateUser(input); + return result.kind === "valid" + ? outcome.completion() + : outcome.failure.emailDomainMismatch(result); + }, + }), + text: mode.atomic({ + description: "提供可直接阅读的校验结果", + format: "text", + success: { + kind: "completion", + text: () => text.silent, + }, + failures: { + emailDomainMismatch: { + ...validateFailures.emailDomainMismatch, + text: ({ email, domain }) => + text.lines([`邮箱 ${email} 不属于指定域 ${domain}`]), + }, + }, + handler({ input, outcome }) { + const result = validateUser(input); + return result.kind === "valid" + ? outcome.completion() + : outcome.failure.emailDomainMismatch(result); + }, }), - exitCode: 1, - text: ({ email, domain }) => - text.lines([`邮箱 ${email} 不属于指定域 ${domain}`]), }, - }, - handler({ input, outcome }) { - if (input.email !== undefined && input.domain !== undefined) { - const emailDomain = input.email.slice(input.email.lastIndexOf("@") + 1); - if (emailDomain.toLowerCase() !== input.domain.toLowerCase()) { - return outcome.failure.emailDomainMismatch({ - email: input.email, - domain: input.domain, - }); - } - } - return outcome.completion(); - }, + })), }); export const cli = define({ @@ -148,6 +255,8 @@ export const cli = define({ commands: "命令", arguments: "参数", options: "选项", + schema: "契约查询", + examples: "调用示例", }, wording: { commandPlaceholder: "命令", @@ -159,10 +268,9 @@ export const cli = define({ present: "{field} 已提供", }, }), - // 启用 text 会要求每个结果变体都有呈现器,新增变体时也会检查。 - output: outputCapability({ - defaultFormat: "text", - compatibilityFlags: { "--json": "structured" }, + version: versionCapability({ value: text.line(coreMetadata.version) }), + schema: schemaCapability({ + description: text.line("查询命令输入、结果契约与线格式。"), }), usageFailureExitCode: 64, commands: { diff --git a/packages/example/src/main.ts b/packages/example/src/main.ts index ad47943..36ce214 100644 --- a/packages/example/src/main.ts +++ b/packages/example/src/main.ts @@ -4,11 +4,16 @@ import { nodeCliOutput } from "@ykdz/cli-contract/node"; import { cli } from "./index.ts"; // 换运行环境时替换 write 即可;这里用 Node 辅助函数处理流的背压和写入错误。 -const result = await executeCli(cli, { - invocation: parseCliInvocation(cli, process.argv.slice(2)), - dependencies: { readLogs }, - write: nodeCliOutput({ stdout: process.stdout, stderr: process.stderr }), -}); +const invocation = parseCliInvocation(cli, process.argv.slice(2)); +const write = nodeCliOutput({ stdout: process.stdout, stderr: process.stderr }); +const result = + invocation.kind === "parsed" + ? await executeCli(cli, { + invocation, + dependencies: { readLogs }, + write, + }) + : await executeCli(cli, { invocation, write }); // process.exit() 可能截断流写入;设置 exitCode 让 Node 自然退出。 process.exitCode = result.exitCode; diff --git a/packages/example/test/cli.e2e.ts b/packages/example/test/cli.e2e.ts index 5b90c61..7d5f552 100644 --- a/packages/example/test/cli.e2e.ts +++ b/packages/example/test/cli.e2e.ts @@ -5,6 +5,11 @@ import { createInterface } from "node:readline"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { queryCliContract } from "@ykdz/cli-contract"; +import coreMetadata from "@ykdz/cli-contract/package.json" with { type: "json" }; + +import { cli } from "../src/index.ts"; + function invoke(...argv: string[]) { const result = spawnSync( process.execPath, @@ -16,6 +21,18 @@ function invoke(...argv: string[]) { return result; } +void test("help 与 version 在没有应用输入时经真实进程正常退出", () => { + const help = invoke("--help"); + assert.equal(help.status, 0); + assert.match(help.stdout, /用法/); + assert.equal(help.stderr, ""); + + const version = invoke("--version"); + assert.equal(version.status, 0); + assert.equal(version.stdout, `${coreMetadata.version}\n`); + assert.equal(version.stderr, ""); +}); + void test( "日志 NDJSON 与文本片段都在流结束前保持各自分帧", { timeout: 5000 }, @@ -26,7 +43,7 @@ void test( [ fileURLToPath(new URL("../src/main.ts", import.meta.url)), "logs", - ...(mode === "structured" ? ["--json"] : []), + ...(mode === "structured" ? ["--output", "structured"] : []), ], { stdio: ["pipe", "pipe", "pipe"] }, ); @@ -54,8 +71,9 @@ void test( child.stdin.write("started\n"); if (mode === "structured") { assert.deepEqual(JSON.parse(await nextLine()), { - schemaVersion: "2", + schemaVersion: "3", command: "logs", + outputMode: "structured", kind: "stream", }); assert.deepEqual(JSON.parse(await nextLine()), { @@ -65,7 +83,7 @@ void test( }); assert.equal( stdout, - '{"schemaVersion":"2","command":"logs","kind":"stream"}\n{"kind":"record","variant":"entry","data":{"message":"started"}}\n', + '{"schemaVersion":"3","command":"logs","outputMode":"structured","kind":"stream"}\n{"kind":"record","variant":"entry","data":{"message":"started"}}\n', ); } else { assert.equal(await nextLine(), "started"); @@ -94,7 +112,7 @@ void test( assert.equal( stdout, mode === "structured" - ? '{"schemaVersion":"2","command":"logs","kind":"stream"}\n{"kind":"record","variant":"entry","data":{"message":"started"}}\n{"kind":"record","variant":"entry","data":{"message":"ready"}}\n{"kind":"streamSuccess"}\n' + ? '{"schemaVersion":"3","command":"logs","outputMode":"structured","kind":"stream"}\n{"kind":"record","variant":"entry","data":{"message":"started"}}\n{"kind":"record","variant":"entry","data":{"message":"ready"}}\n{"kind":"streamSuccess"}\n' : "started\nready\n", ); assert.equal(stderr, ""); @@ -112,6 +130,106 @@ void test("查询成功以文本行组写入 stdout 并退出零", () => { assert.equal(result.stderr, ""); }); +void test("声明示例驱动真实查询进程并在帮助中原样展示", () => { + const lookup = cli.manifest.commands.lookup; + if (lookup === undefined || lookup.kind !== "command") { + throw new Error("缺少 lookup 可执行命令清单"); + } + const examples = lookup.examples; + assert.equal(examples?.length, 2); + const defaultExample = examples?.[0]; + const structuredExample = examples?.[1]; + assert.deepEqual(defaultExample?.argv, ["get", "--", "alice"]); + assert.equal(defaultExample?.outputMode, undefined); + assert.deepEqual(structuredExample?.argv, [ + "get", + "--output=structured", + "--", + "alice", + ]); + assert.equal(structuredExample?.outputMode, "structured"); + + const defaultResult = invoke(...(defaultExample?.argv ?? [])); + assert.equal(defaultResult.status, 0); + assert.equal(defaultResult.stdout, "用户信息\nAlice\n"); + assert.equal(defaultResult.stderr, ""); + + const structuredResult = invoke(...(structuredExample?.argv ?? [])); + assert.equal(structuredResult.status, 0); + assert.equal(structuredResult.stderr, ""); + const envelope = JSON.parse(structuredResult.stdout); + assert.equal(envelope.command, "lookup"); + assert.equal(envelope.outputMode, "structured"); + assert.equal(envelope.kind, "data"); + assert.equal(envelope.variant, "found"); + assert.deepEqual(envelope.data, { name: "Alice" }); + + const help = invoke("get", "--help"); + assert.equal(help.status, 0); + assert.equal(help.stderr, ""); + assert.ok(help.stdout.includes("调用示例")); + assert.ok(help.stdout.includes(` ${defaultExample?.shell}`)); + assert.ok(help.stdout.includes(` ${structuredExample?.shell}`)); +}); + +void test("摘要 next 经正式 example 进程完成契约查询且无需业务输入", () => { + const rootDirectory = queryCliContract(cli, []); + assert.equal(rootDirectory.kind, "directory"); + if (rootDirectory.kind !== "directory") return; + const rootResult = invoke("--schema"); + assert.equal(rootResult.status, 0); + assert.equal(rootResult.stderr, ""); + assert.deepEqual(JSON.parse(rootResult.stdout), rootDirectory); + for (const child of rootDirectory.children) { + const childResult = invoke(...child.nextArgv); + assert.equal(childResult.status, 0, JSON.stringify(child.nextArgv)); + assert.equal(childResult.stderr, ""); + assert.deepEqual( + JSON.parse(childResult.stdout), + queryCliContract(cli, child.path), + ); + } + + const summary = queryCliContract(cli, ["get"]); + assert.equal(summary.kind, "summary"); + if (summary.kind !== "summary") return; + + const inputNext = summary.next.find((next) => next.target === "input"); + assert.ok(inputNext); + assert.deepEqual(inputNext.argv, ["get", "--schema=input"]); + const inputResult = invoke(...inputNext.argv); + assert.equal(inputResult.status, 0); + assert.equal(inputResult.stderr, ""); + assert.deepEqual( + JSON.parse(inputResult.stdout), + queryCliContract(cli, ["get"], "input"), + ); + + const successNext = summary.next.find( + (next) => next.target === "success:structured", + ); + assert.ok(successNext); + const successResult = invoke(...successNext.argv); + assert.equal(successResult.status, 0); + assert.equal(successResult.stderr, ""); + assert.deepEqual( + JSON.parse(successResult.stdout), + queryCliContract(cli, ["get"], "success:structured"), + ); + + const help = invoke("get", "--help"); + assert.equal(help.status, 0); + assert.equal(help.stderr, ""); + assert.ok(help.stdout.includes("契约查询")); + assert.ok(help.stdout.includes("--schema=input")); + assert.ok(help.stdout.includes("--schema=success:structured")); + assert.ok(help.stdout.includes(JSON.stringify(successNext.argv))); + assert.equal( + help.stdout.split("查询命令输入、结果契约与线格式。").length - 1, + 1, + ); +}); + void test("标识校验以完成结果正常退出", () => { const result = invoke("validate", "--id", "alice"); assert.equal(result.status, 0); @@ -144,7 +262,7 @@ void test("数量约束同时出现在帮助和进程用法问题中", () => { assert.equal(result.status, 64); assert.equal(result.stdout, ""); assert.deepEqual(JSON.parse(result.stderr), { - schemaVersion: "2", + schemaVersion: "3", command: "validate", kind: "usageFailure", issues: [ @@ -156,7 +274,8 @@ void test("数量约束同时出现在帮助和进程用法问题中", () => { absentFields: [], }, ], - usage: "user validate [--id ] [--email ] [--domain ]", + usage: + "user validate [--output ] [--id ] [--email ] [--domain ]", helpArgv: ["user", "validate", "--help"], }); }); @@ -166,7 +285,7 @@ void test("邮箱域条件在子进程中返回准确的条件失败", () => { assert.equal(result.status, 64); assert.equal(result.stdout, ""); assert.deepEqual(JSON.parse(result.stderr), { - schemaVersion: "2", + schemaVersion: "3", command: "validate", kind: "usageFailure", issues: [ @@ -178,7 +297,8 @@ void test("邮箱域条件在子进程中返回准确的条件失败", () => { presentForbiddenFields: [], }, ], - usage: "user validate [--id ] [--email ] [--domain ]", + usage: + "user validate [--output ] [--id ] [--email ] [--domain ]", helpArgv: ["user", "validate", "--help"], }); }); @@ -190,13 +310,15 @@ void test("指定域不匹配保留具名应用失败与退出状态", () => { "alice@elsewhere.com", "--domain", "example.com", - "--json", + "--output", + "structured", ); assert.equal(result.status, 1); assert.equal(result.stdout, ""); assert.deepEqual(JSON.parse(result.stderr), { - schemaVersion: "2", + schemaVersion: "3", command: "validate", + outputMode: "structured", kind: "failure", variant: "emailDomainMismatch", data: { email: "alice@elsewhere.com", domain: "example.com" }, @@ -204,12 +326,13 @@ void test("指定域不匹配保留具名应用失败与退出状态", () => { }); void test("结构化成功保留具名事实,stderr 为空", () => { - const result = invoke("get", "alice", "--json"); + const result = invoke("get", "alice", "--output", "structured"); assert.equal(result.status, 0); assert.equal(result.stderr, ""); assert.deepEqual(JSON.parse(result.stdout), { - schemaVersion: "2", + schemaVersion: "3", command: "lookup", + outputMode: "structured", kind: "data", variant: "found", data: { name: "Alice" }, @@ -224,12 +347,13 @@ void test("业务失败保留文本空行,写入 stderr 并返回非零退出 }); void test("结构化业务失败保留具名事实,stdout 为空并返回非零退出码", () => { - const result = invoke("get", "bob", "--json"); + const result = invoke("get", "bob", "--output", "structured"); assert.equal(result.status, 1); assert.equal(result.stdout, ""); assert.deepEqual(JSON.parse(result.stderr), { - schemaVersion: "2", + schemaVersion: "3", command: "lookup", + outputMode: "structured", kind: "failure", variant: "notFound", data: { id: "bob" }, @@ -243,12 +367,13 @@ void test("帮助不要求领域输入,展示机械用法和显式标题", () assert.equal(result.stderr, ""); for (const fact of [ "用法", - "user get ", + "user get [--output ] ", "参数", "用户标识", "选项", "--help", - "--json", + "--output", + "structured", ]) { assert.ok(result.stdout.includes(fact), fact); } @@ -272,11 +397,11 @@ void test("缺少参数返回结构化用法失败和准确的帮助调用", () assert.equal(result.status, 64); assert.equal(result.stdout, ""); assert.deepEqual(JSON.parse(result.stderr), { - schemaVersion: "2", + schemaVersion: "3", command: "lookup", kind: "usageFailure", issues: [{ code: "missingRequiredField", field: "id" }], - usage: "user get ", + usage: "user get [--output ] ", helpArgv: ["user", "get", "--help"], }); }); diff --git a/packages/example/test/scenarios.test.ts b/packages/example/test/scenarios.test.ts index e2978ce..32730f5 100644 --- a/packages/example/test/scenarios.test.ts +++ b/packages/example/test/scenarios.test.ts @@ -24,13 +24,16 @@ function isJsonRecord( } const commands = defineCommandScenarios(cli, { - lookup: { argv: ["get", "alice", "--json"], dependencies }, - logs: { argv: ["logs", "--json"], dependencies }, + lookup: { argv: ["get", "alice", "--output", "structured"], dependencies }, + logs: { argv: ["logs", "--output", "structured"], dependencies }, validate: { argv: ["validate", "--id", "alice"], dependencies }, }); const failures = defineFailureScenarios(cli, { lookup: { - notFound: { argv: ["get", "bob", "--json"], dependencies }, + notFound: { + argv: ["get", "bob", "--output", "structured"], + dependencies, + }, }, validate: { emailDomainMismatch: { @@ -91,7 +94,12 @@ for (const [command, scenario] of Object.entries(commands)) { .split("\n") .map((line) => JSON.parse(line)), [ - { schemaVersion: "2", command: "logs", kind: "stream" }, + { + schemaVersion: "3", + command: "logs", + outputMode: "structured", + kind: "stream", + }, { kind: "record", variant: "entry", data: { message: "started" } }, { kind: "record", variant: "entry", data: { message: "ready" } }, { kind: "streamSuccess" }, @@ -100,8 +108,9 @@ for (const [command, scenario] of Object.entries(commands)) { return; } assert.deepEqual(JSON.parse(capture.stdout), { - schemaVersion: "2", + schemaVersion: "3", command: "lookup", + outputMode: "structured", kind: "data", variant: "found", data: { name: "Alice" }, @@ -205,8 +214,9 @@ for (const [command, scenarios] of Object.entries(failures)) { assert.equal(capture.stdout, ""); if (command === "lookup") { assert.deepEqual(JSON.parse(capture.stderr), { - schemaVersion: "2", + schemaVersion: "3", command: "lookup", + outputMode: "structured", kind: "failure", variant: "notFound", data: { id: "bob" }, diff --git a/packages/example/test/typecheck.ts b/packages/example/test/typecheck.ts index fafcd3c..643a712 100644 --- a/packages/example/test/typecheck.ts +++ b/packages/example/test/typecheck.ts @@ -1,9 +1,4 @@ -import { - defineCli, - helpCapability, - outputCapability, - text, -} from "@ykdz/cli-contract"; +import { defineCli, helpCapability, text } from "@ykdz/cli-contract"; import { defineCommandScenarios, defineFailureScenarios, @@ -19,10 +14,16 @@ export function rejectedDeclarations() { // @ts-expect-error 已声明的 notFound 缺少触发场景。 defineFailureScenarios(cli, { lookup: {} }); + const sharedInput0 = z.object({}); defineCli()({ root: "check", - help: helpCapability(), - output: outputCapability({ defaultFormat: "text" }), + help: helpCapability({ + wording: { + choices: "(choices: {choices})", + default: "(default: {value})", + }, + }), + usageFailureExitCode: 64, commands: { check: { @@ -30,70 +31,162 @@ export function rejectedDeclarations() { name: "check", description: "检查类型", fields: {}, - input: z.object({}), - success: { - kind: "data", - variants: { - found: { - description: "找到结果", - schema: z.object({ name: z.string() }), - exitCode: 0, - text: ({ name }) => text.line(name), - }, - }, - }, - failures: { - notFound: { - description: "结果不存在", - schema: z.object({ id: z.string() }), - exitCode: 1, - text: ({ id }) => text.line(id), + input: sharedInput0, + + outputs: defineCli().outputModes("check", sharedInput0, (mode) => ({ + default: "text", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { + kind: "data", + variants: { + found: { + description: "找到结果", + schema: z.object({ name: z.string() }), + exitCode: 0, + }, + }, + }, + failures: { + notFound: { + description: "结果不存在", + schema: z.object({ id: z.string() }), + exitCode: 1, + }, + }, + handler({ outcome }) { + // @ts-expect-error 不能调用未声明的失败。 + outcome.failure.unknown({}); + // @ts-expect-error 变体载荷必须符合契约模式推导的类型。 + outcome.data.found({ name: 42 }); + return outcome.data.found({ name: "Alice" }); + }, + }), + text: mode.atomic({ + description: "文本输出", + format: "text", + success: { + kind: "data", + variants: { + found: { + description: "找到结果", + schema: z.object({ name: z.string() }), + exitCode: 0, + text: ({ name }) => text.line(name), + }, + }, + }, + failures: { + notFound: { + description: "结果不存在", + schema: z.object({ id: z.string() }), + exitCode: 1, + text: ({ id }) => text.line(id), + }, + }, + handler({ outcome }) { + // @ts-expect-error 不能调用未声明的失败。 + outcome.failure.unknown({}); + // @ts-expect-error 变体载荷必须符合契约模式推导的类型。 + outcome.data.found({ name: 42 }); + return outcome.data.found({ name: "Alice" }); + }, + }), }, - }, - handler({ outcome }) { - // @ts-expect-error 不能调用未声明的失败。 - outcome.failure.unknown({}); - // @ts-expect-error 变体载荷必须符合契约模式推导的类型。 - outcome.data.found({ name: 42 }); - return outcome.data.found({ name: "Alice" }); - }, + })), }, }, }); - // @ts-expect-error 启用 text 后不能遗漏成功的文本呈现器。 + const sharedInput1 = z.object({}); defineCli()({ root: "missingCompletion", - help: helpCapability(), - output: outputCapability({ defaultFormat: "text" }), + help: helpCapability({ + wording: { + choices: "(choices: {choices})", + default: "(default: {value})", + }, + }), + usageFailureExitCode: 64, commands: { missingCompletion: { kind: "rootCommand", name: "check", description: "检查类型", - input: z.object({}), - success: { kind: "completion" }, - failures: {}, - handler: ({ outcome }) => outcome.completion(), + input: sharedInput1, + + outputs: defineCli().outputModes( + "missingCompletion", + sharedInput1, + (mode) => ({ + default: "text", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { kind: "completion" }, + failures: {}, + handler: ({ outcome }) => outcome.completion(), + }), + // @ts-expect-error 文本完成方式必须声明当前 mode 的 presenter。 + text: mode.atomic({ + description: "文本输出", + format: "text", + success: { kind: "completion" }, + failures: {}, + handler: () => undefined as never, + }), + }, + }), + ), }, }, }); + const sharedInput2 = z.object({}); defineCli()({ root: "validCompletion", - help: helpCapability(), - output: outputCapability({ defaultFormat: "text" }), + help: helpCapability({ + wording: { + choices: "(choices: {choices})", + default: "(default: {value})", + }, + }), + usageFailureExitCode: 64, commands: { validCompletion: { kind: "rootCommand", name: "check", description: "检查类型", - input: z.object({}), - success: { kind: "completion", text: () => text.silent }, - failures: {}, - handler: ({ outcome }) => outcome.completion(), + input: sharedInput2, + + outputs: defineCli().outputModes( + "validCompletion", + sharedInput2, + (mode) => ({ + default: "text", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { kind: "completion" }, + failures: {}, + handler: ({ outcome }) => outcome.completion(), + }), + text: mode.atomic({ + description: "文本输出", + format: "text", + success: { kind: "completion", text: () => text.silent }, + failures: {}, + handler: ({ outcome }) => outcome.completion(), + }), + }, + }), + ), }, }, }); diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json index 2c6e2d4..4084f5e 100644 --- a/packages/example/tsconfig.json +++ b/packages/example/tsconfig.json @@ -1,13 +1,8 @@ { - "extends": "@cli-contract/typescript-config/base.json", + "extends": "@cli-contract/tooling-config/node.json", "compilerOptions": { "declaration": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "rewriteRelativeImportExtensions": true, - "target": "es2023", - "types": ["node"] + "declarationMap": false }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] } diff --git a/packages/example/turbo.json b/packages/example/turbo.json index 7cf9112..9ff4e37 100644 --- a/packages/example/turbo.json +++ b/packages/example/turbo.json @@ -2,6 +2,79 @@ "extends": ["//"], "tags": ["contract-example"], "tasks": { - "typecheck": { "dependsOn": ["^build"] } + "authoring:fixture:example": { + "inputs": [], + "outputs": [], + "cache": false, + "tags": ["work:on-demand"] + }, + "build": { + "dependsOn": ["^build"], + "inputs": [], + "outputs": ["dist/**"], + "cache": false, + "tags": ["work:preparation"] + }, + "format:check": { + "inputs": [], + "outputs": [], + "tags": ["quality:light"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "inputs": [], + "outputs": [], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { + "dependsOn": [], + "inputs": [], + "outputs": [], + "tags": ["quality:light"] + }, + "lint:fix": { + "inputs": [], + "outputs": [], + "cache": false, + "tags": ["work:repair"] + }, + "release:verify": { + "inputs": [], + "outputs": [], + "cache": false, + "tags": ["work:release"] + }, + "start": { + "inputs": [], + "outputs": [], + "cache": false, + "tags": ["work:development"] + }, + "test": { + "dependsOn": ["build"], + "inputs": [], + "outputs": [], + "tags": ["quality:light"] + }, + "test:e2e": { + "dependsOn": ["build"], + "inputs": [], + "outputs": [], + "cache": false, + "tags": ["quality:heavy"] + }, + "typecheck": { + "dependsOn": ["^build"], + "inputs": [], + "outputs": [], + "tags": ["quality:light"] + }, + "verify:published": { + "inputs": [], + "outputs": [], + "cache": false, + "tags": ["work:on-demand"] + } } } diff --git a/packages/lib/package.json b/packages/lib/package.json index 67b50c8..ac4c204 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -35,17 +35,22 @@ "./node": { "types": "./dist/node.d.ts", "default": "./dist/node.js" + }, + "./package.json": { + "types": "./package.json", + "default": "./package.json" } }, "publishConfig": { "access": "public" }, "scripts": { - "build": "tsc -p tsconfig.build.json --pretty false && node ../../scripts/link-example.ts", - "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", - "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", + "authoring:fixture:hierarchy-text-coverage": "cat test/hierarchy-text-coverage.typecheck.ts", + "build": "tsc -p tsconfig.build.json --pretty false && link-example", + "format:check": "oxfmt --list-different .", + "format:write": "oxfmt --write .", + "lint": "oxlint --quiet --format=unix --ignore-pattern node_modules .", + "lint:fix": "oxlint --format=unix . --fix", "test": "node --test --conditions=source test/*.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, @@ -54,7 +59,7 @@ "@types/node": "catalog:" }, "devDependencies": { - "@cli-contract/typescript-config": "workspace:*", + "@cli-contract/tooling-config": "workspace:*", "@valibot/to-json-schema": "^1.7.1", "ajv": "^8.20.0", "oxfmt": "catalog:", @@ -65,6 +70,6 @@ "zod": "^4.6.5" }, "engines": { - "node": ">=24" + "node": "^26.13.0" } } diff --git a/packages/lib/src/cli-contract.ts b/packages/lib/src/cli-contract.ts index 4f1c9b7..b82810a 100644 --- a/packages/lib/src/cli-contract.ts +++ b/packages/lib/src/cli-contract.ts @@ -2,7 +2,8 @@ import { compileAtomicVariants, type RuntimeAtomicVariant, } from "#/atomic-variant-compiler"; -import type { CommandUsage } from "#/cli-invocation"; +import { validateCliInput } from "#/cli-input-validation"; +import { parseCliInvocation, type CommandUsage } from "#/cli-invocation"; import { ContractDefinitionError, type ContractDefinitionIssue, @@ -25,10 +26,10 @@ import { copySingleLineText, isSingleLineText } from "#/description"; import type { reservedFieldNames } from "#/field-identity"; import { deepFreeze, isPlainRecord } from "#/json-value"; import type { - AtomicTextPresenter, CompletionFact, CompletionTextPresenter, CompletionOutcome, + DataVariantDefinition, DataFactUnion, DataOutcome, DataVariantDefinitions, @@ -39,7 +40,7 @@ import type { TextLine, TextLines, StreamOutcome, - StreamTextPresenter, + StreamRecordDefinition, StreamRecordDefinitions, StreamRecordFactUnion, StreamSuccessFact, @@ -106,12 +107,29 @@ export type { } from "#/outcome-fact"; const cliContractType = Symbol("CliContract.type"); +const outputModesType = Symbol("OutputModes.type"); const helpCapabilities = new WeakSet(); +const schemaCapabilities = new WeakSet(); const invalidHelpFactWordingValues = new WeakMap< object, ReadonlyMap >(); -const outputCapabilities = new WeakSet(); +const outputModes = new WeakSet(); +const outputModeMetadata = new WeakMap< + object, + Readonly<{ + readonly command: string; + readonly input: ContractSchema; + }> +>(); +const outputModeDefinitions = new WeakSet(); +const outputModeDefinitionMetadata = new WeakMap< + object, + Readonly<{ + readonly command: string; + readonly input: ContractSchema; + }> +>(); const versionCapabilities = new WeakSet(); const compiledCliContracts = new WeakMap(); @@ -121,7 +139,7 @@ const definitionPropertyKeys = { "root", "help", "version", - "output", + "schema", "usageFailureExitCode", "commands", ], @@ -141,11 +159,10 @@ const definitionPropertyKeys = { "aliases", "description", "helpSupplement", + "examples", "fields", "input", - "success", - "failures", - "handler", + "outputs", "usageConstraints", ], field: { @@ -159,19 +176,26 @@ const definitionPropertyKeys = { option: ["kind", "longOption", "shortAlias", "description"], positional: ["kind", "description"], }, + example: ["input", "outputMode"], usage: usageConstraintDefinitionPropertyKeys, - success: { - completion: ["kind", "text"], - data: ["kind", "variants"], - stream: ["kind", "records", "text"], - }, atomicVariant: ["description", "schema", "exitCode", "text"], streamRecord: ["description", "schema", "text"], capability: { help: ["shortAlias", "headings", "wording"], version: ["value", "description", "shortAlias"], - output: ["defaultFormat", "text", "compatibilityFlags"], + schema: ["description"], }, + outputModes: ["default", "modes"], + outputMode: [ + "delivery", + "description", + "format", + "success", + "failures", + "handler", + ], + atomicSuccess: ["kind", "text", "variants"], + streamSuccess: ["kind", "text", "records"], } as const; export interface HelpCapability { @@ -193,6 +217,8 @@ export interface HelpHeadings { readonly arguments?: string; readonly options?: string; readonly constraints?: string; + readonly schema?: string; + readonly examples?: string; readonly supplement?: string; } @@ -290,30 +316,724 @@ export interface VersionCapabilityDefinition { readonly shortAlias?: "-V"; } +/** 根级契约发现能力;说明文案一次装配后会投影到全树帮助。 */ +export interface SchemaCapability { + readonly kind: "schemaCapability"; + readonly longOption: "--schema"; + readonly description: string; + readonly [cliContractType]: "schema"; +} + +export interface SchemaCapabilityDefinition { + readonly description: TextLine; +} + export type OutputFormat = "structured" | "text"; +export type OutputDelivery = "atomic" | "stream"; -export interface OutputCapability< - Formats extends readonly OutputFormat[] = readonly OutputFormat[], +/** 与可执行命令同位的调用样本;省略 outputMode 表示采用命令默认方式。 */ +export interface CommandExampleDefinition< + Input = unknown, + OutputMode extends string = string, > { - readonly kind: "outputCapability"; - readonly defaultFormat: Formats[number]; - readonly formats: Formats; - readonly compatibilityFlags: Readonly>; - readonly [cliContractType]: "output"; + readonly input: Input; + readonly outputMode?: OutputMode; } -type OutputCapabilityDefinition = Readonly<{ - readonly defaultFormat: OutputFormat; - readonly text?: boolean; - readonly compatibilityFlags?: Readonly>; +type CommandExampleInputContext = Readonly< + Record +>; + +type CommandDefinitionExampleContext = Readonly<{ + readonly examples?: readonly CommandExampleDefinition[]; +}>; + +type OutputFormatTextEnabled = + Format extends "text" ? true : false; + +type OutputModeSetDeclaration = Readonly<{ + readonly default?: string; + readonly modes: Readonly>; +}>; + +type OutputModeKeys = Extract; + +type OutputModeDefaultContract = + Definition extends Readonly<{ + readonly modes: infer Modes; + readonly default: infer Default; + }> + ? IsUnion> extends true + ? Default extends OutputModeKeys + ? unknown + : ContractTypeError< + "invalidDefaultOutputMode", + Readonly<{ readonly default: Default }> + > + : Default extends OutputModeKeys + ? unknown + : ContractTypeError< + "invalidDefaultOutputMode", + Readonly<{ readonly default: Default }> + > + : Definition extends Readonly<{ readonly modes: infer Modes }> + ? IsUnion> extends true + ? ContractTypeError< + "invalidDefaultOutputMode", + Readonly<{ readonly default: undefined }> + > + : unknown + : ContractTypeError< + "invalidOutputModes", + Readonly<{ readonly reason: "missingModes" }> + >; + +export interface OutputModesDefinition< + Command extends string = string, + InputSchema extends ContractSchema = ContractSchema, + Modes extends Readonly> = Readonly< + Record + >, + Default extends OutputModeKeys = OutputModeKeys, +> { + readonly default: Default; + readonly modes: Modes; + readonly [outputModesType]: Readonly<{ + readonly command: Command; + readonly input: InputSchema; + }>; +} + +export interface AtomicOutputModeDefinition< + Command extends string, + InputSchema extends ContractSchema, + Format extends OutputFormat, + Success, + Failures extends object, +> { + readonly delivery: "atomic"; + readonly description: string; + readonly format: Format; + readonly success: Success; + readonly failures: Failures; + readonly handler: unknown; + readonly [cliContractType]: Readonly<{ + readonly command: Command; + readonly input: InputSchema; + }>; +} + +export interface StreamOutputModeDefinition< + Command extends string, + InputSchema extends ContractSchema, + Format extends OutputFormat, + Records extends StreamRecordDefinitions, + Failures extends object, +> { + readonly delivery: "stream"; + readonly description: string; + readonly format: Format; + readonly success: Readonly<{ + readonly kind: "stream"; + readonly records: Records; + readonly text?: CompletionTextPresenter; + }>; + readonly failures: Failures; + readonly handler: unknown; + readonly [cliContractType]: Readonly<{ + readonly command: Command; + readonly input: InputSchema; + }>; +} + +export type OutputModeDefinition = + | AtomicOutputModeDefinition< + string, + ContractSchema, + OutputFormat, + | Readonly<{ readonly kind: "completion" }> + | Readonly<{ + readonly kind: "data"; + readonly variants: DataVariantDefinitions; + }>, + object + > + | StreamOutputModeDefinition< + string, + ContractSchema, + OutputFormat, + StreamRecordDefinitions, + object + >; + +export type OutputModeMap = Readonly>; + +type AtomicModeFailures< + _Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = FailureVariantDefinitionsForSchemas; + +type AtomicModeFailuresContract< + Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = AtomicModeFailures & + ClosedAtomicVariants> & + FailureVariantTextContract< + Command, + AtomicModeFailures, + TextEnabled + >; + +type AtomicModeDataSuccess< + _Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = Readonly<{ + readonly kind: "data"; + readonly variants: DataVariantDefinitionsForSchemas; +}>; + +type AtomicModeDataSuccessContract< + Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = AtomicModeDataSuccess & + Readonly<{ + readonly variants: ClosedAtomicVariants< + DataVariantDefinitionsForSchemas + > & + DataVariantTextContract< + Command, + DataVariantDefinitionsForSchemas, + TextEnabled + >; + }>; + +type StreamRecordDefinitionsForSchemas< + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = Readonly<{ + readonly [Variant in keyof Schemas]: Schemas[Variant] extends ContractSchema + ? StreamRecordDefinition< + ContractSchemaInput, + TextEnabled, + ContractSchemaOutput + > & + Readonly<{ readonly schema: Schemas[Variant] }> + : never; }>; -type OutputFormatsFor = - Definition["defaultFormat"] extends "text" - ? readonly ["structured", "text"] - : Definition["text"] extends true - ? readonly ["structured", "text"] - : readonly ["structured"]; +type StreamModeRecords< + _Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = StreamRecordDefinitionsForSchemas; + +type StreamModeRecordsContract< + Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = StreamModeRecords & + ClosedStreamRecords> & + StreamRecordNameContract< + Command, + StreamRecordDefinitionsForSchemas + > & + StreamRecordTextContract< + Command, + StreamRecordDefinitionsForSchemas, + TextEnabled + >; + +type StreamModeFailures< + Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = AtomicModeFailures; + +type StreamModeFailuresContract< + Command extends string, + Schemas extends VariantSchemaMap, + TextEnabled extends boolean, +> = AtomicModeFailuresContract; + +type ClosedRawAtomicSuccess = RejectUnknownProperties< + Success, + Success extends { readonly kind: "completion" } + ? "kind" | "text" + : "kind" | "text" | "variants" +> & + (Success extends { readonly kind: "data"; readonly variants: infer Variants } + ? { readonly variants: ClosedAtomicVariants } + : unknown); + +export interface OutputModeFactory< + Command extends string, + Dependencies, + InputSchema extends ContractSchema, +> { + readonly atomic: { + < + const Format extends OutputFormat, + const RawDefinition extends object, + const RawSuccess extends object, + const RawFailures extends object, + const Failures extends VariantSchemaMap = Readonly>, + >( + definition: RawDefinition & + Readonly<{ + readonly description: string; + readonly format: Format; + readonly success: Readonly<{ + readonly kind: "completion"; + readonly text?: CompletionTextPresenter; + }> & + RawSuccess; + readonly failures: AtomicModeFailures & + RawFailures; + readonly handler: ( + context: Readonly<{ + readonly input: ContractSchemaOutput; + readonly dependencies: Dependencies; + readonly outcome: CompletionOutcome & + FailureOutcome< + Command, + AtomicModeFailures + >; + }>, + ) => + | CompletionFact + | FailureFactUnion< + Command, + AtomicModeFailures + > + | Promise< + | CompletionFact + | FailureFactUnion< + Command, + AtomicModeFailures + > + >; + }> & + RejectUnknownProperties< + NoInfer, + Exclude< + (typeof definitionPropertyKeys.outputMode)[number], + "delivery" + > + > & + Readonly<{ + readonly success: ClosedRawAtomicSuccess>; + readonly failures: ClosedAtomicVariants>; + }> & + Readonly<{ + readonly failures: AtomicModeFailuresContract< + Command, + NoInfer, + boolean + >; + }>, + ): AtomicOutputModeDefinition< + Command, + InputSchema, + Format, + RawSuccess, + RawFailures + >; + < + const Format extends OutputFormat, + const RawDefinition extends object, + const RawSuccess extends object, + const RawFailures extends object, + const Schemas extends VariantSchemaMap, + const Failures extends VariantSchemaMap = Readonly>, + >( + definition: RawDefinition & + Readonly<{ + readonly description: string; + readonly format: Format; + readonly success: AtomicModeDataSuccess & + RawSuccess; + readonly failures: AtomicModeFailures & + RawFailures; + readonly handler: ( + context: Readonly<{ + readonly input: ContractSchemaOutput; + readonly dependencies: Dependencies; + readonly outcome: DataOutcome< + Command, + DataVariantDefinitionsForSchemas + > & + FailureOutcome< + Command, + AtomicModeFailures + >; + }>, + ) => + | DataFactUnion< + Command, + DataVariantDefinitionsForSchemas + > + | FailureFactUnion< + Command, + AtomicModeFailures + > + | Promise< + | DataFactUnion< + Command, + DataVariantDefinitionsForSchemas + > + | FailureFactUnion< + Command, + AtomicModeFailures + > + >; + }> & + RejectUnknownProperties< + NoInfer, + Exclude< + (typeof definitionPropertyKeys.outputMode)[number], + "delivery" + > + > & + Readonly<{ + readonly success: ClosedRawAtomicSuccess>; + readonly failures: ClosedAtomicVariants>; + }> & + Readonly<{ + readonly success: AtomicModeDataSuccessContract< + Command, + NoInfer, + boolean + >; + readonly failures: AtomicModeFailuresContract< + Command, + NoInfer, + boolean + >; + }>, + ): AtomicOutputModeDefinition< + Command, + InputSchema, + Format, + RawSuccess, + RawFailures + >; + }; + readonly stream: < + const Format extends OutputFormat, + const RawDefinition extends object, + const RawSuccess extends object, + const Schemas extends VariantSchemaMap, + const Failures extends VariantSchemaMap = Readonly>, + >( + definition: RawDefinition & + Readonly<{ + readonly description: string; + readonly format: Format; + readonly success: Readonly<{ + readonly kind: "stream"; + readonly records: StreamModeRecords< + Command, + Schemas, + OutputFormatTextEnabled extends false ? false : boolean + >; + }> & + RawSuccess & + (OutputFormatTextEnabled extends true + ? Readonly<{ readonly text?: CompletionTextPresenter }> + : Readonly<{ readonly text?: never }>); + readonly failures: StreamModeFailures< + Command, + Failures, + OutputFormatTextEnabled + >; + readonly handler: ( + context: StreamHandlerContext< + Command, + Dependencies, + ContractSchemaOutput, + StreamModeRecords< + Command, + Schemas, + OutputFormatTextEnabled extends false ? false : boolean + >, + StreamModeFailures< + Command, + Failures, + OutputFormatTextEnabled + > + >, + ) => AsyncGenerator< + StreamRecordFactUnion< + Command, + StreamModeRecords< + Command, + Schemas, + OutputFormatTextEnabled extends false ? false : boolean + > + >, + | StreamSuccessFact + | FailureFactUnion< + Command, + StreamModeFailures< + Command, + Failures, + OutputFormatTextEnabled + > + >, + void + >; + }> & + RejectUnknownProperties< + NoInfer, + Exclude<(typeof definitionPropertyKeys.outputMode)[number], "delivery"> + > & + Readonly<{ + readonly success: ClosedRawStreamSuccess>; + }> & + Readonly<{ + readonly success: Readonly<{ + readonly records: StreamModeRecordsContract< + Command, + NoInfer, + OutputFormatTextEnabled extends false ? false : boolean + >; + }> & + (OutputFormatTextEnabled extends true + ? Readonly<{ readonly text?: CompletionTextPresenter }> + : Readonly<{ readonly text?: never }>); + readonly failures: StreamModeFailuresContract< + Command, + NoInfer, + OutputFormatTextEnabled + >; + }>, + ) => Omit< + StreamOutputModeDefinition< + Command, + InputSchema, + Format, + RawStreamRecords, + StreamModeFailures> + >, + "success" + > & + Readonly<{ readonly success: RawSuccess }>; +} + +type OutputModesDefault = + Definition extends Readonly<{ + readonly modes: infer Modes; + readonly default?: infer Default; + }> + ? Exclude extends never + ? OutputModeKeys + : Extract, OutputModeKeys> + : never; + +type ModeVariantNames = + Mode extends Readonly<{ + readonly success: Readonly<{ + readonly kind: "data"; + readonly variants: infer Variants; + }>; + }> + ? InvalidVariantNames extends never + ? ModeFailureNames + : DataVariantNameContract + : ModeFailureNames; + +type ModeFailureNames = + Mode extends Readonly<{ readonly failures: infer Failures }> + ? FailureVariantNameContract + : unknown; + +// 原子方式的呈现器覆盖按具名方式映射检查,以便诊断直接关联对应声明。 +type VariantsWithText = { + [Variant in keyof Variants & string]: "text" extends keyof Variants[Variant] + ? Variant + : never; +}[keyof Variants & string]; +type VariantPresenterContract< + Command extends string, + Location extends "data" | "failure", + Variants, + Format, +> = Format extends "text" + ? NamedVariantTextContract + : VariantsWithText extends never + ? unknown + : ContractTypeError< + "unexpectedTextPresenter", + Readonly<{ + readonly command: Command; + readonly location: Location; + readonly variant: VariantsWithText; + readonly unexpected: "text"; + }> + >; +type SuccessPresenterContract< + Command extends string, + Success, + Format, +> = Success extends { readonly kind: "completion" } + ? Format extends "text" + ? Success extends { readonly text: unknown } + ? unknown + : ContractTypeError< + "missingTextPresenter", + Readonly<{ + readonly command: Command; + readonly location: "completion"; + readonly missing: "text"; + }> + > + : "text" extends keyof Success + ? ContractTypeError< + "unexpectedTextPresenter", + Readonly<{ + readonly command: Command; + readonly location: "completion"; + readonly unexpected: "text"; + }> + > + : unknown + : Success extends { readonly kind: "data"; readonly variants: infer Variants } + ? VariantPresenterContract + : unknown; +type AtomicPresenterContract = Mode extends { + readonly delivery: "atomic"; + readonly format: infer Format; + readonly success: infer Success; + readonly failures: infer Failures; +} + ? SuccessPresenterContract< + Command, + Success, + Format + > extends ContractTypeError + ? SuccessPresenterContract + : VariantPresenterContract + : unknown; +type RawStreamRecords = Success extends { + readonly records: infer Records extends StreamRecordDefinitions; +} + ? Records + : never; +type ClosedRawStreamSuccess = RejectUnknownProperties< + Success, + "kind" | "records" | "text" +> & + (Success extends { readonly records: infer Records } + ? { readonly records: ClosedStreamRecords } + : unknown); +type StreamPresenterContract = Mode extends { + readonly delivery: "stream"; + readonly format: "text"; + readonly success: infer Success; +} + ? Success extends { readonly records: infer Records } + ? MissingVariantTextPresenters extends never + ? Success extends { readonly text: unknown } + ? unknown + : ContractTypeError< + "missingTextPresenter", + Readonly<{ + readonly command: Command; + readonly location: "streamSuccess"; + readonly missing: "text"; + }> + > + : StreamRecordTextContract + : unknown + : unknown; + +type LocalModeContract = + ModeVariantNames extends ContractTypeError + ? ModeVariantNames + : Mode extends { readonly delivery: "stream" } + ? StreamPresenterContract + : AtomicPresenterContract; + +type OutputModesVariantNames = + Definition extends Readonly<{ readonly modes: infer Modes }> + ? Readonly<{ + readonly modes: { + readonly [Mode in keyof Modes]: LocalModeContract< + Command, + Modes[Mode] + >; + }; + }> + : unknown; + +type OutputModesTypeContract = + OutputModeDefaultContract & + (Definition extends Readonly<{ readonly modes: infer Modes }> + ? [keyof Modes] extends [never] + ? ContractTypeError< + "invalidOutputModes", + Readonly<{ readonly reason: "emptyModes" }> + > + : Exclude extends never + ? unknown + : ContractTypeError< + "invalidOutputModes", + Readonly<{ readonly reason: "invalidMode" }> + > + : never); + +type AtomicOutputModeResult = + Mode extends Readonly<{ + readonly delivery: "atomic"; + readonly success: infer Success; + readonly failures: infer Failures extends object; + }> + ? Success extends Readonly<{ readonly kind: "completion" }> + ? CompletionFact | FailureFactUnion + : Success extends Readonly<{ + readonly kind: "data"; + readonly variants: infer Variants extends DataVariantDefinitions; + }> + ? + | DataFactUnion + | FailureFactUnion + : never + : never; + +type StreamOutputModeResult = + Mode extends Readonly<{ + readonly delivery: "stream"; + readonly failures: infer Failures extends object; + }> + ? StreamSuccessFact | FailureFactUnion + : never; + +type OutputModesResult = + Outputs extends Readonly<{ readonly modes: infer Modes }> + ? { + [Mode in keyof Modes]: + | AtomicOutputModeResult + | StreamOutputModeResult; + }[keyof Modes] + : never; + +type OutputModesFromDeclaration< + Command extends string, + InputSchema extends ContractSchema, + Definition, +> = + Definition extends Readonly<{ readonly modes: infer Modes }> + ? OutputModesDefinition< + Command, + InputSchema, + Extract, + Extract, OutputModeKeys> + > + : never; type LowercaseLetter = | "a" @@ -838,30 +1558,6 @@ type ClosedUsageConstraints< : never : unknown; -type ClosedSuccessDefinition = - Success extends Readonly<{ - readonly kind: "data"; - readonly variants: infer Variants; - }> - ? RejectUnknownProperties< - Success, - (typeof definitionPropertyKeys.success.data)[number] - > & - Readonly<{ readonly variants: ClosedAtomicVariants }> - : Success extends Readonly<{ - readonly kind: "stream"; - readonly records: infer Records; - }> - ? RejectUnknownProperties< - Success, - (typeof definitionPropertyKeys.success.stream)[number] - > & - Readonly<{ readonly records: ClosedStreamRecords }> - : RejectUnknownProperties< - Success, - (typeof definitionPropertyKeys.success.completion)[number] - >; - type ClosedCommandDefinition< Definition, Command extends string = string, @@ -869,12 +1565,6 @@ type ClosedCommandDefinition< Definition, (typeof definitionPropertyKeys.command)[number] > & - (Definition extends Readonly<{ readonly success: infer Success }> - ? Readonly<{ readonly success: ClosedSuccessDefinition }> - : unknown) & - (Definition extends Readonly<{ readonly failures: infer Failures }> - ? Readonly<{ readonly failures: ClosedAtomicVariants }> - : unknown) & (Definition extends Readonly<{ readonly fields: infer Fields }> ? Readonly<{ readonly fields: ClosedFieldDefinitions }> : unknown) & @@ -1255,31 +1945,6 @@ export interface StreamHandlerContext< FailureOutcome; } -type CompletionSuccessDefinition< - Command extends string, - TextEnabled extends boolean, -> = TextEnabled extends true - ? - | Readonly<{ - readonly kind: "completion"; - readonly text: CompletionTextPresenter; - }> - | (Readonly<{ readonly kind: "completion" }> & - ContractTypeError< - "missingTextPresenter", - Readonly<{ - readonly command: Command; - readonly location: "completion"; - readonly missing: "text"; - }> - >) - : TextEnabled extends false - ? Readonly<{ readonly kind: "completion"; readonly text?: never }> - : Readonly<{ - readonly kind: "completion"; - readonly text?: CompletionTextPresenter; - }>; - type MissingVariantTextPresenters = { [Variant in keyof Variants & string]: Variants[Variant] extends Readonly<{ readonly text: unknown; @@ -1322,44 +1987,34 @@ type DataVariantTextContract< DataVariantDefinitions >; -type AtomicTextPresenterContract = [ - TextEnabled, -] extends [true] - ? Readonly<{ readonly text: AtomicTextPresenter }> - : [TextEnabled] extends [false] - ? Readonly<{ readonly text?: never }> - : Readonly<{ readonly text?: AtomicTextPresenter }>; - type AtomicVariantDefinitionForSchema< Schema extends ContractSchema, TextEnabled extends boolean, -> = Readonly<{ - readonly description: string; - readonly schema: Schema; - readonly exitCode: number; -}> & - AtomicTextPresenterContract, TextEnabled>; +> = DataVariantDefinition< + ContractSchemaInput, + TextEnabled, + ContractSchemaOutput +> & + Readonly<{ readonly schema: Schema }>; -type VariantSchemaMap = Record; +type VariantSchemaMap = object; type DataVariantDefinitionsForSchemas< Schemas extends VariantSchemaMap, TextEnabled extends boolean, > = Readonly<{ - readonly [Variant in keyof Schemas]: AtomicVariantDefinitionForSchema< - Schemas[Variant], - TextEnabled - >; + readonly [Variant in keyof Schemas]: Schemas[Variant] extends ContractSchema + ? AtomicVariantDefinitionForSchema + : never; }>; type FailureVariantDefinitionsForSchemas< Schemas extends VariantSchemaMap, TextEnabled extends boolean, > = Readonly<{ - readonly [Variant in keyof Schemas]: AtomicVariantDefinitionForSchema< - Schemas[Variant], - TextEnabled - >; + readonly [Variant in keyof Schemas]: Schemas[Variant] extends ContractSchema + ? AtomicVariantDefinitionForSchema + : never; }>; type FailureVariantTextContract< @@ -1399,179 +2054,20 @@ type StreamRecordNameContract = }> >; -type StreamRecordDefinitionForSchema< - Schema extends ContractSchema, - TextEnabled extends boolean, -> = Readonly<{ - readonly description: string; - readonly schema: Schema; -}> & - ([TextEnabled] extends [false] - ? Readonly<{ readonly text?: never }> - : Readonly<{ - readonly text?: StreamTextPresenter>; - }>); - -type StreamRecordDefinitionsForSchemas< - Schemas extends VariantSchemaMap, - TextEnabled extends boolean, -> = Readonly<{ - readonly [Variant in keyof Schemas]: StreamRecordDefinitionForSchema< - Schemas[Variant], - TextEnabled - >; -}>; - -export interface CompletionRootCommandDefinition< - Command extends string, - Dependencies, - Failures extends FailureVariantDefinitions = Readonly>, - Fields extends FieldDefinitions = Readonly>, - InputSchema extends ContractSchema = ContractSchema, - TextEnabled extends boolean = boolean, -> { - readonly kind: "rootCommand"; - readonly name: string; - readonly description: string; - readonly helpSupplement?: TextLines; - readonly fields?: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly input: InputSchema & - RawFieldInputContract; - readonly success: CompletionSuccessDefinition; - readonly failures: Failures & - ClosedAtomicVariants & - FailureVariantNameContract & - FailureVariantTextContract; - readonly handler: ( - context: CompletionHandlerContext< - Command, - Dependencies, - Failures, - ContractSchemaOutput - >, - ) => - | CompletionFact - | FailureFactUnion - | Promise | FailureFactUnion>; -} - -export interface DataRootCommandDefinition< - Command extends string, - Dependencies, - Fields extends FieldDefinitions, - InputSchema extends ContractSchema, - Variants extends DataVariantDefinitions, - Failures extends FailureVariantDefinitions, -> { - readonly kind: "rootCommand"; - readonly name: string; - readonly description: string; - readonly helpSupplement?: TextLines; - readonly fields: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly input: InputSchema & - RawFieldInputContract; - readonly success: Readonly<{ - readonly kind: "data"; - readonly variants: Variants & - ClosedAtomicVariants & - DataVariantNameContract; - }>; - readonly failures: Failures & - ClosedAtomicVariants & - FailureVariantNameContract; - readonly handler: ( - context: DataHandlerContext< - Command, - Dependencies, - ContractSchemaOutput, - Variants, - Failures - >, - ) => - | DataFactUnion - | FailureFactUnion - | Promise< - DataFactUnion | FailureFactUnion - >; -} - -export interface StreamRootCommandDefinition< - Command extends string, - Dependencies, - Fields extends FieldDefinitions, - InputSchema extends ContractSchema, - Records extends StreamRecordDefinitions, - Failures extends FailureVariantDefinitions, - TextEnabled extends boolean = boolean, +export interface RootCommandDefinition< + Command extends string = string, + _Dependencies = unknown, > { readonly kind: "rootCommand"; readonly name: string; readonly description: string; readonly helpSupplement?: TextLines; - readonly fields?: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly input: InputSchema & - RawFieldInputContract; - readonly success: Readonly<{ - readonly kind: "stream"; - readonly records: Records & - StreamRecordDefinitions & - ClosedStreamRecords & - StreamRecordNameContract; - }> & - (TextEnabled extends true - ? Readonly<{ readonly text: CompletionTextPresenter }> - : TextEnabled extends false - ? Readonly<{ readonly text?: never }> - : Readonly<{ readonly text?: CompletionTextPresenter }>); - readonly failures: Failures & - ClosedAtomicVariants & - FailureVariantNameContract; - readonly handler: ( - context: StreamHandlerContext< - Command, - Dependencies, - ContractSchemaOutput, - Records, - Failures - >, - ) => AsyncGenerator< - StreamRecordFactUnion, - StreamSuccessFact | FailureFactUnion, - void - >; + readonly examples?: readonly CommandExampleDefinition[]; + readonly fields?: FieldDefinitions; + readonly input: ContractSchema; + readonly outputs: OutputModesDefinition; } -export type RootCommandDefinition = - | CompletionRootCommandDefinition< - Command, - Dependencies, - FailureVariantDefinitions, - FieldDefinitions, - ContractSchema - > - | DataRootCommandDefinition< - Command, - Dependencies, - FieldDefinitions, - ContractSchema, - DataVariantDefinitions, - FailureVariantDefinitions - > - | StreamRootCommandDefinition< - Command, - Dependencies, - FieldDefinitions, - ContractSchema, - StreamRecordDefinitions, - FailureVariantDefinitions - >; - export interface RootGroupDefinition { readonly kind: "rootGroup"; readonly parent?: never; @@ -1616,64 +2112,20 @@ export type CommandCombination< export type ExecutableCommandDefinition< Command extends string = string, Dependencies = unknown, -> = - | (Omit< - CompletionRootCommandDefinition< - Command, - Dependencies, - FailureVariantDefinitions, - FieldDefinitions, - ContractSchema - >, - "kind" - > & - HierarchyCommandFacts) - | (Omit< - DataRootCommandDefinition< - Command, - Dependencies, - FieldDefinitions, - ContractSchema, - DataVariantDefinitions, - FailureVariantDefinitions - >, - "kind" - > & - HierarchyCommandFacts) - | (Omit< - StreamRootCommandDefinition< - Command, - Dependencies, - FieldDefinitions, - ContractSchema, - StreamRecordDefinitions, - FailureVariantDefinitions - >, - "kind" - > & - HierarchyCommandFacts); +> = Omit, "kind"> & + HierarchyCommandFacts; export type CommandNodeDefinition = | CommandGroupDefinition + | RootCommandDefinition | (HierarchyCommandFacts & Readonly<{ readonly name: string; readonly description: string; readonly fields?: unknown; readonly input: ContractSchema; - readonly success: - | Readonly<{ readonly kind: "completion" }> - | Readonly<{ - readonly kind: "data"; - readonly variants: DataVariantDefinitions; - }> - | Readonly<{ - readonly kind: "stream"; - readonly records: StreamRecordDefinitions; - readonly text?: CompletionTextPresenter; - }>; - readonly failures: FailureVariantDefinitions; - readonly handler: unknown; + readonly outputs: OutputModesDefinition; + readonly examples?: readonly CommandExampleDefinition[]; }>) | RootGroupDefinition; @@ -1692,38 +2144,15 @@ type HierarchyRawInput = { : never; }[keyof Commands]; -type HierarchyResult = { - [Command in keyof Commands]: Commands[Command] extends Readonly<{ - readonly kind: "command"; - readonly success: Readonly<{ - readonly kind: "data"; - readonly variants: infer Variants extends DataVariantDefinitions; - }>; - readonly failures: infer Failures extends FailureVariantDefinitions; - }> - ? - | DataFactUnion, Variants> - | FailureFactUnion, Failures> - : Commands[Command] extends Readonly<{ - readonly kind: "command"; - readonly success: Readonly<{ readonly kind: "completion" }>; - readonly failures: infer Failures extends FailureVariantDefinitions; - }> - ? - | CompletionFact> - | FailureFactUnion, Failures> - : Commands[Command] extends Readonly<{ - readonly kind: "command"; - readonly success: Readonly<{ - readonly kind: "stream"; - readonly records: StreamRecordDefinitions; - }>; - readonly failures: infer Failures extends FailureVariantDefinitions; - }> - ? - | StreamSuccessFact> - | FailureFactUnion, Failures> - : never; +type HierarchyResult = { + [Command in keyof Commands]: Commands[Command] extends Readonly<{ + readonly kind: "command"; + readonly outputs: infer Outputs extends OutputModesDefinition< + Extract + >; + }> + ? OutputModesResult, Outputs> + : never; }[keyof Commands]; type HierarchyCommandFields = @@ -3019,100 +3448,20 @@ type RootCliDefinitionBase = RootIdentityContract & { readonly root: Root; readonly help: HelpCapability; readonly version?: VersionCapability; - readonly output: OutputCapability; + readonly schema?: SchemaCapability; readonly usageFailureExitCode: number; }; -export type CompletionRootCliDefinition< - Root extends string, - Dependencies, - Failures extends FailureVariantDefinitions = Readonly>, - Fields extends FieldDefinitions = Readonly>, - InputSchema extends ContractSchema = ContractSchema, - TextEnabled extends boolean = boolean, -> = RootCliDefinitionBase & - Readonly<{ - readonly commands: Readonly<{ - readonly [Command in Root]: CompletionRootCommandDefinition< - Command, - Dependencies, - Failures, - Fields, - InputSchema, - TextEnabled - >; - }>; - }>; - -export type DataRootCliDefinition< - Root extends string, - Dependencies, - Fields extends FieldDefinitions, - InputSchema extends ContractSchema, - Variants extends DataVariantDefinitions, - Failures extends FailureVariantDefinitions, -> = RootCliDefinitionBase & - Readonly<{ - readonly commands: Readonly<{ - readonly [Command in Root]: DataRootCommandDefinition< - Command, - Dependencies, - Fields, - InputSchema, - Variants, - Failures - >; - }>; - }>; - -export type StreamRootCliDefinition< +export type RootCliDefinition< Root extends string, Dependencies, - Fields extends FieldDefinitions, - InputSchema extends ContractSchema, - Records extends StreamRecordDefinitions, - Failures extends FailureVariantDefinitions, - TextEnabled extends boolean = boolean, > = RootCliDefinitionBase & Readonly<{ readonly commands: Readonly<{ - readonly [Command in Root]: StreamRootCommandDefinition< - Command, - Dependencies, - Fields, - InputSchema, - Records, - Failures, - TextEnabled - >; + readonly [Command in Root]: RootCommandDefinition; }>; }>; -export type RootCliDefinition = - | CompletionRootCliDefinition< - Root, - Dependencies, - FailureVariantDefinitions, - FieldDefinitions, - ContractSchema - > - | DataRootCliDefinition< - Root, - Dependencies, - FieldDefinitions, - ContractSchema, - DataVariantDefinitions, - FailureVariantDefinitions - > - | StreamRootCliDefinition< - Root, - Dependencies, - FieldDefinitions, - ContractSchema, - StreamRecordDefinitions, - FailureVariantDefinitions - >; - interface FieldGrammarBase { readonly key: Field; readonly description: string; @@ -3173,9 +3522,25 @@ export interface RootCommandGrammar { readonly helpSupplement?: readonly string[]; readonly fields: readonly FieldGrammar[]; readonly usageConstraints?: readonly UsageConstraintGrammar[]; + readonly outputs: OutputModesGrammar; readonly usage: CommandUsage; } +export interface OutputModesGrammar { + readonly selector: "--output"; + readonly default: string; + readonly modes: Readonly< + Record< + string, + Readonly<{ + readonly description: string; + readonly delivery: OutputDelivery; + readonly format: OutputFormat; + }> + > + >; +} + interface GroupGrammarBase { readonly id: Command; readonly name: string; @@ -3205,6 +3570,7 @@ export interface ExecutableCommandGrammar< readonly parent: string; readonly fields: readonly FieldGrammar[]; readonly usageConstraints?: readonly UsageConstraintGrammar[]; + readonly outputs: OutputModesGrammar; } export type CommandGrammar = @@ -3236,11 +3602,9 @@ export interface CliGrammar< readonly value: string; readonly description?: string; }>; - readonly output: Readonly<{ - readonly defaultFormat: OutputFormat; - readonly formats: readonly OutputFormat[]; - readonly selector?: "--output-format"; - readonly compatibilityFlags?: Readonly>; + readonly schema?: Readonly<{ + readonly longOption: "--schema"; + readonly description: string; }>; }>; } @@ -3269,16 +3633,29 @@ export type CommandSuccessManifest = readonly text?: Readonly<{ readonly recordFraming: "fragment" }>; }>; +export interface OutputModeManifest { + readonly description: string; + readonly delivery: OutputDelivery; + readonly format: OutputFormat; + readonly success: CommandSuccessManifest; + readonly failures: Readonly>; +} + +export interface OutputModesManifest { + readonly default: string; + readonly modes: Readonly>; +} + export interface RootCommandManifest { readonly kind: "rootCommand"; readonly name: string; readonly description: string; readonly helpSupplement?: readonly string[]; + readonly examples?: readonly CommandExampleManifest[]; readonly fields: readonly FieldGrammar[]; readonly usageConstraints?: readonly UsageConstraintGrammar[]; readonly input: SchemaManifest; - readonly success: CommandSuccessManifest; - readonly failures: Readonly>; + readonly outputs: OutputModesManifest; } export interface CommandGroupManifest { @@ -3300,13 +3677,20 @@ export interface ExecutableCommandManifest extends Omit< readonly helpSupplement?: readonly string[]; } +/** 经正式解析和输入验证的示例事实;shell 为包含根命令名的 POSIX 调用行。 */ +export interface CommandExampleManifest { + readonly argv: readonly string[]; + readonly shell: string; + readonly outputMode?: string; +} + export type { CountUsageConstraintGrammar, UsageConstraintGrammar }; export interface CliManifest< Root extends string, RootKind extends CliRootKind = CliRootKind, > { - readonly schemaVersion: "4"; + readonly schemaVersion: "5"; readonly root: Root; readonly commands: RootKind extends "rootGroup" ? Readonly> @@ -3321,7 +3705,7 @@ export interface CliManifest< : CommandWireManifest; } -export interface CommandWireManifest { +export interface OutputModeWireManifest { readonly completion?: JsonObject; readonly data?: Readonly>; readonly failure?: Readonly>; @@ -3333,6 +3717,10 @@ export interface CommandWireManifest { }>; } +export type CommandWireManifest = Readonly< + Record +>; + type HierarchyGrammarNode = Node extends Readonly<{ readonly kind: "commandGroup"; @@ -3387,7 +3775,7 @@ type HierarchyExecutableKeys = { }[keyof Commands]; export interface HierarchyCliManifest { - readonly schemaVersion: "4"; + readonly schemaVersion: "5"; readonly root: Root; readonly commands: Readonly<{ [Command in keyof Commands]: HierarchyManifestNode; @@ -3427,6 +3815,17 @@ export interface CliContract< }>; } +export type AnyCliContract = Readonly<{ + readonly [cliContractType]: Readonly<{ + readonly dependencies: unknown; + readonly rawInput: unknown; + readonly result: OutcomeFact; + readonly root: string; + readonly commands: string; + readonly usageFacts: unknown; + }>; +}>; + export type CliContractUsageFacts = Contract extends Readonly<{ readonly [cliContractType]: Readonly<{ @@ -3437,123 +3836,36 @@ export type CliContractUsageFacts = : never; export type CliContractDependencies = - Contract extends CliContract< - infer _Root, - infer Dependencies, - unknown, - OutcomeFact, - CliRootKind, - infer _Commands - > + Contract extends Readonly<{ + readonly [cliContractType]: Readonly<{ + readonly dependencies: infer Dependencies; + }>; + }> ? Dependencies : never; export type CliContractRawInput = - Contract extends CliContract< - infer _Root, - unknown, - infer RawInput, - OutcomeFact, - CliRootKind, - infer _Commands - > + Contract extends Readonly<{ + readonly [cliContractType]: Readonly<{ readonly rawInput: infer RawInput }>; + }> ? RawInput : never; export type CliContractResult = - Contract extends CliContract< - infer _Root, - unknown, - unknown, - infer Result, - CliRootKind, - infer _Commands - > + Contract extends Readonly<{ + readonly [cliContractType]: Readonly<{ readonly result: infer Result }>; + }> ? Result : never; export type CliContractRoot = - Contract extends CliContract< - infer Root, - unknown, - unknown, - OutcomeFact, - CliRootKind, - infer Commands - > - ? [Commands] extends [never] - ? Root - : Extract - : never; - -type HasTextPresenter = - Value extends Readonly<{ readonly text: unknown }> ? true : false; - -type PresentTextVariants = { - [Variant in keyof Variants & string]: HasTextPresenter< - Variants[Variant] - > extends true - ? Variant - : never; -}[keyof Variants & string]; - -type UnexpectedVariantTextIssue< - Command extends string, - Location extends "data" | "failure" | "record", - Variants, -> = [PresentTextVariants] extends [never] - ? never - : ContractTypeError< - "unexpectedTextPresenter", - Readonly<{ - readonly command: Command; - readonly location: Location; - readonly variant: PresentTextVariants; - readonly unexpected: "text"; - }> - >; - -type UnexpectedSuccessTextIssue< - Command extends string, - Location extends "completion" | "streamSuccess", - Success, -> = - HasTextPresenter extends true - ? ContractTypeError< - "unexpectedTextPresenter", - Readonly<{ - readonly command: Command; - readonly location: Location; - readonly unexpected: "text"; - }> - > - : never; - -type UnexpectedHierarchyTextIssues = { - [Command in keyof Commands & string]: Commands[Command] extends Readonly<{ - readonly kind: "command" | "rootCommand"; - readonly success: infer Success; - readonly failures: infer Failures; + Contract extends Readonly<{ + readonly [cliContractType]: Readonly<{ + readonly commands: infer Commands; + }>; }> - ? Success extends Readonly<{ readonly kind: "completion" }> - ? - | UnexpectedSuccessTextIssue - | UnexpectedVariantTextIssue - : Success extends Readonly<{ readonly variants: infer Variants }> - ? - | UnexpectedVariantTextIssue - | UnexpectedVariantTextIssue - : Success extends Readonly<{ - readonly kind: "stream"; - readonly records: infer Records; - }> - ? - | UnexpectedSuccessTextIssue - | UnexpectedVariantTextIssue - | UnexpectedVariantTextIssue - : never + ? Extract : never; -}[keyof Commands & string]; type HierarchyUsageValidation = [ InvalidCommandUsageConstraintIssues, @@ -3563,78 +3875,6 @@ type HierarchyUsageValidation = [ : readonly [HierarchyInputIssues] : readonly [InvalidCommandUsageConstraintIssues]; -type HierarchyCommandTextContract = - Definition extends Readonly<{ - readonly success: infer Success; - readonly failures: infer Failures; - }> - ? Success extends Readonly<{ readonly kind: "completion" }> - ? FailureVariantTextContract & - (HasTextPresenter extends true - ? unknown - : ContractTypeError< - "missingTextPresenter", - Readonly<{ - readonly command: Command; - readonly location: "completion"; - readonly missing: "text"; - }> - >) - : Success extends Readonly<{ - readonly kind: "data"; - readonly variants: infer Variants; - }> - ? DataVariantTextContract & - FailureVariantTextContract - : Success extends Readonly<{ - readonly kind: "stream"; - readonly records: infer Records; - }> - ? StreamRecordTextContract & - FailureVariantTextContract & - (HasTextPresenter extends true - ? unknown - : ContractTypeError< - "missingTextPresenter", - Readonly<{ - readonly command: Command; - readonly location: "streamSuccess"; - readonly missing: "text"; - }> - >) - : unknown - : unknown; - -type HierarchyTextIssues = { - [ - Command in keyof Commands & string - ]: Commands[Command] extends HierarchyCommandTextContract< - Command, - Commands[Command] - > - ? never - : HierarchyCommandTextContract; -}[keyof Commands & string]; - -type HierarchyTextValidation = [ - HierarchyTextIssues, -] extends [never] - ? unknown - : HierarchyTextIssues; - -type HierarchyOutputValidation = - Output extends OutputCapability - ? [Formats] extends [readonly ["structured", "text"]] - ? [HierarchyTextIssues] extends [never] - ? HierarchyUsageValidation - : readonly [HierarchyTextIssues] - : [Formats] extends [readonly ["structured"]] - ? [UnexpectedHierarchyTextIssues] extends [never] - ? HierarchyUsageValidation - : readonly [UnexpectedHierarchyTextIssues] - : HierarchyUsageValidation - : HierarchyUsageValidation; - type ValidationIntersection = Validation extends readonly [] ? unknown @@ -3642,24 +3882,6 @@ type ValidationIntersection = ? Error : never; -type HierarchyContract = [ - TextEnabledForOutput, -] extends [true] - ? HierarchyTextValidation & - ValidationIntersection> - : ValidationIntersection>; - -type TextEnabledForOutput = - Output extends OutputCapability - ? [Formats] extends [never] - ? boolean - : [Formats] extends [readonly ["structured", "text"]] - ? true - : [Formats] extends [readonly ["structured"]] - ? false - : boolean - : boolean; - type EmptyRawObject = Readonly>; type DistributedInputStringKeys = Input extends unknown @@ -3690,63 +3912,6 @@ type RootFallbackRawFieldInputContract< : MissingRootFallbackFieldsContract : RawFieldInputContract; -type RootFallbackKind = "completion" | "data" | "stream"; - -type RootFallbackHandlerContext< - Command extends string, - Dependencies, - InputSchema extends ContractSchema, - Variants extends DataVariantDefinitions, - Records extends StreamRecordDefinitions, - Failures extends FailureVariantDefinitions, - Kind extends RootFallbackKind, -> = { - readonly completion: CompletionHandlerContext< - Command, - Dependencies, - Failures, - ContractSchemaOutput - >; - readonly data: DataHandlerContext< - Command, - Dependencies, - ContractSchemaOutput, - Variants, - Failures - >; - readonly stream: StreamHandlerContext< - Command, - Dependencies, - ContractSchemaOutput, - Records, - Failures - >; -}[Kind]; - -type RootFallbackHandlerResult< - Command extends string, - Variants extends DataVariantDefinitions, - Records extends StreamRecordDefinitions, - Failures extends FailureVariantDefinitions, - Kind extends RootFallbackKind, -> = { - readonly completion: - | CompletionFact - | FailureFactUnion - | Promise | FailureFactUnion>; - readonly data: - | DataFactUnion - | FailureFactUnion - | Promise< - DataFactUnion | FailureFactUnion - >; - readonly stream: AsyncGenerator< - StreamRecordFactUnion, - StreamSuccessFact | FailureFactUnion, - void - >; -}[Kind]; - type RootNodeFromCommands = Commands extends Readonly> ? Node : never; @@ -3759,241 +3924,183 @@ type RootCommandRawInput = type RootCommandResult = RootNodeFromCommands extends Readonly<{ - readonly success: infer Success; - readonly failures: infer Failures extends FailureVariantDefinitions; + readonly outputs: infer Outputs; }> - ? Success extends Readonly<{ readonly kind: "completion" }> - ? CompletionFact | FailureFactUnion - : Success extends Readonly<{ - readonly kind: "data"; - readonly variants: infer Variants extends DataVariantDefinitions; - }> - ? DataFactUnion | FailureFactUnion - : Success extends Readonly<{ readonly kind: "stream" }> - ? StreamSuccessFact | FailureFactUnion - : never + ? OutputModesResult : never; -type RootFallbackCliDefinition< - Root extends string, - _Dependencies, +type ExampleFields = + Node extends Readonly<{ + readonly fields: infer Fields extends FieldDefinitions; + }> + ? Fields + : Readonly>; + +type CommandExampleOutputModeContract< + Command extends string, + Outputs, + Example, +> = "outputMode" extends keyof Example + ? Example extends Readonly<{ readonly outputMode?: infer Mode }> + ? Exclude extends OutputModeKeys< + NoInfer extends Readonly<{ readonly modes: infer Modes }> + ? Modes + : never + > + ? unknown + : ContractTypeError< + "invalidCommandExampleOutputMode", + Readonly<{ + readonly command: Command; + readonly outputMode: Exclude; + readonly available: OutputModeKeys< + NoInfer extends Readonly<{ readonly modes: infer Modes }> + ? Modes + : never + >; + }> + > + : unknown + : unknown; + +type CommandExampleContract< + Command extends string, + Example, Fields extends FieldDefinitions, InputSchema extends ContractSchema, - VariantSchemas extends VariantSchemaMap, - Variants, - RecordSchemas extends VariantSchemaMap, - Records, - FailureSchemas extends VariantSchemaMap, - Failures, - TextEnabled extends boolean, -> = RootCliDefinitionBase & - Readonly<{ - readonly commands: Readonly<{ - readonly [Command in Root]: Readonly<{ - readonly kind: "rootCommand"; - readonly name: string; - readonly description: string; - readonly helpSupplement?: TextLines; - }> & + Outputs, +> = + Example extends Readonly<{ readonly input: infer Input }> + ? Example & + RejectUnknownProperties< + Example, + (typeof definitionPropertyKeys.example)[number] + > & Readonly<{ - readonly input: InputSchema & - RootFallbackRawFieldInputContract; - readonly failures: Failures & - FailureVariantDefinitionsForSchemas & - FailureVariantNameContract & - FailureVariantTextContract; + readonly input: Input & + ContractSchemaInput> & + RawFieldInput> & + RejectUnknownProperties< + Input, + Extract, string> + >; }> & - ( - | Readonly<{ - readonly fields: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly success: Readonly<{ - readonly kind: "data"; - readonly variants: Variants & - DataVariantDefinitionsForSchemas & - DataVariantNameContract & - DataVariantTextContract; - }>; - }> - | Readonly<{ - readonly fields?: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly success: CompletionSuccessDefinition; - }> - | Readonly<{ - readonly fields?: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly success: - | (Readonly<{ - readonly kind: "stream"; - readonly records: Records & - StreamRecordDefinitionsForSchemas< - RecordSchemas, - boolean - > & - StreamRecordNameContract; - readonly text: CompletionTextPresenter; - }> & - StreamRecordTextContract) - | (Readonly<{ - readonly kind: "stream"; - readonly records: Records & - StreamRecordDefinitionsForSchemas< - RecordSchemas, - boolean - > & - StreamRecordNameContract; - readonly text?: never; - }> & - ([TextEnabled] extends [true] - ? ContractTypeError< - "missingTextPresenter", - Readonly<{ - readonly command: Command; - readonly location: "streamSuccess"; - readonly missing: "text"; - }> - > - : unknown)); - }> - ); - }>; - }>; - -type RootFallbackHandlerDefinition< - Root extends string, - Dependencies, - InputSchema extends ContractSchema, - Variants extends DataVariantDefinitions, - Records extends StreamRecordDefinitions, - Failures extends FailureVariantDefinitions, - Kind extends RootFallbackKind, -> = Readonly<{ - readonly commands: Readonly<{ - readonly [Command in Root]: Readonly<{ - readonly success: Readonly<{ readonly kind: Kind }>; - readonly handler: ( - context: RootFallbackHandlerContext< - Command, - Dependencies, - InputSchema, - Variants, - Records, - Failures, - NoInfer - >, - ) => RootFallbackHandlerResult< - Command, - Variants, - Records, - Failures, - NoInfer + CommandExampleOutputModeContract + : ContractTypeError< + "invalidCommandExampleShape", + Readonly<{ readonly command: Command; readonly member: "input" }> >; - }>; - }>; -}>; -type RootFallbackNodeDefinition< - Root extends string, - Dependencies, +type CommandExamplesContract< + Command extends string, + Node, Fields extends FieldDefinitions, InputSchema extends ContractSchema, - VariantSchemas extends VariantSchemaMap, - Variants, - RecordSchemas extends VariantSchemaMap, - Records, - FailureSchemas extends VariantSchemaMap, - Failures, - TextEnabled extends boolean, - Kind extends RootFallbackKind, -> = (RootFallbackCliDefinition< - Root, - Dependencies, - Fields, - InputSchema, - VariantSchemas, - Variants, - RecordSchemas, - Records, - FailureSchemas, - Failures, - TextEnabled -> & - RootFallbackHandlerDefinition< - Root, - Dependencies, - InputSchema, - DataVariantDefinitionsForSchemas, - StreamRecordDefinitionsForSchemas, - FailureVariantDefinitionsForSchemas, - Kind - >)["commands"][Root]; + Outputs, +> = + Node extends Readonly<{ + readonly examples: infer Examples extends readonly unknown[]; + }> + ? Readonly<{ + readonly examples: readonly CommandExampleContract< + Command, + Examples[number], + NoInfer, + NoInfer, + NoInfer + >[]; + }> + : unknown; + +type ExecutableNodeContract = + Node extends Readonly<{ + readonly kind: "command" | "rootCommand"; + readonly input: infer InputSchema extends ContractSchema; + readonly outputs: infer Outputs; + }> + ? Outputs extends OutputModesDefinition + ? Node extends Readonly<{ + readonly fields: infer Fields extends FieldDefinitions; + }> + ? Readonly<{ + readonly fields: CheckedFieldDefinitions & + FieldDefinitionsForInput> & + FieldIdentityContract; + }> & + RawFieldInputContract & + CommandExamplesContract + : Readonly<{ readonly fields?: never }> & + RootFallbackRawFieldInputContract< + Command, + FieldDefinitions, + InputSchema + > & + CommandExamplesContract< + Command, + Node, + ExampleFields, + InputSchema, + Outputs + > + : ContractTypeError< + "outputModesMustMatchCommand", + Readonly<{ readonly command: Command }> + > + : never; + +type ExecutableNodesContract = { + [Command in keyof Commands & string]: Commands[Command] extends Readonly<{ + readonly kind: "command" | "rootCommand"; + }> + ? ExecutableNodeContract + : unknown; +}; /* oxlint-disable typescript/no-unnecessary-type-parameters -- 公共签名的泛型参与嵌套契约约束,不能按单次出现次数机械删除。 */ export interface DefineCli { - /** - * 为命令组创建可执行后代,再把返回记录展开到同一个 `commands` 对象中交给 - * `defineCli()` 装配。后代只声明自己的输入和结果;根级 output 的全树约束在最终 - * 装配处统一判定。 - */ + /** 为命令组创建可执行后代,再把返回记录展开到最终命令树中统一装配。 */ readonly command: ( command: Command, ) => DefineCommand; /** - * 装配根命令或命令组。命令组的后代在 `commands` 中合并后统一检查 - * 根级 output 与每个结果位置的关系;静态事实在这里被拒绝,动态值 - * 继续由定义期的闭合问题说明原因。 + * 为一个可执行命令定义共享输入下的具名输出方式。每种方式由独立的 atomic 或 stream + * 契约构造器创建;输入模式也传入此处,以便处理器明确使用同一命令的验证后输入类型。 */ + readonly outputModes: < + const Command extends string, + InputSchema extends ContractSchema, + const Definition extends OutputModeSetDeclaration, + >( + command: Command, + input: InputSchema, + build: ( + mode: OutputModeFactory, + ) => Definition & + OutputModesTypeContract & + OutputModesVariantNames>, + ) => OutputModesFromDeclaration; + < const Root extends string, - const Fields extends FieldDefinitions, - InputSchema extends ContractSchema, - const VariantSchemas extends VariantSchemaMap, - const Variants, - const RecordSchemas extends VariantSchemaMap, - const Records, - const FailureSchemas extends VariantSchemaMap, - const Failures, - const Output extends OutputCapability, - const Kind extends RootFallbackKind, - const Commands, + const Commands extends Readonly>, const Definition extends object, >( definition: Definition & ClosedRootDefinition & RootCliDefinitionBase & Readonly<{ - readonly output: Output; readonly commands: Commands & ClosedRootCommands & + ExecutableNodesContract & Readonly< Record< Root, - | RootFallbackNodeDefinition< - Root, - Dependencies, - Fields, - InputSchema, - VariantSchemas, - Variants, - RecordSchemas, - Records, - FailureSchemas, - Failures, - boolean, - Kind - > - | RootGroupDefinition + RootGroupDefinition | RootCommandDefinition > >; }> & - (Commands extends Readonly> - ? HierarchyCliDefinition & HierarchyContract - : ValidationIntersection>), + ValidationIntersection>, ): Commands extends Readonly> ? CliContract< Root, @@ -4016,72 +4123,18 @@ export interface DefineCli { } /* oxlint-enable typescript/no-unnecessary-type-parameters */ -type ActualFailureDefinitions = - Failures extends FailureVariantDefinitions ? Failures : never; - -type RootFallbackFieldsProperty< - Command extends string, - Fields extends FieldDefinitions, - InputSchema extends ContractSchema, -> = - | Readonly<{ - readonly fields: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly success: Readonly<{ readonly kind: "data" | "stream" }>; - }> - | Readonly<{ - readonly fields?: CheckedFieldDefinitions & - FieldDefinitionsForInput> & - FieldIdentityContract; - readonly success: Readonly<{ readonly kind: "completion" }>; - }>; - /* oxlint-disable typescript/no-unnecessary-type-parameters -- 公共命令签名用泛型连接字段、模式、结果与 handler。 */ /** 为具名叶命令生成可展开到根契约 `commands` 的定义。 */ export interface DefineCommand { < - const Definition extends object, + const Definition extends object & CommandDefinitionExampleContext, const Parent extends string, - const Fields extends FieldDefinitions, - InputSchema extends ContractSchema, - const VariantSchemas extends VariantSchemaMap, - const Variants, - const RecordSchemas extends VariantSchemaMap, - const Records, - const FailureSchemas extends VariantSchemaMap, - const Failures, - const Kind extends RootFallbackKind, >( definition: Definition & ClosedCommandDefinition & - Omit< - RootFallbackCliDefinition< - Command, - Dependencies, - Fields, - InputSchema, - VariantSchemas, - Variants, - RecordSchemas, - Records, - FailureSchemas, - Failures, - boolean - >["commands"][Command], - "kind" | "fields" - > & - RootFallbackFieldsProperty & - RootFallbackHandlerDefinition< - Command, - Dependencies, - InputSchema, - Variants extends DataVariantDefinitions ? Variants : never, - Records extends StreamRecordDefinitions ? Records : never, - ActualFailureDefinitions, - Kind - >["commands"][Command] & - HierarchyCommandFactsDefinition, + Omit, "kind"> & + HierarchyCommandFactsDefinition & + ExecutableNodeContract, ): CommandCombination; } /* oxlint-enable typescript/no-unnecessary-type-parameters */ @@ -4093,6 +4146,18 @@ interface RuntimeCompiledCli { readonly commands: Readonly>; } +interface EncodedCommandExample { + readonly manifest: CommandExampleManifest; + readonly rawInput: Readonly>; + readonly expectedOutputMode: string; +} + +interface PendingCommandExampleValidation { + readonly command: string; + readonly input: ContractSchema; + readonly examples: readonly EncodedCommandExample[]; +} + type RuntimeCompiledSuccess = | Readonly<{ readonly kind: "completion"; readonly text?: unknown }> | Readonly<{ @@ -4105,6 +4170,43 @@ type RuntimeCompiledSuccess = readonly text?: unknown; }>; +interface RuntimeCompiledResultContract { + readonly success: RuntimeCompiledSuccess; + readonly failures: Readonly>; + readonly handler: unknown; +} + +interface CompiledResultContract { + readonly runtime: RuntimeCompiledResultContract; + readonly manifest: Readonly<{ + readonly success: CommandSuccessManifest; + readonly failures: Readonly>; + }>; + readonly wire: OutputModeWireManifest; +} + +interface RuntimeCompiledOutputMode { + readonly id: string; + readonly description: string; + readonly delivery: OutputDelivery; + readonly format: OutputFormat; + readonly resultContract: RuntimeCompiledResultContract; +} + +interface RuntimeOutputModeDefinition { + readonly delivery: OutputDelivery; + readonly description: string; + readonly format: OutputFormat; + readonly success: RuntimeCompiledSuccess; + readonly failures: FailureVariantDefinitions; + readonly handler: unknown; +} + +interface RuntimeOutputModesDefinition { + readonly default: string; + readonly modes: Readonly>; +} + interface RuntimeCompiledCommandBase { readonly id: string; readonly parent?: string; @@ -4124,9 +4226,9 @@ export interface RuntimeCompiledExecutable extends RuntimeCompiledCommandBase { readonly fields: readonly FieldGrammar[]; readonly usageConstraints: readonly UsageConstraintGrammar[]; readonly input: ContractSchema; - readonly success: RuntimeCompiledSuccess; - readonly failures: Readonly>; - readonly handler: unknown; + readonly defaultOutputMode: string; + readonly outputModes: Readonly>; + readonly examples?: readonly CommandExampleManifest[]; } type RuntimeCompiledCommand = RuntimeCompiledGroup | RuntimeCompiledExecutable; @@ -4147,19 +4249,8 @@ type RuntimeExecutableDefinition = Readonly<{ readonly usageConstraints?: readonly UsageConstraint[]; readonly fields?: FieldDefinitions; readonly input: ContractSchema; - readonly success: - | Readonly<{ readonly kind: "completion" }> - | Readonly<{ - readonly kind: "data"; - readonly variants: DataVariantDefinitions; - }> - | Readonly<{ - readonly kind: "stream"; - readonly records: StreamRecordDefinitions; - readonly text?: CompletionTextPresenter; - }>; - readonly failures: FailureVariantDefinitions; - readonly handler: unknown; + readonly outputs: RuntimeOutputModesDefinition; + readonly examples?: readonly CommandExampleDefinition[]; }>; type RuntimeGroupDefinition = Readonly<{ @@ -4247,6 +4338,8 @@ function copyHelpHeadings(value: unknown): HelpHeadings { "arguments", "options", "constraints", + "schema", + "examples", "supplement", ]); const headings: Record = {}; @@ -4390,6 +4483,34 @@ export function versionCapability< return capability; } +/** + * 在根契约显式装配全树契约发现。说明文案由作者提供一次;核心负责查询事实、帮助和响应。 + */ +export function schemaCapability< + const Definition extends SchemaCapabilityDefinition, +>( + definition: Definition & + RejectUnknownProperties< + Definition, + (typeof definitionPropertyKeys.capability.schema)[number] + >, +): SchemaCapability { + assertKnownCapabilityOptions( + definition, + definitionPropertyKeys.capability.schema, + "schema", + ); + const description = copyVersionTextLine(definition.description); + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- schemaCapability 由本工厂签发并登记到 WeakSet。 + const capability = Object.freeze({ + kind: "schemaCapability" as const, + longOption: "--schema" as const, + description, + }) as SchemaCapability; + schemaCapabilities.add(capability); + return capability; +} + function copyVersionTextLine(value: unknown): string { if ( typeof value !== "object" || @@ -4424,6 +4545,9 @@ function compileControls(definition: RuntimeCliDefinition) { const version = isVersionCapability(definition.version) ? definition.version : undefined; + const schema = isSchemaCapability(definition.schema) + ? definition.schema + : undefined; return deepFreeze({ help: { longOption: definition.help.longOption, @@ -4451,58 +4575,17 @@ function compileControls(definition: RuntimeCliDefinition) { : { description: version.description }), }, }), - output: { - defaultFormat: definition.output.defaultFormat, - formats: definition.output.formats, - ...(definition.output.formats.includes("text") - ? { selector: "--output-format" as const } - : {}), - ...(Object.keys(definition.output.compatibilityFlags).length === 0 - ? {} - : { compatibilityFlags: definition.output.compatibilityFlags }), - }, + ...(schema === undefined + ? {} + : { + schema: { + longOption: "--schema" as const, + description: schema.description, + }, + }), }); } -/** - * 在根契约显式装配一次应用结果的输出格式能力,并自动覆盖整棵命令树。 - * 它把 selector 与兼容 flag 保留为调用控制,不进入命令 input 或 handler;帮助、版本 - * 与用法失败使用各自固定投影。`defaultFormat`、text 支持和兼容映射必须由消费者 - * 显式选择,不能在命令节点另行装配格式或输出 profile。 - */ -export function outputCapability< - const Definition extends OutputCapabilityDefinition, ->( - definition: Definition & - RejectUnknownProperties< - Definition, - (typeof definitionPropertyKeys.capability.output)[number] - >, -): OutputCapability> { - assertKnownCapabilityOptions( - definition, - definitionPropertyKeys.capability.output, - "output", - ); - const formats = - definition.defaultFormat === "text" || definition.text === true - ? (["structured", "text"] as const) - : (["structured"] as const); - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- outputCapability 由本工厂签发并登记到 WeakSet,类型品牌不在运行时输出。 - const capability = Object.freeze({ - kind: "outputCapability" as const, - defaultFormat: definition.defaultFormat, - formats: Object.freeze(formats), - compatibilityFlags: Object.freeze( - definition.compatibilityFlags === undefined - ? {} - : definition.compatibilityFlags, - ), - }) as OutputCapability>; - outputCapabilities.add(capability); - return capability; -} - function assertKnownCapabilityOptions( definition: object, allowed: readonly string[], @@ -4516,9 +4599,7 @@ function assertKnownCapabilityOptions( } /** - * 定义一个闭合 CLI 契约。根级 help 与 output 能力在此处一次装配,并据此约束根命令的结果投影; - * 最终根装配会逐个检查整棵命令树的 completion、data/failure variant、stream record 与 stream success - * 是否满足 text 能力要求的同位 presenter;structured-only 禁止 presenter,`define.command()` 组合也在此检查。 + * 定义一个闭合 CLI 契约。各可执行命令声明具名输出方式及其结果投影。 * 动态声明仍会在定义期报告 `ContractDefinitionError`,不能以它替代静态可判定声明的类型检查。 * @see {@link https://github.com/YKDZ/cli-contract/tree/main/packages/example | 端到端示例} */ @@ -4535,6 +4616,43 @@ export function defineCli(): DefineCli { }), }), }); + Object.defineProperty(define, "outputModes", { + configurable: true, + value: ( + command: string, + input: ContractSchema, + build: (mode: { + readonly atomic: (definition: object) => object; + readonly stream: (definition: object) => object; + }) => OutputModeSetDeclaration, + ) => { + const issueMode = (definition: object, delivery: OutputDelivery) => { + const mode = Object.freeze({ ...definition, delivery }); + outputModeDefinitions.add(mode); + outputModeDefinitionMetadata.set(mode, { command, input }); + return mode; + }; + const modes = build({ + atomic: (definition) => issueMode(definition, "atomic"), + stream: (definition) => issueMode(definition, "stream"), + }); + const modeNames = Object.keys(modes.modes); + const hasDefault = Object.hasOwn(modes, "default"); + const outputDefinition = Object.freeze({ + ...(hasDefault + ? { default: Reflect.get(modes, "default") } + : modeNames.length === 1 + ? { default: modeNames[0] } + : {}), + modes: Object.freeze({ ...modes.modes }), + }); + if (typeof outputDefinition === "object" && outputDefinition !== null) { + outputModes.add(outputDefinition); + outputModeMetadata.set(outputDefinition, { command, input }); + } + return outputDefinition; + }, + }); return define; } @@ -4553,13 +4671,6 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { invalidHelpFactWordingValues.get(definition.help), ); } - if (isTextOutputEnabled(definition.output)) { - for (const slot of ["choices", "default"] as const) { - if (helpWording?.[slot] === undefined) { - reportMissingHelpFactTemplate(issues, definition.root, slot); - } - } - } let orderedIds: readonly string[]; if (isHierarchy) { collectCliBaseIssues(definition, issues); @@ -4591,6 +4702,7 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { RootCommandManifest | CommandGroupManifest | ExecutableCommandManifest > = {}; const wire: Record = {}; + const examplesToValidate: PendingCommandExampleValidation[] = []; for (const id of orderedIds) { // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- orderedIds 来自已验证的命令键或根键。 @@ -4646,26 +4758,15 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 命令组分支已继续下一轮;其余节点按可执行定义编译。 const executable = node as RuntimeExecutableDefinition; - if (typeof executable.handler !== "function") { - issues.push({ code: "invalidCommandHandler", command: id }); - } const input = compileContractSchema( executable.input, { command: id, location: "input" }, issues, ); - const compiledSuccess = compileSuccess( - id, - executable.success, - isTextOutputEnabled(definition.output), - issues, - ); - const compiledFailures = compileFailures(id, executable.failures, issues); - collectTextPresenterIssues( + const compiledOutputs = compileOutputModes( id, - isTextOutputEnabled(definition.output), - executable.success, - executable.failures, + executable.input, + executable.outputs, issues, ); const fields = @@ -4677,6 +4778,36 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { id, issues, ); + const compiledExamples = compileCommandExamples( + id, + executable.examples, + fields, + commandPathTokens(id, definition.commands), + compiledOutputs.default, + compiledOutputs.runtime, + issues, + ); + const manifestExamples = + compiledExamples === undefined + ? undefined + : Object.freeze( + compiledExamples.map(({ manifest: example }) => example), + ); + if (compiledExamples !== undefined) { + examplesToValidate.push({ + command: id, + input: executable.input, + examples: compiledExamples, + }); + } + if (Object.keys(compiledOutputs.runtime).length > 0) { + if (helpWording?.choices === undefined) { + reportMissingHelpFactTemplate(issues, id, "choices"); + } + if (helpWording?.default === undefined) { + reportMissingHelpFactTemplate(issues, id, "default"); + } + } if ( fields.some((field) => field.choices !== undefined) && helpWording?.choices === undefined @@ -4712,6 +4843,7 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { synopsis: createUsageSynopsis( commandPath(id, definition.commands), fields, + true, ), }); runtimeCommands[id] = Object.freeze({ @@ -4726,9 +4858,9 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { usageConstraints, usage, input: executable.input, - success: compiledSuccess.runtime, - failures: compiledFailures.runtime, - handler: executable.handler, + defaultOutputMode: compiledOutputs.default, + outputModes: compiledOutputs.runtime, + ...(manifestExamples === undefined ? {} : { examples: manifestExamples }), }); if (executable.kind === "command") { grammarNodes.push( @@ -4743,6 +4875,7 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { ...(helpSupplement === undefined ? {} : { helpSupplement }), fields, ...(usageConstraints.length === 0 ? {} : { usageConstraints }), + outputs: compiledOutputs.grammar, usage, }), ); @@ -4751,11 +4884,11 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { const manifestDetails = { description: executable.description, ...(helpSupplement === undefined ? {} : { helpSupplement }), + ...(manifestExamples === undefined ? {} : { examples: manifestExamples }), fields, ...(usageConstraints.length === 0 ? {} : { usageConstraints }), input: usageCompilation.inputProjection, - success: compiledSuccess.manifest, - failures: compiledFailures.manifest, + outputs: compiledOutputs.manifest, }; manifestCommands[id] = executable.kind === "command" @@ -4772,12 +4905,7 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { name: executable.name, ...manifestDetails, }); - wire[id] = deepFreeze({ - ...compiledSuccess.wire, - ...(Object.keys(compiledFailures.wire).length === 0 - ? {} - : { failure: compiledFailures.wire }), - }); + wire[id] = compiledOutputs.wire; } if (issues.length > 0) throw new ContractDefinitionError( @@ -4803,6 +4931,10 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { ...(rootCommand.usageConstraints.length === 0 ? {} : { usageConstraints: rootCommand.usageConstraints }), + outputs: outputModesGrammar( + rootCommand.outputModes, + rootCommand.defaultOutputMode, + ), usage: rootCommand.usage, } : { @@ -4822,7 +4954,7 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { controls, }); const manifest = deepFreeze({ - schemaVersion: "4" as const, + schemaVersion: "5" as const, root: definition.root, commands: manifestCommands, controls, @@ -4839,18 +4971,428 @@ function compileCli(definition: RuntimeCliDefinition): CliContract { // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 根命令的 wire 已由同一编译循环写入映射。 wire: isHierarchy ? wire : (wire[definition.root] as CommandWireManifest), }); - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 契约类型品牌由编译器签发,并登记到 compiledCliContracts。 - const contract = Object.freeze({ - grammar, - manifest, - }) as unknown as CliContract; - compiledCliContracts.set(contract, { - contract, - root: definition.root, - usageFailureExitCode: definition.usageFailureExitCode, - commands: deepFreeze(runtimeCommands), + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 契约类型品牌由编译器签发,并登记到 compiledCliContracts。 + const contract = Object.freeze({ + grammar, + manifest, + }) as unknown as CliContract; + compiledCliContracts.set(contract, { + contract, + root: definition.root, + usageFailureExitCode: definition.usageFailureExitCode, + commands: deepFreeze(runtimeCommands), + }); + validateCommandExamples(contract, examplesToValidate, issues); + if (issues.length > 0) { + compiledCliContracts.delete(contract); + throw new ContractDefinitionError( + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- issues.length 已检查为正数。 + issues as [ContractDefinitionIssue, ...ContractDefinitionIssue[]], + ); + } + return contract; +} + +function compileCommandExamples( + command: string, + examples: unknown, + fields: readonly FieldGrammar[], + pathTokens: readonly string[], + defaultOutputMode: string, + availableModes: Readonly>, + issues: ContractDefinitionIssue[], +): readonly EncodedCommandExample[] | undefined { + if (examples === undefined) return undefined; + if (!Array.isArray(examples)) { + issues.push({ + code: "invalidCommandExample", + command, + index: 0, + reason: "invalidShape", + }); + return undefined; + } + if (examples.length === 0) return undefined; + + const encoded: EncodedCommandExample[] = []; + const fieldByKey = new Map(fields.map((field) => [field.key, field])); + examples.forEach((example, index) => { + if (!isPlainRecord(example)) { + issues.push({ + code: "invalidCommandExample", + command, + index, + reason: "invalidShape", + }); + return; + } + const inputDescriptor = Object.getOwnPropertyDescriptor(example, "input"); + if ( + inputDescriptor === undefined || + !("value" in inputDescriptor) || + !isPlainRecord(inputDescriptor.value) + ) { + issues.push({ + code: "invalidCommandExample", + command, + index, + reason: "invalidShape", + }); + return; + } + + const rawInput: Record = Object.create(null); + for (const fieldKey of Reflect.ownKeys(inputDescriptor.value)) { + if (typeof fieldKey !== "string" || !fieldByKey.has(fieldKey)) { + issues.push({ + code: "invalidCommandExample", + command, + index, + reason: "unknownField", + field: String(fieldKey), + }); + return; + } + const valueDescriptor = Object.getOwnPropertyDescriptor( + inputDescriptor.value, + fieldKey, + ); + if (valueDescriptor === undefined || !("value" in valueDescriptor)) { + issues.push({ + code: "invalidCommandExample", + command, + index, + reason: "unencodableFieldValue", + field: fieldKey, + }); + return; + } + rawInput[fieldKey] = valueDescriptor.value; + } + + const hasFixedOutputMode = Object.hasOwn(example, "outputMode"); + const outputModeDescriptor = hasFixedOutputMode + ? Object.getOwnPropertyDescriptor(example, "outputMode") + : undefined; + const outputModeValue = + outputModeDescriptor !== undefined && "value" in outputModeDescriptor + ? outputModeDescriptor.value + : undefined; + if ( + hasFixedOutputMode && + (typeof outputModeValue !== "string" || + !Object.hasOwn(availableModes, outputModeValue)) + ) { + issues.push({ + code: "invalidCommandExample", + command, + index, + reason: "invalidOutputMode", + received: + typeof outputModeValue === "string" + ? outputModeValue + : String(outputModeValue), + }); + return; + } + const fixedOutputMode = + hasFixedOutputMode && typeof outputModeValue === "string" + ? outputModeValue + : undefined; + const expectedOutputMode = fixedOutputMode ?? defaultOutputMode; + const encodedFields = encodeCommandExampleFields( + command, + index, + rawInput, + fields, + issues, + ); + if (encodedFields === undefined) return; + + const outputSelector = + fixedOutputMode !== undefined ? [`--output=${fixedOutputMode}`] : []; + const argv = Object.freeze([ + ...pathTokens.slice(1), + ...outputSelector, + ...encodedFields.options, + ...(encodedFields.positionals.length === 0 + ? [] + : ["--", ...encodedFields.positionals]), + ]); + const shellTokens = [ + ...pathTokens, + ...outputSelector, + ...encodedFields.options, + ]; + if (encodedFields.positionals.length > 0) { + shellTokens.push("--", ...encodedFields.positionals); + } + if (shellTokens.some((token) => !isPosixArgvToken(token))) { + issues.push({ + code: "invalidCommandExample", + command, + index, + reason: "unencodableFieldValue", + }); + return; + } + const manifest: CommandExampleManifest = deepFreeze({ + argv, + shell: shellTokens.map(quotePosixShellToken).join(" "), + ...(fixedOutputMode === undefined ? {} : { outputMode: fixedOutputMode }), + }); + encoded.push({ + manifest, + rawInput: Object.freeze(rawInput), + expectedOutputMode, + }); + }); + return encoded.length === 0 ? undefined : Object.freeze(encoded); +} + +function encodeCommandExampleFields( + command: string, + index: number, + rawInput: Readonly>, + fields: readonly FieldGrammar[], + issues: ContractDefinitionIssue[], +): + | Readonly<{ + readonly options: readonly string[]; + readonly positionals: readonly string[]; + }> + | undefined { + const options: string[] = []; + const positionals: string[] = []; + for (const field of fields) { + if (!Object.hasOwn(rawInput, field.key)) continue; + const value = rawInput[field.key]; + if (field.kind === "flag") { + if (value === true) { + options.push(field.longOption); + } else if (value === false && field.negatedLongOption !== undefined) { + options.push(field.negatedLongOption); + } else { + reportUnencodableCommandExampleField(issues, command, index, field.key); + return undefined; + } + continue; + } + if (field.kind === "valueOption") { + if (typeof value !== "string" || !isPosixArgvToken(value)) { + reportUnencodableCommandExampleField(issues, command, index, field.key); + return undefined; + } + options.push(`${field.longOption}=${value}`); + continue; + } + if (field.kind === "repeatableOption") { + if (!isNonEmptyStringArray(value)) { + reportUnencodableCommandExampleField(issues, command, index, field.key); + return undefined; + } + for (const item of value) { + if (!isPosixArgvToken(item)) { + reportUnencodableCommandExampleField( + issues, + command, + index, + field.key, + ); + return undefined; + } + options.push(`${field.longOption}=${item}`); + } + continue; + } + if (field.kind === "positional") { + if (typeof value !== "string" || !isPosixArgvToken(value)) { + reportUnencodableCommandExampleField(issues, command, index, field.key); + return undefined; + } + positionals.push(value); + continue; + } + if (!isNonEmptyStringArray(value)) { + reportUnencodableCommandExampleField(issues, command, index, field.key); + return undefined; + } + for (const item of value) { + if (!isPosixArgvToken(item)) { + reportUnencodableCommandExampleField(issues, command, index, field.key); + return undefined; + } + positionals.push(item); + } + } + return Object.freeze({ + options: Object.freeze(options), + positionals: Object.freeze(positionals), + }); +} + +function reportUnencodableCommandExampleField( + issues: ContractDefinitionIssue[], + command: string, + index: number, + field: string, +): void { + issues.push({ + code: "invalidCommandExample", + command, + index, + reason: "unencodableFieldValue", + field, }); - return contract; +} + +function isNonEmptyStringArray(value: unknown): value is readonly string[] { + if (!Array.isArray(value) || value.length === 0) return false; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if ( + descriptor === undefined || + !("value" in descriptor) || + typeof descriptor.value !== "string" + ) { + return false; + } + } + return true; +} + +function isPosixArgvToken(value: string): boolean { + if (value.includes("\0")) return false; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false; + } + } + return true; +} + +function quotePosixShellToken(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function validateCommandExamples( + contract: CliContract, + pending: readonly PendingCommandExampleValidation[], + issues: ContractDefinitionIssue[], +): void { + for (const commandExamples of pending) { + for (const [index, example] of commandExamples.examples.entries()) { + const invocation = parseCliInvocation(contract, example.manifest.argv); + if (invocation.kind !== "parsed") { + issues.push({ + code: "invalidCommandExample", + command: commandExamples.command, + index, + reason: "parseDidNotComplete", + received: invocation.kind, + ...(invocation.kind === "usageFailure" + ? { usageIssues: invocation.issues } + : {}), + }); + continue; + } + if (invocation.command !== commandExamples.command) { + issues.push({ + code: "invalidCommandExample", + command: commandExamples.command, + index, + reason: "wrongCommand", + expected: commandExamples.command, + received: invocation.command, + }); + continue; + } + if (invocation.outputMode !== example.expectedOutputMode) { + issues.push({ + code: "invalidCommandExample", + command: commandExamples.command, + index, + reason: "wrongOutputMode", + expected: example.expectedOutputMode, + received: invocation.outputMode, + }); + continue; + } + const mismatchedFields = rawInputMismatchFields( + example.rawInput, + invocation.input, + ); + if (mismatchedFields.length > 0) { + issues.push({ + code: "invalidCommandExample", + command: commandExamples.command, + index, + reason: "rawInputMismatch", + mismatchedFields, + }); + continue; + } + const validation = validateCliInput( + commandExamples.input, + invocation.input, + commandExamples.command, + ); + if (validation.kind === "rejected") { + issues.push({ + code: "invalidCommandExample", + command: commandExamples.command, + index, + reason: "inputRejected", + evidence: validation.evidence, + }); + } + } + } +} + +function rawInputMismatchFields( + expected: Readonly>, + received: Readonly>, +): readonly string[] { + const keys = new Set([ + ...Reflect.ownKeys(expected), + ...Reflect.ownKeys(received), + ]); + const mismatched = [...keys].filter((key) => { + const expectedDescriptor = Object.getOwnPropertyDescriptor(expected, key); + const receivedDescriptor = Object.getOwnPropertyDescriptor(received, key); + if ( + (expectedDescriptor === undefined) !== + (receivedDescriptor === undefined) + ) { + return true; + } + if (expectedDescriptor === undefined || receivedDescriptor === undefined) { + return false; + } + if (!("value" in expectedDescriptor) || !("value" in receivedDescriptor)) { + return true; + } + const expectedValue = expectedDescriptor.value; + const receivedValue = receivedDescriptor.value; + if (Array.isArray(expectedValue) || Array.isArray(receivedValue)) { + return ( + !Array.isArray(expectedValue) || + !Array.isArray(receivedValue) || + expectedValue.length !== receivedValue.length || + expectedValue.some( + (value, index) => !Object.is(value, receivedValue[index]), + ) + ); + } + return !Object.is(expectedValue, receivedValue); + }); + return Object.freeze(mismatched.map(String).toSorted()); } function reportMissingHelpFactTemplate( @@ -4959,59 +5501,88 @@ function collectUnknownDefinitionProperties( } } } + if (Array.isArray(node.examples)) { + node.examples.forEach((example, index) => { + collectUnknownProperties( + example, + [...path, "examples", String(index)], + definitionPropertyKeys.example, + issues, + ); + }); + } collectUnknownUsageConstraintDefinitionProperties( node.usageConstraints, [...path, "usageConstraints"], issues, ); - if (isPlainRecord(node.success)) { - const successPath = [...path, "success"]; - const success = node.success; - if (success.kind === "completion") { - collectUnknownProperties( - success, - successPath, - definitionPropertyKeys.success.completion, - issues, - ); - } else if (success.kind === "data") { - collectUnknownProperties( - success, - successPath, - definitionPropertyKeys.success.data, - issues, - ); - collectUnknownVariantProperties( - success.variants, - [...successPath, "variants"], - definitionPropertyKeys.atomicVariant, - issues, - ); - } else if (success.kind === "stream") { - collectUnknownProperties( - success, - successPath, - definitionPropertyKeys.success.stream, - issues, - ); - collectUnknownVariantProperties( - success.records, - [...successPath, "records"], - definitionPropertyKeys.streamRecord, - issues, - ); - } - } - collectUnknownVariantProperties( - node.failures, - [...path, "failures"], - definitionPropertyKeys.atomicVariant, + if (!isPlainRecord(node.outputs)) continue; + const outputsPath = [...path, "outputs"]; + collectUnknownProperties( + node.outputs, + outputsPath, + definitionPropertyKeys.outputModes, issues, ); + if (!isPlainRecord(node.outputs.modes)) continue; + for (const [modeName, rawMode] of Object.entries(node.outputs.modes)) { + const modePath = [...outputsPath, "modes", modeName]; + if (!isPlainRecord(rawMode)) continue; + collectUnknownProperties( + rawMode, + modePath, + definitionPropertyKeys.outputMode, + issues, + ); + const success = rawMode.success; + if (isPlainRecord(success)) { + const successPath = [...modePath, "success"]; + if (success.kind === "completion") { + collectUnknownProperties( + success, + successPath, + ["kind", "text"], + issues, + ); + } else if (success.kind === "data") { + collectUnknownProperties( + success, + successPath, + definitionPropertyKeys.atomicSuccess, + issues, + ); + collectUnknownVariantProperties( + success.variants, + [...successPath, "variants"], + definitionPropertyKeys.atomicVariant, + issues, + ); + } else if (success.kind === "stream") { + collectUnknownProperties( + success, + successPath, + definitionPropertyKeys.streamSuccess, + issues, + ); + collectUnknownVariantProperties( + success.records, + [...successPath, "records"], + definitionPropertyKeys.streamRecord, + issues, + ); + } + } + collectUnknownVariantProperties( + rawMode.failures, + [...modePath, "failures"], + definitionPropertyKeys.atomicVariant, + issues, + ); + } } } -export function getCompiledCli(contract: CliContract): RuntimeCompiledCli { +export function getCompiledCli(contract: AnyCliContract): RuntimeCompiledCli { const compiled = compiledCliContracts.get(contract); if (compiled === undefined) { throw new ContractExecutionError([{ code: "invalidCliContract" }]); @@ -5081,44 +5652,11 @@ function collectCliBaseIssues( ) { issues.push({ code: "invalidCapability", capability: "version" }); } - if (!outputCapabilities.has(definition.output)) { - issues.push({ code: "invalidCapability", capability: "output" }); - } else if ( - !definition.output.formats.includes(definition.output.defaultFormat) + if ( + definition.schema !== undefined && + !schemaCapabilities.has(definition.schema) ) { - issues.push({ - code: "invalidOutputFormat", - expected: "structured|text", - received: - typeof definition.output.defaultFormat === "string" - ? definition.output.defaultFormat - : null, - }); - } else if (!isPlainRecord(definition.output.compatibilityFlags)) { - issues.push({ - code: "invalidOutputCompatibilityFlags", - received: outputCompatibilityFlagsKind( - definition.output.compatibilityFlags, - ), - }); - } else { - for (const [flag, format] of Object.entries( - definition.output.compatibilityFlags, - )) { - if ( - !/^--[a-z0-9]+(?:-[a-z0-9]+)*$/.test(flag) || - flag === "--help" || - flag === "--output-format" || - (isVersionCapability(definition.version) && flag === "--version") || - !definition.output.formats.includes(format) - ) { - issues.push({ - code: "invalidOutputCompatibilityFlag", - flag, - received: typeof format === "string" ? format : null, - }); - } - } + issues.push({ code: "invalidCapability", capability: "schema" }); } if ( !Number.isInteger(definition.usageFailureExitCode) || @@ -5318,6 +5856,7 @@ export function usageFailureHelpArgv( function compileFailures( command: string, + outputMode: string, failures: FailureVariantDefinitions, issues: ContractDefinitionIssue[], ): Readonly<{ @@ -5330,7 +5869,8 @@ function compileFailures( "failure", failures, 1, - createFailureWireSchema, + (command, variant, schema) => + createFailureWireSchema(command, outputMode, variant, schema), issues, ); return { @@ -5340,10 +5880,290 @@ function compileFailures( }; } +function compileOutputModes( + command: string, + input: ContractSchema, + definition: RuntimeOutputModesDefinition, + issues: ContractDefinitionIssue[], +): Readonly<{ + readonly default: string; + readonly runtime: Readonly>; + readonly manifest: OutputModesManifest; + readonly wire: CommandWireManifest; + readonly grammar: OutputModesGrammar; +}> { + const outputObject = definition as object; + const metadata = outputModeMetadata.get(outputObject); + if (!outputModes.has(outputObject)) { + issues.push({ + code: "invalidOutputModes", + command, + reason: "notFactoryOutputModes", + }); + } else if (metadata === undefined || metadata.command !== command) { + issues.push({ + code: "invalidOutputModes", + command, + reason: "mismatchedCommand", + }); + } else if (metadata.input !== input) { + issues.push({ + code: "invalidOutputModes", + command, + reason: "mismatchedInput", + }); + } + + const rawModes: unknown = definition.modes; + if (!isPlainRecord(rawModes)) { + issues.push({ + code: "invalidOutputModes", + command, + reason: "emptyModes", + }); + } + const entries = isPlainRecord(rawModes) ? Object.entries(rawModes) : []; + if (entries.length === 0) { + issues.push({ + code: "invalidOutputModes", + command, + reason: "emptyModes", + }); + } + const names = entries.map(([name]) => name); + const requestedDefault: unknown = definition.default; + const hasDefault = Object.hasOwn(definition, "default"); + const defaultMode = hasDefault + ? typeof requestedDefault === "string" && names.includes(requestedDefault) + ? requestedDefault + : undefined + : names.length === 1 + ? names[0] + : undefined; + if (defaultMode === undefined) { + issues.push({ + code: "invalidDefaultOutputMode", + command, + received: typeof requestedDefault === "string" ? requestedDefault : null, + }); + } + + const runtime: Record = {}; + const manifestModes: Record = {}; + const wire: Record = {}; + for (const [modeName, rawMode] of entries) { + if (!/^[a-z][A-Za-z0-9]*$/.test(modeName)) { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidName", + }); + } + if (!isPlainRecord(rawMode)) { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidSuccess", + }); + continue; + } + const mode = rawMode; + const modeMetadata = outputModeDefinitionMetadata.get(mode); + if ( + !outputModeDefinitions.has(mode) || + modeMetadata === undefined || + modeMetadata.command !== command || + modeMetadata.input !== input + ) { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidSuccess", + }); + continue; + } + if (!isSingleLineText(mode.description)) { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidDescription", + }); + } + const delivery = mode.delivery; + if (delivery !== "atomic" && delivery !== "stream") { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidDelivery", + }); + continue; + } + const format = mode.format; + if (format !== "structured" && format !== "text") { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidFormat", + }); + continue; + } + if (typeof mode.handler !== "function") { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "missingHandler", + }); + } + if (!isPlainRecord(mode.failures)) { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidFailures", + }); + continue; + } + if (!isPlainRecord(mode.success)) { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidSuccess", + }); + continue; + } + const successKind = mode.success.kind; + if ( + (delivery === "atomic" && + successKind !== "completion" && + successKind !== "data") || + (delivery === "stream" && successKind !== "stream") + ) { + issues.push({ + code: "invalidOutputMode", + command, + mode: modeName, + reason: "invalidSuccess", + }); + continue; + } + + const runtimeMode = mode as unknown as RuntimeOutputModeDefinition; + const description = isSingleLineText(mode.description) + ? mode.description + : ""; + const resultContract = compileResultContract( + command, + modeName, + runtimeMode, + format === "text", + issues, + ); + runtime[modeName] = Object.freeze({ + id: modeName, + description, + delivery, + format, + resultContract: resultContract.runtime, + }); + manifestModes[modeName] = deepFreeze({ + description, + delivery, + format, + success: resultContract.manifest.success, + failures: resultContract.manifest.failures, + }); + wire[modeName] = resultContract.wire; + } + + const resolvedDefault = defaultMode ?? ""; + const frozenRuntime = deepFreeze(runtime); + return deepFreeze({ + default: resolvedDefault, + runtime: frozenRuntime, + manifest: { default: resolvedDefault, modes: manifestModes }, + wire, + grammar: outputModesGrammar(frozenRuntime, resolvedDefault), + }); +} + +function outputModesGrammar( + modes: Readonly>, + defaultMode: string, +): OutputModesGrammar { + return deepFreeze({ + selector: "--output" as const, + default: defaultMode, + modes: Object.fromEntries( + Object.entries(modes).map(([name, mode]) => [ + name, + { + description: mode.description, + delivery: mode.delivery, + format: mode.format, + }, + ]), + ), + }); +} + +function compileResultContract( + command: string, + outputMode: string, + definition: RuntimeOutputModeDefinition, + textEnabled: boolean, + issues: ContractDefinitionIssue[], +): CompiledResultContract { + const success = compileSuccess( + command, + outputMode, + definition.success, + textEnabled, + issues, + ); + const failures = compileFailures( + command, + outputMode, + definition.failures, + issues, + ); + collectTextPresenterIssues( + command, + textEnabled, + definition.success, + definition.failures, + issues, + ); + return deepFreeze({ + runtime: { + success: success.runtime, + failures: failures.runtime, + handler: definition.handler, + }, + manifest: { + success: success.manifest, + failures: failures.manifest, + }, + wire: { + ...success.wire, + ...(Object.keys(failures.wire).length === 0 + ? {} + : { failure: failures.wire }), + }, + }); +} + function collectTextPresenterIssues( command: string, textEnabled: boolean, - success: RuntimeExecutableDefinition["success"], + success: RuntimeCompiledSuccess, failures: FailureVariantDefinitions, issues: ContractDefinitionIssue[], ): void { @@ -5397,13 +6217,9 @@ function collectTextPresenterIssues( } } -function isTextOutputEnabled(output: unknown): boolean { - return isOutputCapability(output) && output.formats.includes("text"); -} - -function isOutputCapability(value: unknown): value is OutputCapability { +function isSchemaCapability(value: unknown): value is SchemaCapability { return ( - typeof value === "object" && value !== null && outputCapabilities.has(value) + typeof value === "object" && value !== null && schemaCapabilities.has(value) ); } @@ -5421,8 +6237,12 @@ function collectControlFieldConflicts( definition: RuntimeCliDefinition, issues: ContractDefinitionIssue[], ): void { - const controls = new Map([ + const controls = new Map< + string, + "help" | "outputMode" | "version" | "schema" + >([ ["--help", "help"], + ["--output", "outputMode"], ]); if ( helpCapabilities.has(definition.help) && @@ -5430,17 +6250,6 @@ function collectControlFieldConflicts( ) { controls.set(definition.help.shortAlias, "help"); } - if ( - outputCapabilities.has(definition.output) && - isPlainRecord(definition.output.compatibilityFlags) - ) { - for (const spelling of Object.keys(definition.output.compatibilityFlags)) { - controls.set(spelling, "outputFormat"); - } - if (definition.output.formats.includes("text")) { - controls.set("--output-format", "outputFormat"); - } - } if (isVersionCapability(definition.version)) { const version = definition.version; controls.set("--version", "version"); @@ -5448,6 +6257,9 @@ function collectControlFieldConflicts( controls.set(version.shortAlias, "version"); } } + if (isSchemaCapability(definition.schema)) { + controls.set("--schema", "schema"); + } for (const field of fields) { if (field.kind === "positional" || field.kind === "variadicPositional") { continue; @@ -5472,21 +6284,16 @@ function collectControlFieldConflicts( } } -function outputCompatibilityFlagsKind(value: unknown): string { - if (value === null) return "null"; - if (Array.isArray(value)) return "array"; - return typeof value; -} - function collectTextPresenterIssue( command: string, textEnabled: boolean, location: "completion" | "data" | "failure" | "record" | "streamSuccess", - definition: Readonly>, + definition: object, issues: ContractDefinitionIssue[], variant?: string, ): void { - const hasPresenter = typeof definition.text === "function"; + const hasPresenter = + "text" in definition && typeof definition.text === "function"; if (textEnabled === hasPresenter) return; issues.push({ code: textEnabled ? "missingTextPresenter" : "unexpectedTextPresenter", @@ -5498,13 +6305,14 @@ function collectTextPresenterIssue( function compileSuccess( command: string, - success: RuntimeExecutableDefinition["success"], + outputMode: string, + success: RuntimeCompiledSuccess, textEnabled: boolean, issues: ContractDefinitionIssue[], ): Readonly<{ - readonly runtime: RuntimeCompiledExecutable["success"]; + readonly runtime: RuntimeCompiledSuccess; readonly manifest: CommandSuccessManifest; - readonly wire: CliManifest["wire"]; + readonly wire: OutputModeWireManifest; }> { if (success.kind === "completion") { return { @@ -5514,7 +6322,7 @@ function compileSuccess( }), manifest: Object.freeze({ kind: "completion" }), wire: Object.freeze({ - completion: deepFreeze(createCompletionWireSchema(command)), + completion: deepFreeze(createCompletionWireSchema(command, outputMode)), }), }; } @@ -5524,12 +6332,16 @@ function compileSuccess( if (Object.keys(records.runtime).length === 0) { issues.push({ code: "missingStreamRecord", command }); } - const header = deepFreeze(createStreamHeaderWireSchema(command)); + const header = deepFreeze( + createStreamHeaderWireSchema(command, outputMode), + ); const terminal = deepFreeze(createStreamSuccessWireSchema()); const recordWire = Object.fromEntries( Object.entries(records.wire).map(([variant, schema]) => [ variant, - deepFreeze(createStreamRecordWireSchema(variant, schema)), + deepFreeze( + createStreamRecordWireSchema(command, outputMode, variant, schema), + ), ]), ) as Readonly>; return deepFreeze({ @@ -5564,7 +6376,8 @@ function compileSuccess( "data", success.variants, 0, - createDataWireSchema, + (command, variant, schema) => + createDataWireSchema(command, outputMode, variant, schema), issues, ); if (Object.keys(variants.runtime).length === 0) { @@ -5584,10 +6397,13 @@ function compileSuccess( function createUsageSynopsis( commandName: string, fields: readonly FieldGrammar[], + outputSelectable = false, ): string { - return [commandName, ...fields.map((field) => formatFieldUsage(field))].join( - " ", - ); + return [ + commandName, + ...(outputSelectable ? ["[--output ]"] : []), + ...fields.map((field) => formatFieldUsage(field)), + ].join(" "); } export function formatFieldUsage( diff --git a/packages/lib/src/cli-execution.ts b/packages/lib/src/cli-execution.ts index 1046f10..1ef33e7 100644 --- a/packages/lib/src/cli-execution.ts +++ b/packages/lib/src/cli-execution.ts @@ -1,5 +1,3 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; - import { createCompletionFact, createDataFact, @@ -12,7 +10,7 @@ import { isCompiledExecutable, isIssuedOutcomeFact, usageFailureHelpArgv, - type CliContract, + type AnyCliContract, type CliContractDependencies, type CliContractResult, type CliContractRoot, @@ -20,15 +18,25 @@ import { type HelpFactWording, type OutcomeFact, } from "#/cli-contract"; +import { + validateCliInput, + validateContractSchemaSynchronously, +} from "#/cli-input-validation"; import { invocationBelongsTo, type CliInvocation, type NonEmptyCliUsageIssues, type InputRejectedIssue, type NonEmptyUsageIssues, - type SchemaIssueEvidence, } from "#/cli-invocation"; import { CliWriteError } from "#/cli-write-error"; +import type { ContractQueryDocument } from "#/contract-discovery"; +import { + canonicalCommandPath, + queryCliContract, + type ContractDirectoryQuery, + type ContractSummaryQuery, +} from "#/contract-discovery"; import { ContractExecutionError, type ContractExecutionIssue, @@ -62,6 +70,7 @@ type CompiledGroup = Extract< CompiledCommand, { readonly kind: "rootGroup" | "commandGroup" } >; +type CompiledOutputMode = CompiledExecutable["outputModes"][string]; export type CliOutputDestination = "stderr" | "stdout"; @@ -82,10 +91,11 @@ export type WriteCliOutput = (output: CliOutput) => Promise | void; * 正常控制流完成全部可控写入后的程序化结果;宿主仍须落实真实进程退出状态。 * 流结果只保留终态,不累计已写记录。程序缺陷与写入错误使执行拒绝,不进入此联合。 */ -export type CliTermination = +export type CliTermination = | Readonly<{ readonly kind: "applicationResult"; readonly command: CliContractRoot; + readonly outputMode: string; readonly result: CliContractResult; readonly exitCode: number; }> @@ -99,9 +109,15 @@ export type CliTermination = readonly command: CliContractRoot; readonly exitCode: 0; }> + | Readonly<{ + readonly kind: "schema"; + readonly command: CliContractRoot; + readonly document: ContractQueryDocument; + readonly exitCode: 0; + }> | UsageFailureTermination; -type UsageFailureTermination = { +type UsageFailureTermination = { [Command in CliContractRoot]: Readonly<{ readonly kind: "usageFailure"; readonly command: Command; @@ -110,17 +126,23 @@ type UsageFailureTermination = { }>; }[CliContractRoot]; -export interface ExecuteCliOptions { - readonly invocation: CliInvocation>; - readonly dependencies: CliContractDependencies; +export type ExecuteCliOptions< + Contract extends AnyCliContract, + Invocation extends CliInvocation = CliInvocation, +> = Readonly<{ + readonly invocation: Invocation; + readonly dependencies?: CliContractDependencies; readonly write: WriteCliOutput; -} +}> & + ([Extract] extends [never] + ? unknown + : Readonly<{ readonly dependencies: CliContractDependencies }>); type RuntimeStreamGenerator = AsyncGenerator; /** * 执行同一契约签发的调用:验证 raw input,将模式的验证结果和宿主依赖交给 handler, - * 再验证、投影结果并顺序等待宿主写入。帮助、版本和用法失败不执行业务 handler。 + * 再验证、投影结果并顺序等待宿主写入。帮助、版本、发现和用法失败不执行业务 handler。 * * 宿主负责参数来源、依赖构造、输出端口和真实进程退出;应 await 本函数, * 等可控写入完成后再使用终止结果的 exitCode,不能提前结束进程。 @@ -130,9 +152,12 @@ type RuntimeStreamGenerator = AsyncGenerator; * 活动流会执行必要清理,已经写出的合法前缀不会撤回,也不会追加伪造的成功终态。 */ // oxlint-disable-next-line typescript/consistent-return -export async function executeCli( +export async function executeCli< + const Contract extends AnyCliContract, + const Invocation extends CliInvocation>, +>( cliContract: Contract, - options: ExecuteCliOptions, + options: ExecuteCliOptions, ): Promise> { const compiled = getCompiledCli(cliContract); if (!invocationBelongsTo(options.invocation, cliContract)) { @@ -178,6 +203,19 @@ export async function executeCli( exitCode: 0, }); } + case "schema": { + await writeCliOutput(options.write, { + destination: "stdout", + chunk: `${JSON.stringify(options.invocation.document)}\n`, + }); + return Object.freeze({ + kind: "schema", + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 命令来自当前 Contract 的编译树。 + command: command.id as CliContractRoot, + document: options.invocation.document, + exitCode: 0, + }); + } case "usageFailure": { await writeUsageFailure(compiled, options.write, { command: command.id, @@ -196,19 +234,17 @@ export async function executeCli( if (!isCompiledExecutable(command)) { throw new ContractExecutionError([{ code: "invalidCliContract" }]); } - const validation = validateSynchronously( + const validation = validateCliInput( command.input, options.invocation.input, - { command: command.id, location: "input" }, + command.id, ); - if (validation.issues !== undefined) { + if (validation.kind === "rejected") { // oxlint-disable-next-line typescript/no-unsafe-type-assertion const issues = Object.freeze([ Object.freeze({ code: "inputRejected" as const, - evidence: Object.freeze( - validation.issues.map(normalizeSchemaIssueEvidence), - ), + evidence: validation.evidence, }), ]) as readonly [InputRejectedIssue]; await writeUsageFailure(compiled, options.write, { @@ -225,11 +261,15 @@ export async function executeCli( }); } + const outputMode = command.outputModes[options.invocation.outputMode]; + if (outputMode === undefined) { + throw new ContractExecutionError([{ code: "invalidCliContract" }]); + } return executeApplicationResult( command, + outputMode, validation.value, options, - options.invocation.outputFormat, ); } } @@ -259,7 +299,10 @@ function createHelpText( ); const options = [ ...optionFields.map((field) => formatHelpField(field, wording)), - ...formatCoreControls(compiled), + ...formatCoreControls( + compiled, + isCommandGroup(command) ? undefined : command, + ), ]; appendHelpSegment(segments, headings?.usage, [ @@ -292,6 +335,20 @@ function createHelpText( ), ), ); + const schemaHeading = + compiled.contract.grammar.controls.help.headings?.schema; + appendHelpSegment( + segments, + schemaHeading, + createSchemaHelpEntries(compiled, command), + ); + appendHelpSegment( + segments, + headings?.examples, + isCommandGroup(command) + ? [] + : (command.examples ?? []).map((example) => helpLine(example.shell)), + ); appendHelpSegment( segments, headings?.supplement, @@ -344,10 +401,11 @@ function appendHelpSegment( function formatCoreControls( compiled: ReturnType, + executable?: CompiledExecutable, ): readonly HelpEntry[] { const help = compiled.contract.grammar.controls.help; const version = compiled.contract.grammar.controls.version; - const output = compiled.contract.grammar.controls.output; + const schema = compiled.contract.grammar.controls.schema; const versionEntry: HelpEntry | undefined = version === undefined ? undefined @@ -370,23 +428,66 @@ function formatCoreControls( .join(", "), ), ...(versionEntry === undefined ? [] : [versionEntry]), - ...(output.selector === undefined + ...(schema === undefined + ? [] + : [helpDetail(schema.longOption, schema.description)]), + ...(executable === undefined ? [] : [ helpLine( - `${output.selector} <${output.formats.join("|")}> ${renderHelpFactTemplate("choices", help.wording?.choices, { choices: output.formats.map((format) => JSON.stringify(format)).join(", ") })} ${renderHelpFactTemplate("default", help.wording?.default, { value: JSON.stringify(output.defaultFormat) })}`, + [ + "--output ", + renderHelpFactTemplate("choices", help.wording?.choices, { + choices: Object.keys(executable.outputModes) + .map((mode) => JSON.stringify(mode)) + .join(", "), + }), + renderHelpFactTemplate("default", help.wording?.default, { + value: JSON.stringify(executable.defaultOutputMode), + }), + ].join(" "), + ), + ...Object.values(executable.outputModes).map((mode) => + helpDetail( + `${mode.id} (${mode.delivery}, ${mode.format})`, + mode.description, + ), ), ]), - ...Object.entries(output.compatibilityFlags ?? {}).map(([flag, format]) => - helpLine( - output.selector === undefined - ? `${flag} = ${format}` - : `${flag} = ${output.selector} ${format}`, - ), - ), ]; } +function createSchemaHelpEntries( + compiled: ReturnType, + command: CompiledCommand, +): readonly HelpEntry[] { + if (compiled.contract.grammar.controls.schema === undefined) return []; + const path = canonicalCommandPath(compiled.commands, command.id); + const query = queryCliContract(compiled.contract, path); + if (query.kind === "queryFailure") return []; + if (query.kind === "directory") { + return directoryHelpEntries(query); + } + if (query.kind === "summary") { + return summaryHelpEntries(query); + } + return []; +} + +function directoryHelpEntries( + query: ContractDirectoryQuery, +): readonly HelpEntry[] { + return query.children.map((child) => + helpDetail(JSON.stringify(child.nextArgv), child.description), + ); +} + +function summaryHelpEntries(query: ContractSummaryQuery): readonly HelpEntry[] { + return query.next.map((next) => + helpDetail(`--schema=${next.target}`, JSON.stringify(next.argv)), + ); +} + async function writeUsageFailure( compiled: ReturnType, write: WriteCliOutput, @@ -467,24 +568,29 @@ function formatHelpField( ); } -async function executeApplicationResult( +async function executeApplicationResult< + Contract extends AnyCliContract, + Invocation extends CliInvocation>, +>( compiled: CompiledExecutable, + outputMode: CompiledOutputMode, input: unknown, - options: ExecuteCliOptions, - outputFormat: "structured" | "text", + options: ExecuteCliOptions, ): Promise> { const issuedOutcomeFacts = new WeakSet(); + const resultContract = outputMode.resultContract; + const success = resultContract.success; const successOutcome = - compiled.success.kind === "completion" + success.kind === "completion" ? { completion: () => createCompletionFact(compiled.id, issuedOutcomeFacts), } - : compiled.success.kind === "data" + : success.kind === "data" ? { data: Object.freeze( Object.fromEntries( - Object.keys(compiled.success.variants).map((variant) => [ + Object.keys(success.variants).map((variant) => [ variant, (payload: unknown) => createDataFact( @@ -500,7 +606,7 @@ async function executeApplicationResult( : { record: Object.freeze( Object.fromEntries( - Object.keys(compiled.success.records).map((variant) => [ + Object.keys(success.records).map((variant) => [ variant, (payload: unknown) => createStreamRecordFact( @@ -519,7 +625,7 @@ async function executeApplicationResult( ...successOutcome, failure: Object.freeze( Object.fromEntries( - Object.keys(compiled.failures).map((variant) => [ + Object.keys(resultContract.failures).map((variant) => [ variant, (payload: unknown) => createFailureFact( @@ -532,30 +638,30 @@ async function executeApplicationResult( ), ), }); - if (typeof compiled.handler !== "function") { + if (typeof resultContract.handler !== "function") { throw new ContractExecutionError([{ code: "invalidCliContract" }]); } - const handlerResult: unknown = compiled.handler({ + const handlerResult: unknown = resultContract.handler({ input, dependencies: options.dependencies, outcome, }); - if (compiled.success.kind === "stream") { + if (success.kind === "stream") { const generator = requireAsyncGenerator(handlerResult, compiled.id); return executeStreamResult( compiled, + outputMode, generator, issuedOutcomeFacts, options, - outputFormat, ); } const result = await handlerResult; const projected = projectApplicationFact( compiled, + outputMode, result, issuedOutcomeFacts, - outputFormat, "atomic", ); await writeProjectedOutput(options.write, projected.output); @@ -563,6 +669,7 @@ async function executeApplicationResult( kind: "applicationResult", // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 命令来自当前 Contract 的编译树,泛型身份在此恢复。 command: compiled.id as CliContractRoot, + outputMode: outputMode.id, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 结果经该命令的结果模式验证并由本次执行签发。 result: projected.result as CliContractResult, exitCode: projected.exitCode, @@ -612,22 +719,25 @@ async function writeCliOutput( } } -async function executeStreamResult( +async function executeStreamResult< + Contract extends AnyCliContract, + Invocation extends CliInvocation>, +>( compiled: CompiledExecutable, + outputMode: CompiledOutputMode, generator: RuntimeStreamGenerator, issuedOutcomeFacts: WeakSet, - options: ExecuteCliOptions, - outputFormat: "structured" | "text", + options: ExecuteCliOptions, ): Promise> { - if (compiled.success.kind !== "stream") { + if (outputMode.resultContract.success.kind !== "stream") { throw new ContractExecutionError([{ code: "invalidCliContract" }]); } let active = true; try { - if (outputFormat === "structured") { + if (outputMode.format === "structured") { await writeCliOutput(options.write, { destination: "stdout", - chunk: `${JSON.stringify(createStreamHeaderEnvelope(compiled.id))}\n`, + chunk: `${JSON.stringify(createStreamHeaderEnvelope(compiled.id, outputMode.id))}\n`, }); } for (;;) { @@ -635,9 +745,9 @@ async function executeStreamResult( if (!step.done) { const projected = projectApplicationFact( compiled, + outputMode, step.value, issuedOutcomeFacts, - outputFormat, "record", ); await writeProjectedOutput(options.write, projected.output); @@ -646,9 +756,9 @@ async function executeStreamResult( active = false; const projected = projectApplicationFact( compiled, + outputMode, step.value, issuedOutcomeFacts, - outputFormat, "terminal", ); await writeProjectedOutput(options.write, projected.output); @@ -656,6 +766,7 @@ async function executeStreamResult( kind: "applicationResult", // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 命令来自当前 Contract 的编译树,泛型身份在此恢复。 command: compiled.id as CliContractRoot, + outputMode: outputMode.id, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 结果经该命令的结果模式验证并由本次执行签发。 result: projected.result as CliContractResult, exitCode: projected.exitCode, @@ -688,9 +799,9 @@ type ProjectedApplicationFact = Readonly<{ function projectApplicationFact( compiled: CompiledExecutable, + outputMode: CompiledOutputMode, value: unknown, issuedOutcomeFacts: WeakSet, - outputFormat: "structured" | "text", phase: "atomic" | "record" | "terminal", ): ProjectedApplicationFact { if (!isIssuedOutcomeFact(value, issuedOutcomeFacts)) { @@ -698,7 +809,10 @@ function projectApplicationFact( } const result = value; if (phase === "record") { - if (result.kind !== "record" || compiled.success.kind !== "stream") { + if ( + result.kind !== "record" || + outputMode.resultContract.success.kind !== "stream" + ) { throwExecutionIssue({ code: "outcomeKindMismatch", command: compiled.id, @@ -706,7 +820,7 @@ function projectApplicationFact( received: result.kind, }); } - const record = compiled.success.records[result.variant]; + const record = outputMode.resultContract.success.records[result.variant]; if (record === undefined) { throwExecutionIssue({ code: "undeclaredOutcomeVariant", @@ -721,13 +835,13 @@ function projectApplicationFact( variant: result.variant, }); const chunk = - outputFormat === "structured" + outputMode.format === "structured" ? `${JSON.stringify(createStreamRecordEnvelope(result.variant, data))}\n` : encodeRecordText(presentStreamRecordText(record.text, data)); return { result, output: { destination: "stdout", chunk }, exitCode: 0 }; } if (phase === "terminal" && result.kind === "streamSuccess") { - if (compiled.success.kind !== "stream") { + if (outputMode.resultContract.success.kind !== "stream") { throwExecutionIssue({ code: "outcomeKindMismatch", command: compiled.id, @@ -736,10 +850,10 @@ function projectApplicationFact( }); } let chunk: string | undefined; - if (outputFormat === "structured") + if (outputMode.format === "structured") chunk = `${JSON.stringify(createStreamSuccessEnvelope())}\n`; else { - const presenter = compiled.success.text; + const presenter = outputMode.resultContract.success.text; if (typeof presenter !== "function") throw new ContractExecutionError([{ code: "invalidCliContract" }]); chunk = encodeTerminalText(presenter()); @@ -760,7 +874,7 @@ function projectApplicationFact( }); } if (result.kind === "failure") { - const failure = compiled.failures[result.variant]; + const failure = outputMode.resultContract.failures[result.variant]; if (failure === undefined) { throwExecutionIssue({ code: "undeclaredOutcomeVariant", @@ -780,7 +894,7 @@ function projectApplicationFact( data, issuedOutcomeFacts, ); - if (outputFormat === "text") { + if (outputMode.format === "text") { const presenter = failure.text; if (typeof presenter !== "function") { throw new ContractExecutionError([{ code: "invalidCliContract" }]); @@ -798,12 +912,12 @@ function projectApplicationFact( result: normalized, output: { destination: "stderr", - chunk: `${JSON.stringify(createFailureEnvelope(compiled.id, result.variant, data))}\n`, + chunk: `${JSON.stringify(createFailureEnvelope(compiled.id, outputMode.id, result.variant, data))}\n`, }, exitCode: failure.exitCode, }; } - if (compiled.success.kind === "completion") { + if (outputMode.resultContract.success.kind === "completion") { if (result.kind !== "completion") { throwExecutionIssue({ code: "outcomeKindMismatch", @@ -812,8 +926,8 @@ function projectApplicationFact( received: result.kind, }); } - if (outputFormat === "text") { - const presenter = compiled.success.text; + if (outputMode.format === "text") { + const presenter = outputMode.resultContract.success.text; if (typeof presenter !== "function") { throw new ContractExecutionError([{ code: "invalidCliContract" }]); } @@ -829,12 +943,15 @@ function projectApplicationFact( result, output: { destination: "stdout", - chunk: `${JSON.stringify(createCompletionEnvelope(compiled.id))}\n`, + chunk: `${JSON.stringify(createCompletionEnvelope(compiled.id, outputMode.id))}\n`, }, exitCode: 0, }; } - if (compiled.success.kind !== "data" || result.kind !== "data") { + if ( + outputMode.resultContract.success.kind !== "data" || + result.kind !== "data" + ) { throwExecutionIssue({ code: "outcomeKindMismatch", command: compiled.id, @@ -843,7 +960,7 @@ function projectApplicationFact( }); } - const variant = compiled.success.variants[result.variant]; + const variant = outputMode.resultContract.success.variants[result.variant]; if (variant === undefined) { throwExecutionIssue({ code: "undeclaredOutcomeVariant", @@ -863,7 +980,7 @@ function projectApplicationFact( data, issuedOutcomeFacts, ); - if (outputFormat === "text") { + if (outputMode.format === "text") { const presenter = variant.text; if (typeof presenter !== "function") { throw new ContractExecutionError([{ code: "invalidCliContract" }]); @@ -881,7 +998,7 @@ function projectApplicationFact( result: normalized, output: { destination: "stdout", - chunk: `${JSON.stringify(createDataEnvelope(compiled.id, result.variant, data))}\n`, + chunk: `${JSON.stringify(createDataEnvelope(compiled.id, outputMode.id, result.variant, data))}\n`, }, exitCode: variant.exitCode, }; @@ -900,7 +1017,7 @@ function projectAtomicData( value: unknown, target: Exclude, ): JsonValue { - const validation = validateSynchronously(schema, value, target); + const validation = validateContractSchemaSynchronously(schema, value, target); if (validation.issues !== undefined) { throwExecutionIssue({ code: "outputSchemaRejected", @@ -921,123 +1038,6 @@ function projectAtomicData( } } -function validateSynchronously( - schema: ContractSchema, - value: unknown, - target: ExecutionSchemaTarget, -): StandardSchemaV1.Result { - const result = schema["~standard"].validate(value); - if (isPromise(result)) { - throwExecutionIssue({ - code: "asynchronousSchemaValidation", - ...target, - expected: "synchronousStandardResult", - received: "promise", - }); - } - if (!isStandardResult(result)) { - throwExecutionIssue({ - code: "invalidStandardResult", - ...target, - expected: "standardResult", - received: describeValueKind(result), - }); - } - return result; -} - -function describeValueKind( - value: unknown, -): Extract< - ContractExecutionIssue, - { readonly code: "invalidStandardResult" } ->["received"] { - if (value === null) return "null"; - if (Array.isArray(value)) return "array"; - return typeof value; -} - function throwExecutionIssue(issue: ContractExecutionIssue): never { throw new ContractExecutionError([issue]); } - -function isStandardResult( - value: unknown, -): value is StandardSchemaV1.Result { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return false; - } - if (!("issues" in value) || value.issues === undefined) { - return "value" in value; - } - return Array.isArray(value.issues) && value.issues.every(isStandardIssue); -} - -function isStandardIssue(value: unknown): value is StandardSchemaV1.Issue { - if ( - typeof value !== "object" || - value === null || - !("message" in value) || - typeof value.message !== "string" - ) { - return false; - } - if (!("path" in value) || value.path === undefined) { - return true; - } - return ( - Array.isArray(value.path) && - value.path.every((segment) => { - const key = - typeof segment === "object" && segment !== null && "key" in segment - ? segment.key - : segment; - return ( - typeof key === "string" || - typeof key === "number" || - typeof key === "symbol" - ); - }) - ); -} - -function normalizeSchemaIssueEvidence( - issue: StandardSchemaV1.Issue, -): SchemaIssueEvidence { - const path = normalizeSchemaPath(issue.path); - return Object.freeze( - path === undefined - ? { message: issue.message } - : { message: issue.message, path }, - ); -} - -function normalizeSchemaPath( - path: StandardSchemaV1.Issue["path"], -): readonly (number | string)[] | undefined { - if (path === undefined) { - return undefined; - } - const normalized: Array = []; - for (const segment of path) { - const key = - typeof segment === "object" && segment !== null ? segment.key : segment; - if ( - typeof key !== "string" && - !(typeof key === "number" && Number.isFinite(key)) - ) { - return undefined; - } - normalized.push(key); - } - return Object.freeze(normalized); -} - -function isPromise(value: unknown): value is Promise { - return ( - typeof value === "object" && - value !== null && - "then" in value && - typeof value.then === "function" - ); -} diff --git a/packages/lib/src/cli-input-validation.ts b/packages/lib/src/cli-input-validation.ts new file mode 100644 index 0000000..dab16c5 --- /dev/null +++ b/packages/lib/src/cli-input-validation.ts @@ -0,0 +1,165 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +import { ContractExecutionError } from "#/contract-execution-error"; +import type { + ContractExecutionIssue, + ExecutionSchemaTarget, +} from "#/contract-execution-error"; +import type { ContractSchema } from "#/contract-schema"; + +export type CliInputValidation = + | Readonly<{ readonly kind: "accepted"; readonly value: unknown }> + | Readonly<{ + readonly kind: "rejected"; + readonly evidence: readonly Readonly<{ + readonly message: string; + readonly path?: readonly (number | string)[]; + }>[]; + }>; + +/** + * 使用契约模式验证已解析的原始输入,不调用命令处理器。模式抛出的异常,以及格式错误或异步返回的标准模式结果, + * 继续遵守正常 CLI 执行时相同的执行错误边界。 + */ +export function validateCliInput( + schema: ContractSchema, + rawInput: unknown, + command: string, +): CliInputValidation { + const validation = validateContractSchemaSynchronously(schema, rawInput, { + command, + location: "input", + }); + if (validation.issues !== undefined) { + return Object.freeze({ + kind: "rejected", + evidence: Object.freeze( + validation.issues.map(normalizeSchemaIssueEvidence), + ), + }); + } + return Object.freeze({ kind: "accepted", value: validation.value }); +} + +/** 同步验证契约模式,并保留核心缺陷对应的错误边界。 */ +export function validateContractSchemaSynchronously( + schema: ContractSchema, + value: unknown, + target: ExecutionSchemaTarget, +): StandardSchemaV1.Result { + const result = schema["~standard"].validate(value); + if (isPromise(result)) { + throwExecutionIssue({ + code: "asynchronousSchemaValidation", + ...target, + expected: "synchronousStandardResult", + received: "promise", + }); + } + if (!isStandardResult(result)) { + throwExecutionIssue({ + code: "invalidStandardResult", + ...target, + expected: "standardResult", + received: describeValueKind(result), + }); + } + return result; +} + +function describeValueKind( + value: unknown, +): Extract< + ContractExecutionIssue, + { readonly code: "invalidStandardResult" } +>["received"] { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function throwExecutionIssue(issue: ContractExecutionIssue): never { + throw new ContractExecutionError([issue]); +} + +function isStandardResult( + value: unknown, +): value is StandardSchemaV1.Result { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + if (!("issues" in value) || value.issues === undefined) { + return "value" in value; + } + return Array.isArray(value.issues) && value.issues.every(isStandardIssue); +} + +function isStandardIssue(value: unknown): value is StandardSchemaV1.Issue { + if ( + typeof value !== "object" || + value === null || + !("message" in value) || + typeof value.message !== "string" + ) { + return false; + } + if (!("path" in value) || value.path === undefined) { + return true; + } + return ( + Array.isArray(value.path) && + value.path.every((segment) => { + const key = + typeof segment === "object" && segment !== null && "key" in segment + ? segment.key + : segment; + return ( + typeof key === "string" || + typeof key === "number" || + typeof key === "symbol" + ); + }) + ); +} + +function normalizeSchemaIssueEvidence(issue: StandardSchemaV1.Issue): Readonly<{ + readonly message: string; + readonly path?: readonly (number | string)[]; +}> { + const path = normalizeSchemaPath(issue.path); + return Object.freeze( + path === undefined + ? { message: issue.message } + : { message: issue.message, path }, + ); +} + +function normalizeSchemaPath( + path: StandardSchemaV1.Issue["path"], +): readonly (number | string)[] | undefined { + if (path === undefined) { + return undefined; + } + const normalized: Array = []; + for (const segment of path) { + const key = + typeof segment === "object" && segment !== null ? segment.key : segment; + if ( + typeof key !== "string" && + !(typeof key === "number" && Number.isFinite(key)) + ) { + return undefined; + } + normalized.push(key); + } + return Object.freeze(normalized); +} + +function isPromise(value: unknown): value is Promise { + return ( + typeof value === "object" && + value !== null && + "then" in value && + typeof value.then === "function" + ); +} diff --git a/packages/lib/src/cli-invocation.ts b/packages/lib/src/cli-invocation.ts index 5674fa8..453b271 100644 --- a/packages/lib/src/cli-invocation.ts +++ b/packages/lib/src/cli-invocation.ts @@ -1,15 +1,21 @@ import { getCompiledCli, isCompiledExecutable, - type CliContract, type CliContractRawInput, type CliContractRoot, + type AnyCliContract, type CliContractUsageFacts, type FieldDefinitions, type RuntimeCompiledExecutable, type WhenUsageConstraint, type UsageFact, } from "#/cli-contract"; +import { + canonicalCommandPath, + queryCliContract, + type ContractQueryDocument, + type ContractQueryFailureReason, +} from "#/contract-discovery"; import { ContractExecutionError } from "#/contract-execution-error"; import { evaluateUsageConstraints } from "#/usage-constraint"; @@ -24,7 +30,7 @@ export interface ParsedInvocation { readonly kind: "parsed"; readonly command: Command; readonly input: Input; - readonly outputFormat: "structured" | "text"; + readonly outputMode: string; } export interface HelpRequest { @@ -37,6 +43,12 @@ export interface VersionRequest { readonly command: Command; } +export interface SchemaRequest { + readonly kind: "schema"; + readonly command: Command; + readonly document: ContractQueryDocument; +} + export interface UnexpectedPositionalIssue { readonly code: "unexpectedPositional"; readonly position: number; @@ -246,21 +258,21 @@ type ContractConditionalUsageIssue< : never; export type CliUsageIssue< - Contract extends CliContract = CliContract, + Contract extends AnyCliContract = AnyCliContract, Command extends string = CliContractRoot, > = | Exclude | ContractConditionalUsageIssue; export type NonEmptyCliUsageIssues< - Contract extends CliContract = CliContract, + Contract extends AnyCliContract = AnyCliContract, Command extends string = CliContractRoot, > = readonly [ CliUsageIssue, ...CliUsageIssue[], ]; -type UsageFailuresForContract = { +type UsageFailuresForContract = { [Command in CliContractRoot]: UsageFailure< Command, NonEmptyCliUsageIssues @@ -285,43 +297,57 @@ export interface InvalidFieldChoiceIssue { readonly choices: readonly string[]; } -export interface InvalidOutputFormatIssue { - readonly code: "invalidOutputFormat"; +export interface InvalidOutputModeIssue { + readonly code: "invalidOutputMode"; readonly position: number; readonly option: string; readonly received: string | null; + readonly available: readonly string[]; } -export interface ConflictingOutputFormatIssue { - readonly code: "conflictingOutputFormat"; +export interface InvalidSchemaQueryIssue { + readonly code: "invalidSchemaQuery"; + readonly position: number; + readonly option: "--schema"; + readonly target: string | null; + readonly reason: + | "invalidSyntax" + | "unknownPart" + | "unknownOutputMode" + | "unsupportedPart"; +} + +export interface ConflictingOutputModeIssue { + readonly code: "conflictingOutputMode"; readonly occurrences: readonly [ Readonly<{ readonly position: number; readonly option: string; - readonly format: "structured" | "text"; + readonly mode: string; }>, Readonly<{ readonly position: number; readonly option: string; - readonly format: "structured" | "text"; + readonly mode: string; }>, ...Readonly<{ readonly position: number; readonly option: string; - readonly format: "structured" | "text"; + readonly mode: string; }>[], ]; } export type UsageIssue = | ConflictingFlagIssue - | ConflictingOutputFormatIssue + | ConflictingOutputModeIssue | CountConstraintViolationIssue | ForbiddenCombinationViolationIssue | ConditionalConstraintViolationIssue | InputRejectedIssue | InvalidFieldChoiceIssue - | InvalidOutputFormatIssue + | InvalidOutputModeIssue + | InvalidSchemaQueryIssue | MissingOptionValueIssue | MissingRequiredFieldIssue | RepeatedOptionIssue @@ -347,26 +373,27 @@ export interface UsageFailure< } /** 解析器签发的闭合调用事实,只能交还产生它的同一个契约执行;不要自行拼装。 */ -export type CliInvocation = +export type CliInvocation = UnboundCliInvocation & { readonly [cliInvocationType]: Contract; }; -type UnboundCliInvocation = +type UnboundCliInvocation = | ParsedInvocation, CliContractRawInput> | HelpRequest> | VersionRequest> + | SchemaRequest> | UsageFailuresForContract; /** * 将宿主提供的参数 token 同步、无副作用地解析为同一契约的调用事实。 * 参数从命令路径或字段开始,不包含 Node 可执行文件和脚本路径;来源与 shell 拆词由宿主负责。 * - * 解析器检查调用语法与用法约束,保留帮助、版本和用法失败等闭合分支; + * 解析器检查调用语法与用法约束,保留帮助、版本、发现和用法失败等闭合分支; * 已解析的 raw input 尚未经过契约模式验证。它不读取进程或环境,不执行 handler,也不写输出。 * 将返回值交给 {@link executeCli},由执行内核完成后续验证与投影。 */ -export function parseCliInvocation( +export function parseCliInvocation( cliContract: Contract, argv: readonly string[], ): CliInvocation { @@ -374,7 +401,7 @@ export function parseCliInvocation( const input: Record = {}; const occurrencesByField = new Map(); const valueOccurrencesByField = new Map(); - const outputOccurrences: OutputFormatOccurrence[] = []; + const outputOccurrences: OutputModeOccurrence[] = []; let selected = compiled.commands[compiled.root]; if (selected === undefined) throw new ContractExecutionError([{ code: "invalidCliContract" }]); @@ -390,32 +417,15 @@ export function parseCliInvocation( } const eager = detectEagerControl(compiled, token); if (eager !== undefined) { - return bindInvocation(cliContract, { - kind: eager, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 命令 id 来自当前 Contract 编译结果,泛型身份在此恢复。 - command: selected.id as CliContractRoot, - }); - } - const output = consumeOutputControl( - compiled, - argv, - position, - outputOccurrences, - ); - if (output.kind === "issue") { - return usageFailure( + return handleEagerControl( cliContract, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 命令 id 来自当前 Contract 编译结果,泛型身份在此恢复。 - selected.id as CliContractRoot, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- usage 与同一已编译命令一同产生,泛型身份在此恢复。 - selected.usage as CommandUsage>, - output.issue, + compiled, + selected, + eager, + token, + position, ); } - if (output.kind === "consumed") { - position = output.nextPosition; - continue; - } if (token.startsWith("-") && token !== "-") { const option = consumeOption( compiled, @@ -495,14 +505,19 @@ export function parseCliInvocation( ? detectEagerControl(compiled, token) : undefined; if (eager !== undefined) { - return bindInvocation(cliContract, { - kind: eager, - command, - }); + return handleEagerControl( + cliContract, + compiled, + selected, + eager, + token, + position, + ); } if (optionsEnabled) { const output = consumeOutputControl( compiled, + selected, argv, position, outputOccurrences, @@ -575,38 +590,98 @@ export function parseCliInvocation( command, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- raw input 由此 Contract 的字段语法解析并冻结,泛型形状在此恢复。 input: freezeRawInput(input) as CliContractRawInput, - outputFormat: - outputOccurrences[0]?.format ?? - compiled.contract.grammar.controls.output.defaultFormat, + outputMode: outputOccurrences[0]?.mode ?? selected.defaultOutputMode, }); } -type OutputFormatOccurrence = Readonly<{ +type OutputModeOccurrence = Readonly<{ readonly position: number; readonly option: string; - readonly format: "structured" | "text"; + readonly mode: string; }>; function detectEagerControl( compiled: ReturnType, token: string, -): "help" | "version" | undefined { +): "help" | "version" | "schema" | undefined { const help = compiled.contract.grammar.controls.help; if (token === help.longOption || token === help.shortAlias) { return "help"; } const version = compiled.contract.grammar.controls.version; - return version !== undefined && + if ( + version !== undefined && (token === version.longOption || token === version.shortAlias) - ? "version" + ) { + return "version"; + } + const schema = compiled.contract.grammar.controls.schema; + return schema !== undefined && + (token === schema.longOption || token.startsWith(`${schema.longOption}=`)) + ? "schema" : undefined; } +function handleEagerControl( + cliContract: Contract, + compiled: ReturnType, + command: ReturnType["commands"][string], + control: "help" | "version" | "schema", + token: string, + position: number, +): CliInvocation { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 命令来自当前 Contract 的编译树。 + const commandId = command.id as CliContractRoot; + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 用法与同一已编译命令一同产生。 + const usage = command.usage as CommandUsage>; + if (control !== "schema") { + return bindInvocation(cliContract, { kind: control, command: commandId }); + } + + const target = + token === "--schema" ? undefined : token.slice("--schema=".length); + const document = queryCliContract( + cliContract, + canonicalCommandPath(compiled.commands, command.id), + target, + ); + if (document.kind === "queryFailure") { + return usageFailure(cliContract, commandId, usage, { + code: "invalidSchemaQuery", + position, + option: "--schema", + target: target ?? null, + reason: schemaQueryFailureReason(document.reason), + }); + } + return bindInvocation(cliContract, { + kind: "schema", + command: commandId, + document, + }); +} + +function schemaQueryFailureReason( + reason: ContractQueryFailureReason, +): InvalidSchemaQueryIssue["reason"] { + switch (reason) { + case "invalidSyntax": + case "unknownPart": + case "unknownOutputMode": + case "unsupportedPart": + return reason; + case "discoveryNotInstalled": + case "unknownCommandPath": + return "unsupportedPart"; + } +} + function consumeOutputControl( compiled: ReturnType, + executable: RuntimeCompiledExecutable, argv: readonly string[], position: number, - occurrences: OutputFormatOccurrence[], + occurrences: OutputModeOccurrence[], ): | Readonly<{ readonly kind: "notControl" }> | Readonly<{ readonly kind: "consumed"; readonly nextPosition: number }> @@ -614,8 +689,7 @@ function consumeOutputControl( // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 调用点均传入当前位置的 argv token,公共类型保证字符串。 const token = argv[position] as string; const option = readOptionToken(token); - const output = compiled.contract.grammar.controls.output; - if (output.selector === option.spelling) { + if (option.spelling === "--output") { const nextToken = argv[position + 1]; if ( option.value === undefined && @@ -625,77 +699,54 @@ function consumeOutputControl( return { kind: "consumed", nextPosition: position + 1 }; } const value = option.value ?? argv[position + 1]; - if ( - value === undefined || - !isOutputFormat(value) || - !output.formats.includes(value) - ) { + const available = Object.keys(executable.outputModes); + if (value === undefined || !Object.hasOwn(executable.outputModes, value)) { return { kind: "issue", issue: { - code: "invalidOutputFormat", + code: "invalidOutputMode", position, option: option.spelling, received: value ?? null, + available: Object.freeze(available), }, }; } const nextPosition = option.value === undefined ? position + 2 : position + 1; - return selectOutputFormat( + return selectOutputMode( occurrences, { position, option: option.spelling, - format: value, + mode: value, }, nextPosition, ); } - const format = output.compatibilityFlags?.[option.spelling]; - if (format === undefined) return { kind: "notControl" }; - if (option.value !== undefined) { - return { - kind: "issue", - issue: { - code: "invalidOutputFormat", - position, - option: option.spelling, - received: option.value, - }, - }; - } - return selectOutputFormat( - occurrences, - { position, option: option.spelling, format }, - position + 1, - ); + return { kind: "notControl" }; } -function isOutputFormat(value: unknown): value is "structured" | "text" { - return value === "structured" || value === "text"; -} - -function selectOutputFormat( - occurrences: OutputFormatOccurrence[], - occurrence: OutputFormatOccurrence, +function selectOutputMode( + occurrences: OutputModeOccurrence[], + occurrence: OutputModeOccurrence, nextPosition: number, ): | Readonly<{ readonly kind: "consumed"; readonly nextPosition: number }> | Readonly<{ readonly kind: "issue"; - readonly issue: ConflictingOutputFormatIssue; + readonly issue: ConflictingOutputModeIssue; }> { occurrences.push(occurrence); if (occurrences.length === 1) return { kind: "consumed", nextPosition }; return { kind: "issue", issue: { - code: "conflictingOutputFormat", + code: "conflictingOutputMode", // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 此前已排除单次出现,冲突证据至少有两项。 occurrences: Object.freeze([ ...occurrences, - ]) as ConflictingOutputFormatIssue["occurrences"], + ]) as ConflictingOutputModeIssue["occurrences"], }, }; } @@ -991,12 +1042,7 @@ function longOptionsInScope( ...(compiled.contract.grammar.controls.version === undefined ? [] : [compiled.contract.grammar.controls.version.longOption]), - ...(compiled.contract.grammar.controls.output.selector === undefined - ? [] - : [compiled.contract.grammar.controls.output.selector]), - ...Object.keys( - compiled.contract.grammar.controls.output.compatibilityFlags ?? {}, - ), + "--output", ]; } @@ -1074,7 +1120,7 @@ function damerauLevenshteinDistance(left: string, right: string): number { return matrix[left.length + 1]![right.length + 1]!; } -function unexpectedPositional( +function unexpectedPositional( contract: Contract, command: CliContractRoot, usage: CommandUsage>, @@ -1088,7 +1134,7 @@ function unexpectedPositional( }); } -function usageFailure( +function usageFailure( contract: Contract, command: CliContractRoot, usage: CommandUsage>, @@ -1097,7 +1143,7 @@ function usageFailure( return usageFailureFromIssues(contract, command, usage, [issue]); } -function usageFailureFromIssues( +function usageFailureFromIssues( contract: Contract, command: CliContractRoot, usage: CommandUsage>, @@ -1121,7 +1167,7 @@ export function invocationBelongsTo( return invocationContracts.get(invocation) === contract; } -function bindInvocation( +function bindInvocation( contract: Contract, invocation: UnboundCliInvocation, ): CliInvocation { diff --git a/packages/lib/src/contract-definition-error.ts b/packages/lib/src/contract-definition-error.ts index cf29545..b756a20 100644 --- a/packages/lib/src/contract-definition-error.ts +++ b/packages/lib/src/contract-definition-error.ts @@ -1,3 +1,4 @@ +import type { SchemaIssueEvidence, UsageIssue } from "#/cli-invocation"; import type { JsonValue } from "#/contract-schema"; export type SchemaDefinitionTarget = @@ -64,6 +65,13 @@ export type ContractDefinitionIssue = readonly projection: "input" | "output"; } > + | Readonly< + SchemaDefinitionTarget & { + readonly code: "schemaReferenceNotSelfContained"; + readonly projection: "input" | "output"; + readonly reference: string; + } + > | Readonly< SchemaDefinitionTarget & { readonly code: "invalidSchemaProjection"; @@ -144,21 +152,34 @@ export type ContractDefinitionIssue = }> | Readonly<{ readonly code: "invalidCapability"; - readonly capability: "help" | "output" | "version"; + readonly capability: "help" | "version" | "schema"; }> | Readonly<{ - readonly code: "invalidOutputFormat"; - readonly expected: "structured|text"; - readonly received: string | null; + readonly code: "invalidOutputModes"; + readonly command: string; + readonly reason: + | "notFactoryOutputModes" + | "emptyModes" + | "mismatchedCommand" + | "mismatchedInput"; }> | Readonly<{ - readonly code: "invalidOutputCompatibilityFlag"; - readonly flag: string; + readonly code: "invalidDefaultOutputMode"; + readonly command: string; readonly received: string | null; }> | Readonly<{ - readonly code: "invalidOutputCompatibilityFlags"; - readonly received: string; + readonly code: "invalidOutputMode"; + readonly command: string; + readonly mode: string; + readonly reason: + | "invalidName" + | "invalidDescription" + | "invalidDelivery" + | "invalidFormat" + | "missingHandler" + | "invalidSuccess" + | "invalidFailures"; }> | Readonly<{ readonly code: "missingTextPresenter"; @@ -286,7 +307,7 @@ export type ContractDefinitionIssue = readonly command: string; readonly field: string; readonly spelling: string; - readonly control: "help" | "outputFormat" | "version"; + readonly control: "help" | "outputMode" | "version" | "schema"; }> | Readonly<{ readonly code: "requiredPositionalAfterOptional"; @@ -310,6 +331,27 @@ export type ContractDefinitionIssue = readonly code: "missingDataVariant"; readonly command: string; }> + | Readonly<{ + readonly code: "invalidCommandExample"; + readonly command: string; + readonly index: number; + readonly reason: + | "invalidShape" + | "unknownField" + | "unencodableFieldValue" + | "invalidOutputMode" + | "parseDidNotComplete" + | "wrongCommand" + | "wrongOutputMode" + | "rawInputMismatch" + | "inputRejected"; + readonly field?: string; + readonly received?: string; + readonly expected?: string; + readonly usageIssues?: readonly UsageIssue[]; + readonly evidence?: readonly SchemaIssueEvidence[]; + readonly mismatchedFields?: readonly string[]; + }> | Readonly<{ readonly code: "invalidUsageConstraint"; readonly command: string; @@ -426,6 +468,8 @@ function formatContractDefinitionIssue(issue: ContractDefinitionIssue): string { return `${formatSchemaTarget(issue)} 的 ${issue.projection} 投影不是 Draft 2020-12`; case "schemaExportFailed": return `${formatSchemaTarget(issue)} 无法导出 ${issue.projection} 投影`; + case "schemaReferenceNotSelfContained": + return `${formatSchemaTarget(issue)} 的 ${issue.projection} 投影引用 ${issue.reference} 无法离线解释`; case "invalidSchemaProjection": return `${formatSchemaTarget(issue)} 的 ${issue.projection} 投影不是 JSON object`; case "schemaFieldSetMismatch": @@ -454,12 +498,12 @@ function formatContractDefinitionIssue(issue: ContractDefinitionIssue): string { return `命令 ${issue.command} 的 stream 必须声明至少一个 record`; case "invalidCapability": return `CLI 的 ${issue.capability} 能力无效`; - case "invalidOutputFormat": - return `CLI 输出格式应为 ${issue.expected},实际为 ${issue.received ?? "非字符串"}`; - case "invalidOutputCompatibilityFlag": - return `CLI 输出兼容 flag ${issue.flag} 无效`; - case "invalidOutputCompatibilityFlags": - return `CLI 输出兼容 flags 必须是普通 record,实际为 ${issue.received}`; + case "invalidOutputModes": + return `命令 ${issue.command} 的输出方式集合无效(${issue.reason})`; + case "invalidDefaultOutputMode": + return `命令 ${issue.command} 的默认输出方式 ${issue.received ?? "缺失"} 无效`; + case "invalidOutputMode": + return `命令 ${issue.command} 的输出方式 ${issue.mode} 无效(${issue.reason})`; case "missingTextPresenter": return `命令 ${issue.command} 的 ${issue.location}${issue.variant === undefined ? "" : ` 变体 ${issue.variant}`} 缺少 text presenter`; case "unexpectedTextPresenter": @@ -512,6 +556,8 @@ function formatContractDefinitionIssue(issue: ContractDefinitionIssue): string { return `命令 ${issue.command} 的字段 ${issue.fields.join(", ")} 重复使用 long option ${issue.longOption}`; case "missingDataVariant": return `命令 ${issue.command} 必须声明至少一个 data 变体`; + case "invalidCommandExample": + return formatCommandExampleIssue(issue); case "invalidUsageConstraint": return `命令 ${issue.command} 的 usage constraint${issue.index === null ? "" : `[${issue.index}]`}${issue.member === undefined ? "" : ` ${issue.member}`} ${formatIssueValue(issue.received)} 无效${issue.expected === undefined ? "" : `,期望 ${issue.expected}`}${issue.path === undefined ? "" : `(${issue.path.join(".")})`}`; case "invalidCountConstraint": @@ -527,6 +573,38 @@ function formatContractDefinitionIssue(issue: ContractDefinitionIssue): string { } } +// oxlint-disable-next-line typescript/consistent-return -- 原因联合闭合,所有分支均返回诊断文本。 +function formatCommandExampleIssue( + issue: Extract< + ContractDefinitionIssue, + { readonly code: "invalidCommandExample" } + >, +): string { + const location = `命令 ${issue.command} 的示例[${issue.index}]`; + switch (issue.reason) { + case "invalidShape": + return `${location} 必须包含对象形式的 input`; + case "unknownField": + return `${location} 引用了未声明字段 ${issue.field ?? "<未知>"}`; + case "unencodableFieldValue": + return `${location} 的字段 ${issue.field ?? "<未知>"} 不符合可编码的原始字段形状`; + case "invalidOutputMode": + return `${location} 指定了命令 ${issue.command} 不存在的输出方式 ${issue.received ?? "<无效值>"}`; + case "parseDidNotComplete": + return issue.usageIssues === undefined + ? `${location} 的规范 argv 未解析为完整调用` + : `${location} 的调用语法无效:${formatIssueValue(issue.usageIssues)}`; + case "wrongCommand": + return `${location} 解析到命令 ${issue.received ?? "<未知>"},预期 ${issue.expected ?? issue.command}`; + case "wrongOutputMode": + return `${location} 解析到输出方式 ${issue.received ?? "<未知>"},预期 ${issue.expected ?? "<未知>"}`; + case "rawInputMismatch": + return `${location} 的原始输入无法经规范 argv 往返${issue.mismatchedFields === undefined ? "" : `:${issue.mismatchedFields.join(", ")}`}`; + case "inputRejected": + return `${location} 未通过输入模式验证:${(issue.evidence ?? []).map(({ message, path }) => `${message}${path === undefined ? "" : ` (${path.join(".")})`}`).join(";")}`; + } +} + function formatIssueValue(value: unknown): string { if (typeof value === "number" && !Number.isFinite(value)) { return String(value); diff --git a/packages/lib/src/contract-discovery.ts b/packages/lib/src/contract-discovery.ts new file mode 100644 index 0000000..a88f8c4 --- /dev/null +++ b/packages/lib/src/contract-discovery.ts @@ -0,0 +1,491 @@ +import { + getCompiledCli, + isCompiledExecutable, + type AnyCliContract, + type CommandGroupManifest, + type CommandSuccessManifest, + type CommandWireManifest, + type CommandExampleManifest, + type ExecutableCommandManifest, + type FailureVariantManifest, + type FieldGrammar, + type OutputModeWireManifest, + type RootCommandManifest, + type UsageConstraintGrammar, +} from "#/cli-contract"; +import type { CommandUsage } from "#/cli-invocation"; +import type { JsonObject } from "#/contract-schema"; +import { deepFreeze } from "#/json-value"; +import type { StreamRecordManifest } from "#/stream-variant-compiler"; + +type RuntimeCommand = ReturnType["commands"][string]; +type RuntimeExecutable = Extract< + RuntimeCommand, + { readonly kind: "command" | "rootCommand" } +>; +type RuntimeGroup = Extract< + RuntimeCommand, + { readonly kind: "commandGroup" | "rootGroup" } +>; +type CommandManifest = + | RootCommandManifest + | CommandGroupManifest + | ExecutableCommandManifest; + +export interface ContractDirectoryChild { + readonly command: string; + readonly kind: "group" | "command"; + readonly name: string; + readonly description: string; + readonly path: readonly string[]; + readonly nextArgv: readonly string[]; +} + +export interface ContractQueryNext { + readonly target: string; + readonly argv: readonly string[]; +} + +export interface ContractDirectoryQuery { + readonly kind: "directory"; + readonly schemaVersion: string; + readonly command: string; + readonly path: readonly string[]; + readonly name: string; + readonly description: string; + readonly children: readonly ContractDirectoryChild[]; +} + +export interface ContractSummaryMode { + readonly description: string; + readonly delivery: "atomic" | "stream"; + readonly format: "structured" | "text"; +} + +export interface ContractSummaryQuery { + readonly kind: "summary"; + readonly schemaVersion: string; + readonly command: string; + readonly path: readonly string[]; + readonly name: string; + readonly description: string; + readonly usage: CommandUsage; + readonly fields: readonly FieldGrammar[]; + readonly usageConstraints: readonly UsageConstraintGrammar[]; + readonly outputs: Readonly<{ + readonly default: string; + readonly modes: Readonly>; + }>; + readonly examples: readonly CommandExampleManifest[]; + readonly next: readonly ContractQueryNext[]; +} + +export interface ContractInputQuery { + readonly kind: "input"; + readonly schemaVersion: string; + readonly command: string; + readonly path: readonly string[]; + readonly fields: readonly FieldGrammar[]; + readonly usageConstraints: readonly UsageConstraintGrammar[]; + readonly usage: CommandUsage; + readonly input: Readonly<{ + readonly inputSchema: JsonObject; + readonly outputSchema: JsonObject; + }>; +} + +export type ContractSuccessManifest = + | Readonly<{ readonly kind: "completion" }> + | Readonly<{ + readonly kind: "data"; + readonly variants: Readonly>; + }> + | Readonly<{ + readonly kind: "stream"; + readonly text?: Readonly<{ readonly recordFraming: "fragment" }>; + }>; + +interface ModeQueryBase { + readonly schemaVersion: string; + readonly command: string; + readonly path: readonly string[]; + readonly mode: string; +} + +export interface ContractSuccessQuery extends ModeQueryBase { + readonly kind: "success"; + readonly success: ContractSuccessManifest; +} + +export interface ContractFailuresQuery extends ModeQueryBase { + readonly kind: "failures"; + readonly failures: Readonly>; +} + +export interface ContractRecordsQuery extends ModeQueryBase { + readonly kind: "records"; + readonly records: Readonly>; +} + +export interface ContractWireQuery extends ModeQueryBase { + readonly kind: "wire"; + readonly wire: OutputModeWireManifest; +} + +export type ContractQueryDocument = + | ContractDirectoryQuery + | ContractSummaryQuery + | ContractInputQuery + | ContractSuccessQuery + | ContractFailuresQuery + | ContractRecordsQuery + | ContractWireQuery; + +export type ContractQueryFailureReason = + | "discoveryNotInstalled" + | "unknownCommandPath" + | "invalidSyntax" + | "unknownPart" + | "unknownOutputMode" + | "unsupportedPart"; + +export interface ContractQueryFailure { + readonly kind: "queryFailure"; + readonly path: readonly string[]; + readonly target?: string; + readonly reason: ContractQueryFailureReason; +} + +export type ContractQueryResult = ContractQueryDocument | ContractQueryFailure; + +/** 以根之后的规范命令名路径查询发现事实;不接受内部 id 或 alias。 */ +export function queryCliContract( + contract: AnyCliContract, + path: readonly string[], + target?: string, +): ContractQueryResult { + const compiled = getCompiledCli(contract); + const canonicalPath = Object.freeze([...path]); + if (compiled.contract.grammar.controls.schema === undefined) { + return queryFailure(canonicalPath, target, "discoveryNotInstalled"); + } + const command = resolveCommandPath(compiled.commands, compiled.root, path); + if (command === undefined) { + return queryFailure(canonicalPath, target, "unknownCommandPath"); + } + const commandPath = canonicalCommandPath(compiled.commands, command.id); + const manifest = getCommandManifest( + compiled.contract.manifest.commands, + command.id, + ); + if (manifest === undefined) { + return queryFailure(canonicalPath, target, "unknownCommandPath"); + } + const schemaVersion = compiled.contract.manifest.schemaVersion; + + if (target === undefined) { + return isCompiledExecutable(command) + ? createSummaryQuery(schemaVersion, command, manifest, commandPath) + : createDirectoryQuery( + schemaVersion, + command, + commandPath, + compiled.commands, + ); + } + + if (!isCompiledExecutable(command) || !isExecutableManifest(manifest)) { + return queryFailure(canonicalPath, target, "unsupportedPart"); + } + if (target === "input") { + return deepFreeze({ + kind: "input" as const, + schemaVersion, + command: command.id, + path: commandPath, + fields: manifest.fields, + usageConstraints: manifest.usageConstraints ?? [], + usage: command.usage, + input: manifest.input, + }); + } + + const parsedTarget = parseSchemaTarget(target); + if (parsedTarget.kind === "failure") { + return queryFailure(canonicalPath, target, parsedTarget.reason); + } + const { part, mode: modeId } = parsedTarget; + if ( + !Object.hasOwn(command.outputModes, modeId) || + !Object.hasOwn(manifest.outputs.modes, modeId) + ) { + return queryFailure(canonicalPath, target, "unknownOutputMode"); + } + const outputMode = command.outputModes[modeId]; + const modeManifest = manifest.outputs.modes[modeId]; + if (outputMode === undefined || modeManifest === undefined) { + return queryFailure(canonicalPath, target, "unknownOutputMode"); + } + const base: ModeQueryBase = { + schemaVersion, + command: command.id, + path: commandPath, + mode: modeId, + }; + + switch (part) { + case "success": + return deepFreeze({ + ...base, + kind: "success" as const, + success: projectSuccess(modeManifest.success), + }); + case "failures": + return deepFreeze({ + ...base, + kind: "failures" as const, + failures: modeManifest.failures, + }); + case "records": + if (modeManifest.success.kind !== "stream") { + return queryFailure(canonicalPath, target, "unsupportedPart"); + } + return deepFreeze({ + ...base, + kind: "records" as const, + records: modeManifest.success.records, + }); + case "wire": { + if (outputMode.format !== "structured") { + return queryFailure(canonicalPath, target, "unsupportedPart"); + } + const wire = commandWireManifest(compiled, command.id)?.[modeId]; + return wire === undefined + ? queryFailure(canonicalPath, target, "unsupportedPart") + : deepFreeze({ ...base, kind: "wire" as const, wire }); + } + } +} + +export function canonicalCommandPath( + commands: Readonly>, + commandId: string, +): readonly string[] { + const path: string[] = []; + let current = commands[commandId]; + while (current?.parent !== undefined) { + path.unshift(current.name); + current = commands[current.parent]; + } + return Object.freeze(path); +} + +function createDirectoryQuery( + schemaVersion: string, + command: RuntimeGroup, + path: readonly string[], + commands: Readonly>, +): ContractDirectoryQuery { + const children = Object.values(commands) + .filter((candidate) => candidate.parent === command.id) + .map((candidate) => { + const childPath = canonicalCommandPath(commands, candidate.id); + return { + command: candidate.id, + kind: isCompiledExecutable(candidate) + ? ("command" as const) + : ("group" as const), + name: candidate.name, + description: candidate.description, + path: childPath, + nextArgv: [...childPath, "--schema"], + }; + }); + return deepFreeze({ + kind: "directory" as const, + schemaVersion, + command: command.id, + path, + name: command.name, + description: command.description, + children, + }); +} + +function createSummaryQuery( + schemaVersion: string, + command: RuntimeExecutable, + manifest: CommandManifest, + path: readonly string[], +): ContractSummaryQuery { + if (!isExecutableManifest(manifest)) { + throw new TypeError("已编译命令清单与运行时命令不一致"); + } + const modes = Object.fromEntries( + Object.entries(manifest.outputs.modes).map(([mode, output]) => [ + mode, + { + description: output.description, + delivery: output.delivery, + format: output.format, + }, + ]), + ); + const next: ContractQueryNext[] = [ + { + target: "input", + argv: [...path, "--schema=input"], + }, + ]; + for (const [mode, output] of Object.entries(modes)) { + next.push({ + target: `success:${mode}`, + argv: [...path, `--schema=success:${mode}`], + }); + next.push({ + target: `failures:${mode}`, + argv: [...path, `--schema=failures:${mode}`], + }); + if (output.delivery === "stream") { + next.push({ + target: `records:${mode}`, + argv: [...path, `--schema=records:${mode}`], + }); + } + if (output.format === "structured") { + next.push({ + target: `wire:${mode}`, + argv: [...path, `--schema=wire:${mode}`], + }); + } + } + return deepFreeze({ + kind: "summary" as const, + schemaVersion, + command: command.id, + path, + name: command.name, + description: command.description, + usage: command.usage, + fields: manifest.fields, + usageConstraints: manifest.usageConstraints ?? [], + outputs: { default: manifest.outputs.default, modes }, + examples: manifest.examples ?? [], + next, + }); +} + +function projectSuccess( + success: CommandSuccessManifest, +): ContractSuccessManifest { + if (success.kind !== "stream") return success; + return { + kind: "stream", + ...(success.text === undefined ? {} : { text: success.text }), + }; +} + +function parseSchemaTarget(target: string): + | Readonly<{ + readonly kind: "selected"; + readonly part: "success" | "failures" | "records" | "wire"; + readonly mode: string; + }> + | Readonly<{ + readonly kind: "failure"; + readonly reason: "invalidSyntax" | "unknownPart"; + }> { + if (target.length === 0) { + return { kind: "failure", reason: "invalidSyntax" }; + } + const separator = target.indexOf(":"); + if (separator < 0) { + return { + kind: "failure", + reason: ["input", "success", "failures", "records", "wire"].includes( + target, + ) + ? "invalidSyntax" + : "unknownPart", + }; + } + if (separator === 0 || separator !== target.lastIndexOf(":")) { + return { kind: "failure", reason: "invalidSyntax" }; + } + const part = target.slice(0, separator); + const mode = target.slice(separator + 1); + if ( + ["input", "success", "failures", "records", "wire"].includes(part) && + mode.length === 0 + ) { + return { kind: "failure", reason: "invalidSyntax" }; + } + if (mode.length === 0) return { kind: "failure", reason: "invalidSyntax" }; + if (part === "input") return { kind: "failure", reason: "invalidSyntax" }; + if ( + part !== "success" && + part !== "failures" && + part !== "records" && + part !== "wire" + ) { + return { kind: "failure", reason: "unknownPart" }; + } + return { kind: "selected", part, mode }; +} + +function resolveCommandPath( + commands: Readonly>, + root: string, + path: readonly string[], +): RuntimeCommand | undefined { + let current = commands[root]; + for (const name of path) { + if (current === undefined || isCompiledExecutable(current)) + return undefined; + current = Object.values(commands).find( + (candidate) => + candidate.parent === current?.id && candidate.name === name, + ); + } + return current; +} + +function getCommandManifest( + commands: + | Readonly> + | Readonly>, + command: string, +): CommandManifest | undefined { + return (commands as Readonly>)[command]; +} + +function isExecutableManifest( + manifest: CommandManifest, +): manifest is RootCommandManifest | ExecutableCommandManifest { + return manifest.kind === "rootCommand" || manifest.kind === "command"; +} + +function commandWireManifest( + compiled: ReturnType, + command: string, +): CommandWireManifest | undefined { + const root = compiled.commands[compiled.root]; + if (root === undefined) return undefined; + const wire = compiled.contract.manifest.wire; + return isCompiledExecutable(root) + ? (wire as CommandWireManifest) + : (wire as Readonly>)[command]; +} + +function queryFailure( + path: readonly string[], + target: string | undefined, + reason: ContractQueryFailureReason, +): ContractQueryFailure { + return deepFreeze({ + kind: "queryFailure" as const, + path, + ...(target === undefined ? {} : { target }), + reason, + }); +} diff --git a/packages/lib/src/contract-schema-compiler.ts b/packages/lib/src/contract-schema-compiler.ts index 2c21e71..7869949 100644 --- a/packages/lib/src/contract-schema-compiler.ts +++ b/packages/lib/src/contract-schema-compiler.ts @@ -11,6 +11,7 @@ import type { ContractSchema, JsonObject } from "#/contract-schema"; import { isSingleLineText } from "#/description"; import { invalidFieldIdentityReason } from "#/field-identity"; import { copyJsonObject, deepFreeze } from "#/json-value"; +import { findUnresolvedSchemaReferences } from "#/schema-resource"; export function compileContractSchema( schema: ContractSchema, @@ -80,6 +81,8 @@ export function compileContractSchema( if (inputSchema === undefined || outputSchema === undefined) { return undefined; } + collectUnresolvedReferences(inputSchema, target, "input", issues); + collectUnresolvedReferences(outputSchema, target, "output", issues); checkDraft202012(inputSchema, target, "input", issues); checkDraft202012(outputSchema, target, "output", issues); checkInputDefaults(inputSchema, outputSchema, target, issues); @@ -88,6 +91,28 @@ export function compileContractSchema( : undefined; } +function collectUnresolvedReferences( + schema: JsonObject, + target: SchemaDefinitionTarget, + projection: "input" | "output", + issues: ContractDefinitionIssue[], +): void { + const identity = [ + target.command, + target.location, + "variant" in target ? target.variant : "input", + projection, + ].join(":"); + for (const reference of findUnresolvedSchemaReferences(schema, identity)) { + issues.push({ + code: "schemaReferenceNotSelfContained", + ...target, + projection, + reference, + }); + } +} + export function projectFieldDescriptions( schema: SchemaManifest, fields: readonly FieldGrammar[], diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 53f8ebb..39d3c7f 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -1,9 +1,10 @@ export { defineCli, helpCapability, - outputCapability, + schemaCapability, versionCapability, type CliContract, + type AnyCliContract, type CliContractDependencies, type CliContractRawInput, type CliContractResult, @@ -12,6 +13,8 @@ export { type CliManifest, type CommandCombination, type CommandCombinationBrand, + type CommandExampleDefinition, + type CommandExampleManifest, type CommandGrammar, type CommandGroupDefinition, type CommandGroupGrammar, @@ -20,17 +23,11 @@ export { type CountUsageConstraint, type CanonicalLongOption, type CompletionFact, - type CompletionHandlerContext, type CompletionOutcome, - type CompletionRootCliDefinition, - type CompletionRootCommandDefinition, type ContractTypeError, type DataFact, type DataFactUnion, - type DataHandlerContext, type DataOutcome, - type DataRootCliDefinition, - type DataRootCommandDefinition, type DataVariantDefinition, type DataVariantDefinitions, type StreamHandlerContext, @@ -39,8 +36,6 @@ export { type StreamRecordDefinitions, type StreamRecordFact, type StreamRecordFactUnion, - type StreamRootCliDefinition, - type StreamRootCommandDefinition, type StreamSuccessFact, type DefineCli, type DefineCommand, @@ -65,11 +60,20 @@ export { type HierarchyCliGrammar, type HierarchyCliManifest, type OutcomeFact, - type OutputCapability, + type AtomicOutputModeDefinition, + type StreamOutputModeDefinition, + type OutputDelivery, type OutputFormat, + type OutputModeDefinition, + type OutputModeFactory, + type OutputModeManifest, + type OutputModesDefinition, + type OutputModesGrammar, + type OutputModesManifest, text, TextValueError, type SilentText, + type TextFragment, type TextLine, type TextLines, type TextValueErrorIssue, @@ -86,6 +90,8 @@ export { type RawValueOptionInputForSchema, type RootCliDefinition, type RootGroupDefinition, + type SchemaCapability, + type SchemaCapabilityDefinition, type RootGroupGrammar, type RootCommandDefinition, type RootCommandGrammar, @@ -110,6 +116,24 @@ export { type WhenUsageConstraint, } from "#/cli-contract"; export type { AtomicTextPresenter } from "#/outcome-fact"; +export { + queryCliContract, + type ContractDirectoryChild, + type ContractDirectoryQuery, + type ContractFailuresQuery, + type ContractInputQuery, + type ContractQueryDocument, + type ContractQueryFailure, + type ContractQueryFailureReason, + type ContractQueryNext, + type ContractQueryResult, + type ContractRecordsQuery, + type ContractSuccessManifest, + type ContractSuccessQuery, + type ContractSummaryMode, + type ContractSummaryQuery, + type ContractWireQuery, +} from "#/contract-discovery"; export { executeCli, type CliOutput, @@ -124,7 +148,7 @@ export { type CliInvocation, type CommandUsage, type ConflictingFlagIssue, - type ConflictingOutputFormatIssue, + type ConflictingOutputModeIssue, type CountConstraintViolationIssue, type ConditionalConstraintViolationIssue, type EqualsUsageObservation, @@ -132,7 +156,8 @@ export { type HelpRequest, type InputRejectedIssue, type InvalidFieldChoiceIssue, - type InvalidOutputFormatIssue, + type InvalidSchemaQueryIssue, + type InvalidOutputModeIssue, type MissingOptionValueIssue, type MissingRequiredFieldIssue, type NonEmptyCliUsageIssues, @@ -140,6 +165,7 @@ export { type OptionOccurrenceEvidence, type ParsedInvocation, type RepeatedOptionIssue, + type SchemaRequest, type PresentUsageObservation, type SchemaIssueEvidence, type UnexpectedPositionalIssue, diff --git a/packages/lib/src/outcome-fact.ts b/packages/lib/src/outcome-fact.ts index 73fd0e0..7b79a8e 100644 --- a/packages/lib/src/outcome-fact.ts +++ b/packages/lib/src/outcome-fact.ts @@ -288,52 +288,84 @@ function encodeApplicationText( } export type DataVariantDefinition< - Payload = unknown, + Input = unknown, TextEnabled extends boolean = boolean, + Output = Input, > = { readonly description: string; - readonly schema: ContractSchema; + readonly schema: ContractSchema; readonly exitCode: number; } & (TextEnabled extends true - ? Readonly<{ readonly text: AtomicTextPresenter }> + ? Readonly<{ readonly text: AtomicTextPresenter }> : TextEnabled extends false ? Readonly<{ readonly text?: never }> - : Readonly<{ readonly text?: AtomicTextPresenter }>); - -export type DataVariantDefinitions = - Readonly>>; + : Readonly<{ readonly text?: AtomicTextPresenter }>); + +export type DataVariantDefinitions<_TextEnabled extends boolean = boolean> = + Readonly< + Record< + string, + Readonly<{ + readonly description: string; + readonly schema: ContractSchema; + readonly exitCode: number; + readonly text?: unknown; + }> + > + >; export type StreamRecordDefinition< - Payload = unknown, + Input = unknown, TextEnabled extends boolean = boolean, + Output = Input, > = Readonly<{ readonly description: string; - readonly schema: ContractSchema; + readonly schema: ContractSchema; }> & (TextEnabled extends true - ? Readonly<{ readonly text: StreamTextPresenter }> + ? Readonly<{ readonly text: StreamTextPresenter }> : TextEnabled extends false ? Readonly<{ readonly text?: never }> - : Readonly<{ readonly text?: StreamTextPresenter }>); - -export type StreamRecordDefinitions = - Readonly>>; + : Readonly<{ readonly text?: StreamTextPresenter }>); + +export type StreamRecordDefinitions<_TextEnabled extends boolean = boolean> = + Readonly< + Record< + string, + Readonly<{ + readonly description: string; + readonly schema: ContractSchema; + readonly text?: unknown; + }> + > + >; export type FailureVariantDefinition< - Payload = unknown, + Input = unknown, TextEnabled extends boolean = boolean, + Output = Input, > = { readonly description: string; - readonly schema: ContractSchema; + readonly schema: ContractSchema; readonly exitCode: number; } & (TextEnabled extends true - ? Readonly<{ readonly text: AtomicTextPresenter }> + ? Readonly<{ readonly text: AtomicTextPresenter }> : TextEnabled extends false ? Readonly<{ readonly text?: never }> - : Readonly<{ readonly text?: AtomicTextPresenter }>); - -export type FailureVariantDefinitions = - Readonly>>; + : Readonly<{ readonly text?: AtomicTextPresenter }>); + +export type FailureVariantDefinitions<_TextEnabled extends boolean = boolean> = + Readonly< + Record< + string, + Readonly<{ + readonly description: string; + readonly schema: ContractSchema; + readonly exitCode: number; + readonly text?: unknown; + }> + > + >; export interface CompletionFact { readonly kind: "completion"; @@ -394,17 +426,22 @@ export interface CompletionOutcome { completion(): CompletionFact; } -type DataVariantPayload = - Variant extends DataVariantDefinition ? Payload : never; +type DataVariantPayload = + Variant extends DataVariantDefinition + ? Phase extends "input" + ? Input + : Output + : never; export type DataFactUnion< Command extends string, Variants extends DataVariantDefinitions, + Phase extends "input" | "output" = "input", > = { readonly [Variant in keyof Variants & string]: DataFact< Command, Variant, - DataVariantPayload + DataVariantPayload >; }[keyof Variants & string]; @@ -423,23 +460,31 @@ export type DataOutcome< }>; }>; -type FailureVariantPayload = - Variant extends FailureVariantDefinition ? Payload : never; +type FailureVariantPayload< + Variant, + Phase extends "input" | "output" = "input", +> = + Variant extends FailureVariantDefinition + ? Phase extends "input" + ? Input + : Output + : never; export type FailureFactUnion< Command extends string, - Failures extends FailureVariantDefinitions, + Failures extends object, + Phase extends "input" | "output" = "input", > = { readonly [Variant in keyof Failures & string]: FailureFact< Command, Variant, - FailureVariantPayload + FailureVariantPayload >; }[keyof Failures & string]; export type FailureOutcome< Command extends string, - Failures extends FailureVariantDefinitions, + Failures extends object, > = Readonly<{ readonly failure: Readonly<{ [Variant in keyof Failures]: ( @@ -452,17 +497,22 @@ export type FailureOutcome< }>; }>; -type StreamRecordPayload = - Variant extends StreamRecordDefinition ? Payload : never; +type StreamRecordPayload = + Variant extends StreamRecordDefinition + ? Phase extends "input" + ? Input + : Output + : never; export type StreamRecordFactUnion< Command extends string, Records extends StreamRecordDefinitions, + Phase extends "input" | "output" = "input", > = { readonly [Variant in keyof Records & string]: StreamRecordFact< Command, Variant, - StreamRecordPayload + StreamRecordPayload >; }[keyof Records & string]; diff --git a/packages/lib/src/outcome-wire.ts b/packages/lib/src/outcome-wire.ts index ff50d0a..c54e450 100644 --- a/packages/lib/src/outcome-wire.ts +++ b/packages/lib/src/outcome-wire.ts @@ -1,19 +1,25 @@ import type { JsonObject, JsonValue } from "#/contract-schema"; +import { embedSchemaResource } from "#/schema-resource"; -const schemaVersion = "2" as const; +const schemaVersion = "3" as const; export function createCompletionEnvelope( command: Command, + outputMode: string, ) { return Object.freeze({ schemaVersion, command, + outputMode, kind: "completion" as const, }); } -export function createCompletionWireSchema(command: string): JsonObject { - const envelope = createCompletionEnvelope(command); +export function createCompletionWireSchema( + command: string, + outputMode: string, +): JsonObject { + const envelope = createCompletionEnvelope(command, outputMode); return createEnvelopeSchema(envelope, {}); } @@ -21,25 +27,31 @@ export function createDataEnvelope< Command extends string, Variant extends string, Data, ->(command: Command, variant: Variant, data: Data) { - return createAtomicEnvelope(command, "data", variant, data); +>(command: Command, outputMode: string, variant: Variant, data: Data) { + return createAtomicEnvelope(command, outputMode, "data", variant, data); } export function createDataWireSchema( command: string, + outputMode: string, variant: string, dataSchema: JsonObject, ): JsonObject { - const envelope = createDataEnvelope(command, variant, null); - return createEnvelopeSchema(envelope, { data: dataSchema }); + const envelope = createDataEnvelope(command, outputMode, variant, null); + return createEnvelopeSchema(envelope, { + data: embedSchemaResource( + dataSchema, + `data:${command}:${outputMode}:${variant}`, + ), + }); } export function createFailureEnvelope< Command extends string, Variant extends string, Data, ->(command: Command, variant: Variant, data: Data) { - return createAtomicEnvelope(command, "failure", variant, data); +>(command: Command, outputMode: string, variant: Variant, data: Data) { + return createAtomicEnvelope(command, outputMode, "failure", variant, data); } function createAtomicEnvelope< @@ -47,10 +59,17 @@ function createAtomicEnvelope< Kind extends "data" | "failure", Variant extends string, Data, ->(command: Command, kind: Kind, variant: Variant, data: Data) { +>( + command: Command, + outputMode: string, + kind: Kind, + variant: Variant, + data: Data, +) { return Object.freeze({ schemaVersion, command, + outputMode, kind, variant, data, @@ -59,17 +78,29 @@ function createAtomicEnvelope< export function createFailureWireSchema( command: string, + outputMode: string, variant: string, dataSchema: JsonObject, ): JsonObject { - const envelope = createFailureEnvelope(command, variant, null); - return createEnvelopeSchema(envelope, { data: dataSchema }); + const envelope = createFailureEnvelope(command, outputMode, variant, null); + return createEnvelopeSchema(envelope, { + data: embedSchemaResource( + dataSchema, + `failure:${command}:${outputMode}:${variant}`, + ), + }); } export function createStreamHeaderEnvelope( command: Command, + outputMode: string, ) { - return Object.freeze({ schemaVersion, command, kind: "stream" as const }); + return Object.freeze({ + schemaVersion, + command, + outputMode, + kind: "stream" as const, + }); } export function createStreamRecordEnvelope( @@ -83,16 +114,27 @@ export function createStreamSuccessEnvelope() { return Object.freeze({ kind: "streamSuccess" as const }); } -export function createStreamHeaderWireSchema(command: string): JsonObject { - return createEnvelopeSchema(createStreamHeaderEnvelope(command), {}); +export function createStreamHeaderWireSchema( + command: string, + outputMode: string, +): JsonObject { + return createEnvelopeSchema( + createStreamHeaderEnvelope(command, outputMode), + {}, + ); } export function createStreamRecordWireSchema( + command: string, + outputMode: string, variant: string, dataSchema: JsonObject, ): JsonObject { return createEnvelopeSchema(createStreamRecordEnvelope(variant, null), { - data: dataSchema, + data: embedSchemaResource( + dataSchema, + `record:${command}:${outputMode}:${variant}`, + ), }); } @@ -213,11 +255,11 @@ function usageIssueSchemas(): JsonObject[] { }; const outputOccurrence = objectSchema( { - format: { enum: ["structured", "text"] }, + mode: string, option: string, position, }, - ["position", "option", "format"], + ["position", "option", "mode"], ); return [ @@ -226,7 +268,7 @@ function usageIssueSchemas(): JsonObject[] { negativeOccurrences: occurrences(1), positiveOccurrences: occurrences(1), }), - issueSchema("conflictingOutputFormat", { + issueSchema("conflictingOutputMode", { occurrences: { items: outputOccurrence, minItems: 2, type: "array" }, }), { @@ -277,11 +319,25 @@ function usageIssueSchemas(): JsonObject[] { position, value: string, }), - issueSchema("invalidOutputFormat", { + issueSchema("invalidOutputMode", { + available: { items: string, minItems: 1, type: "array" }, option: string, position, received: { oneOf: [string, { type: "null" }] }, }), + issueSchema("invalidSchemaQuery", { + option: { const: "--schema" }, + position, + reason: { + enum: [ + "invalidSyntax", + "unknownPart", + "unknownOutputMode", + "unsupportedPart", + ], + }, + target: { oneOf: [string, { type: "null" }] }, + }), issueSchema("missingOptionValue", { field: string, option: string, diff --git a/packages/lib/src/schema-resource.ts b/packages/lib/src/schema-resource.ts new file mode 100644 index 0000000..6a20ca8 --- /dev/null +++ b/packages/lib/src/schema-resource.ts @@ -0,0 +1,380 @@ +import type { JsonObject } from "#/contract-schema"; + +const schemaObjectKeywords = [ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", +] as const; + +const schemaSingleKeywords = [ + "additionalProperties", + "contentSchema", + "contains", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", +] as const; + +const schemaArrayKeywords = ["allOf", "anyOf", "oneOf", "prefixItems"] as const; + +type ReferenceKeyword = "$ref" | "$dynamicRef"; + +interface SchemaResource { + readonly id: string; + readonly root: Readonly>; + readonly index: number; + readonly anchors: Set; +} + +interface SchemaReference { + readonly reference: string; + readonly resource: SchemaResource; + readonly keyword: ReferenceKeyword; + target?: SchemaResource; + fragmentReference?: string; +} + +interface SchemaResourceGraph { + readonly resources: readonly SchemaResource[]; + readonly resourceByNode: WeakMap; + readonly referencesByNode: WeakMap< + object, + Map + >; + readonly unresolvedReferences: readonly string[]; +} + +export function embedSchemaResource( + schema: JsonObject, + identity: string, +): JsonObject { + const graph = createSchemaResourceGraph(schema, identity); + const resourceIds = new Map( + graph.resources.map((resource) => [ + resource, + createEmbeddedResourceId(identity, resource.index), + ]), + ); + + const rewrite = (value: unknown): unknown => { + if (!isRecord(value)) return value; + const mapped = mapSchemaChildren(value, rewrite); + let result: Record | undefined; + const resource = graph.resourceByNode.get(value); + const hasInvalidRootId = + value === schema && + Object.hasOwn(value, "$id") && + typeof value.$id !== "string"; + if (resource !== undefined && !hasInvalidRootId) { + result = { ...mapped, $id: resourceIds.get(resource) }; + } + + for (const reference of graph.referencesByNode.get(value)?.values() ?? []) { + if (reference.target === undefined) continue; + const rewritten = relocateReference(reference, resourceIds); + if (rewritten === reference.reference) continue; + result ??= { ...mapped }; + result[reference.keyword] = rewritten; + } + return result ?? mapped; + }; + + const embedded = rewrite(schema); + return isJsonObject(embedded) ? embedded : schema; +} + +export function findUnresolvedSchemaReferences( + schema: JsonObject, + identity: string, +): readonly string[] { + return createSchemaResourceGraph(schema, identity).unresolvedReferences; +} + +function createSchemaResourceGraph( + schema: JsonObject, + identity: string, +): SchemaResourceGraph { + const resources: SchemaResource[] = []; + const resourcesById = new Map(); + const resourceByNode = new WeakMap(); + const referencesByNode = new WeakMap< + object, + Map + >(); + const references: SchemaReference[] = []; + const unresolvedReferences: string[] = []; + const fallbackId = createSchemaResourceId(identity); + const resolvedRootId = + typeof schema.$id === "string" + ? resolveIdentifier(schema.$id, fallbackId) + : fallbackId; + if (resolvedRootId === undefined && typeof schema.$id === "string") { + unresolvedReferences.push(schema.$id); + } + const root = createResource( + resolvedRootId ?? fallbackId, + schema, + resources, + resourcesById, + resourceByNode, + ); + + visitSchema(schema, root, true); + for (const reference of references) { + const resolved = resolveReference( + reference.reference, + reference.resource.id, + ); + const targets = + resolved === undefined + ? undefined + : resourcesById.get(resolved.resourceId); + const target = targets?.length === 1 ? targets[0] : undefined; + if ( + resolved === undefined || + target === undefined || + !referenceTargetExists(target, resolved.fragment) + ) { + unresolvedReferences.push(reference.reference); + continue; + } + reference.target = target; + reference.fragmentReference = resolved.fragmentReference; + } + + return { + resources, + resourceByNode, + referencesByNode, + unresolvedReferences: [...new Set(unresolvedReferences)], + }; + + function visitSchema( + value: unknown, + inheritedResource: SchemaResource, + rootNode = false, + ): void { + if (!isRecord(value)) return; + let resource = inheritedResource; + if (!rootNode && typeof value.$id === "string") { + const id = resolveIdentifier(value.$id, inheritedResource.id); + if (id === undefined) { + unresolvedReferences.push(value.$id); + } else { + resource = createResource( + id, + value, + resources, + resourcesById, + resourceByNode, + ); + } + } + if (rootNode) resourceByNode.set(value, resource); + for (const anchorKey of ["$anchor", "$dynamicAnchor"] as const) { + const anchor = value[anchorKey]; + if (typeof anchor === "string") resource.anchors.add(anchor); + } + for (const keyword of ["$ref", "$dynamicRef"] as const) { + const reference = value[keyword]; + if (typeof reference !== "string") continue; + const parsed: SchemaReference = { reference, resource, keyword }; + references.push(parsed); + const current = referencesByNode.get(value) ?? new Map(); + current.set(keyword, parsed); + referencesByNode.set(value, current); + } + mapSchemaChildren(value, (child) => { + visitSchema(child, resource); + return child; + }); + } +} + +function createSchemaResourceId(identity: string): string { + return `https://schemas.cli-contract.invalid/${encodeURIComponent(identity)}`; +} + +function createEmbeddedResourceId(identity: string, index: number): string { + return `https://schemas.cli-contract.invalid/wire/${encodeURIComponent(identity)}/${index}`; +} + +function createResource( + id: string, + root: Readonly>, + resources: SchemaResource[], + resourcesById: Map, + resourceByNode: WeakMap, +): SchemaResource { + const resourceId = withoutFragment(id); + const resource: SchemaResource = { + id: resourceId, + root, + index: resources.length, + anchors: new Set(), + }; + resources.push(resource); + const sameIdentity = resourcesById.get(resourceId) ?? []; + resourcesById.set(resourceId, [...sameIdentity, resource]); + resourceByNode.set(root, resource); + return resource; +} + +function mapSchemaChildren( + value: Record, + mapChild: (child: unknown) => unknown, +): Record { + let mapped: Record | undefined; + const set = (key: string, child: unknown): void => { + if (child === value[key]) return; + mapped ??= { ...value }; + mapped[key] = child; + }; + + for (const keyword of schemaObjectKeywords) { + const children = value[keyword]; + if (!isRecord(children)) continue; + let mappedChildren: Record | undefined; + for (const [name, child] of Object.entries(children)) { + const next = mapChild(child); + if (next === child) continue; + mappedChildren ??= { ...children }; + mappedChildren[name] = next; + } + if (mappedChildren !== undefined) set(keyword, mappedChildren); + } + for (const keyword of schemaSingleKeywords) { + set(keyword, mapChild(value[keyword])); + } + for (const keyword of schemaArrayKeywords) { + const children = value[keyword]; + if (!Array.isArray(children)) continue; + let mappedChildren: unknown[] | undefined; + for (const [index, child] of children.entries()) { + const next = mapChild(child); + if (next === child) continue; + mappedChildren ??= [...children]; + mappedChildren[index] = next; + } + if (mappedChildren !== undefined) set(keyword, mappedChildren); + } + return mapped ?? value; +} + +function resolveIdentifier( + identifier: string, + baseId: string, +): string | undefined { + try { + return withoutFragment(new URL(identifier, baseId).href); + } catch { + return undefined; + } +} + +function resolveReference( + reference: string, + baseId: string, +): + | Readonly<{ + readonly resourceId: string; + readonly fragment: string; + readonly fragmentReference: string; + }> + | undefined { + try { + const resolved = new URL(reference, baseId); + const fragment = decodeURIComponent(resolved.hash.slice(1)); + const fragmentStart = reference.indexOf("#"); + const fragmentReference = + fragmentStart < 0 ? "" : reference.slice(fragmentStart); + resolved.hash = ""; + return { + resourceId: resolved.href, + fragment, + fragmentReference, + }; + } catch { + return undefined; + } +} + +function relocateReference( + reference: SchemaReference, + resourceIds: ReadonlyMap, +): string { + const target = reference.target; + if (target === undefined) return reference.reference; + if (target === reference.resource && reference.reference.startsWith("#")) { + return reference.reference; + } + if (target === reference.resource) { + return reference.fragmentReference || "#"; + } + const targetId = resourceIds.get(target); + return targetId === undefined + ? reference.reference + : `${targetId}${reference.fragmentReference}`; +} + +function withoutFragment(uri: string): string { + const resolved = new URL(uri); + resolved.hash = ""; + return resolved.href; +} + +function referenceTargetExists( + resource: SchemaResource, + fragment: string, +): boolean { + if (fragment === "") return true; + if (!fragment.startsWith("/")) return resource.anchors.has(fragment); + + let current: unknown = resource.root; + for (const encodedSegment of fragment.slice(1).split("/")) { + const segment = decodePointerSegment(encodedSegment); + if (segment === undefined) return false; + if (Array.isArray(current)) { + if (!/^\d+$/.test(segment)) return false; + current = current[Number(segment)]; + } else if (isRecord(current) && Object.hasOwn(current, segment)) { + current = current[segment]; + } else { + return false; + } + if (current === undefined) return false; + } + return true; +} + +function decodePointerSegment(segment: string): string | undefined { + let decoded = ""; + for (let index = 0; index < segment.length; index += 1) { + const character = segment[index]; + if (character !== "~") { + decoded += character; + continue; + } + const escape = segment[index + 1]; + if (escape === "0") decoded += "~"; + else if (escape === "1") decoded += "/"; + else return undefined; + index += 1; + } + return decoded; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/lib/test/application-failure.test.ts b/packages/lib/test/application-failure.test.ts index f224b26..6911544 100644 --- a/packages/lib/test/application-failure.test.ts +++ b/packages/lib/test/application-failure.test.ts @@ -6,21 +6,22 @@ import { ContractExecutionError, defineCli, executeCli, - helpCapability, - outputCapability, parseCliInvocation, type ContractSchema, } from "@ykdz/cli-contract"; import { z } from "zod"; +import { englishHelpCapability } from "./english-help.ts"; +import { omitPayloadRootResourceId } from "./schema-test-helpers.ts"; + const emptyInput = z.object({}); const unavailableData = z.object({ service: z.string() }); void test("具名应用失败写入 stderr 并返回显式失败状态", async () => { const cli = defineCli()({ root: "check", - help: helpCapability(), - output: outputCapability({ defaultFormat: "structured" }), + help: englishHelpCapability(), + usageFailureExitCode: 64, commands: { check: { @@ -28,22 +29,34 @@ void test("具名应用失败写入 stderr 并返回显式失败状态", async ( name: "check", description: "检查服务", input: emptyInput, - success: { kind: "completion" }, - failures: { - unavailable: { - description: "服务暂不可用", - schema: unavailableData, - exitCode: 9, + + outputs: defineCli().outputModes("check", emptyInput, (mode) => ({ + default: "structured", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { kind: "completion" }, + failures: { + unavailable: { + description: "服务暂不可用", + schema: unavailableData, + exitCode: 9, + }, + }, + handler: ({ outcome }) => + outcome.failure.unavailable({ service: "billing" }), + }), }, - }, - handler: ({ outcome }) => - outcome.failure.unavailable({ service: "billing" }), + })), }, }, }); const writes: Array> = []; - assert.deepEqual(cli.manifest.commands.check.failures, { + const structuredMode = cli.manifest.commands.check.outputs.modes.structured; + assert.ok(structuredMode); + assert.deepEqual(structuredMode.failures, { unavailable: { description: "服务暂不可用", exitCode: 9, @@ -64,12 +77,17 @@ void test("具名应用失败写入 stderr 并返回显式失败状态", async ( }, }, }); - assert.deepEqual(cli.manifest.wire.failure?.unavailable, { + const structuredWire = cli.manifest.wire.structured; + assert.ok(structuredWire); + const wireFailure = structuredWire.failure?.unavailable; + assert.ok(wireFailure); + assert.deepEqual(omitPayloadRootResourceId(wireFailure), { $schema: "https://json-schema.org/draft/2020-12/schema", additionalProperties: false, properties: { - schemaVersion: { const: "2" }, + schemaVersion: { const: "3" }, command: { const: "check" }, + outputMode: { const: "structured" }, kind: { const: "failure" }, variant: { const: "unavailable" }, data: { @@ -81,7 +99,14 @@ void test("具名应用失败写入 stderr 并返回显式失败状态", async ( additionalProperties: false, }, }, - required: ["schemaVersion", "command", "kind", "variant", "data"], + required: [ + "schemaVersion", + "command", + "outputMode", + "kind", + "variant", + "data", + ], type: "object", }); @@ -97,12 +122,13 @@ void test("具名应用失败写入 stderr 并返回显式失败状态", async ( { destination: "stderr", chunk: - '{"schemaVersion":"2","command":"check","kind":"failure","variant":"unavailable","data":{"service":"billing"}}\n', + '{"schemaVersion":"3","command":"check","outputMode":"structured","kind":"failure","variant":"unavailable","data":{"service":"billing"}}\n', }, ]); assert.deepEqual(termination, { kind: "applicationResult", command: "check", + outputMode: "structured", result: { kind: "failure", command: "check", @@ -116,8 +142,8 @@ void test("具名应用失败写入 stderr 并返回显式失败状态", async ( void test("非法失败 payload 在任何写出前被拒绝", async () => { const cli = defineCli()({ root: "check", - help: helpCapability(), - output: outputCapability({ defaultFormat: "structured" }), + help: englishHelpCapability(), + usageFailureExitCode: 64, commands: { check: { @@ -125,19 +151,29 @@ void test("非法失败 payload 在任何写出前被拒绝", async () => { name: "check", description: "检查服务", input: emptyInput, - success: { kind: "completion" }, - failures: { - unavailable: { - description: "服务暂不可用", - schema: unavailableData, - exitCode: 9, + + outputs: defineCli().outputModes("check", emptyInput, (mode) => ({ + default: "structured", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { kind: "completion" }, + failures: { + unavailable: { + description: "服务暂不可用", + schema: unavailableData, + exitCode: 9, + }, + }, + handler: ({ outcome }) => + outcome.failure.unavailable({ + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 负例故意注入错误类型,以验证结果边界。 + service: 1 as unknown as string, + }), + }), }, - }, - handler: ({ outcome }) => - outcome.failure.unavailable({ - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- 负例故意注入错误类型,以验证结果边界。 - service: 1 as unknown as string, - }), + })), }, }, }); @@ -184,8 +220,8 @@ void test("failure 输出模式的空 issues 仍产生 outputSchemaRejected", as }; const cli = defineCli()({ root: "check", - help: helpCapability(), - output: outputCapability({ defaultFormat: "structured" }), + help: englishHelpCapability(), + usageFailureExitCode: 64, commands: { check: { @@ -193,16 +229,26 @@ void test("failure 输出模式的空 issues 仍产生 outputSchemaRejected", as name: "check", description: "检查服务", input: emptyInput, - success: { kind: "completion" }, - failures: { - unavailable: { - description: "服务暂不可用", - schema: rejectedFailureSchema, - exitCode: 9, + + outputs: defineCli().outputModes("check", emptyInput, (mode) => ({ + default: "structured", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { kind: "completion" }, + failures: { + unavailable: { + description: "服务暂不可用", + schema: rejectedFailureSchema, + exitCode: 9, + }, + }, + handler: ({ outcome }) => + outcome.failure.unavailable({ service: "billing" }), + }), }, - }, - handler: ({ outcome }) => - outcome.failure.unavailable({ service: "billing" }), + })), }, }, }); @@ -239,8 +285,8 @@ void test("定义期聚合失败变体的无效描述与退出码", () => { () => defineCli()({ root: "check", - help: helpCapability(), - output: outputCapability({ defaultFormat: "structured" }), + help: englishHelpCapability(), + usageFailureExitCode: 64, commands: { check: { @@ -248,15 +294,25 @@ void test("定义期聚合失败变体的无效描述与退出码", () => { name: "check", description: "检查服务", input: emptyInput, - success: { kind: "completion" }, - failures: { - unavailable: { - description: "", - schema: unavailableData, - exitCode: 0, + + outputs: defineCli().outputModes("check", emptyInput, (mode) => ({ + default: "structured", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { kind: "completion" }, + failures: { + unavailable: { + description: "", + schema: unavailableData, + exitCode: 0, + }, + }, + handler: ({ outcome }) => outcome.completion(), + }), }, - }, - handler: ({ outcome }) => outcome.completion(), + })), }, }, }), diff --git a/packages/lib/test/basic-argv-grammar.test.ts b/packages/lib/test/basic-argv-grammar.test.ts index 05ff998..27a1739 100644 --- a/packages/lib/test/basic-argv-grammar.test.ts +++ b/packages/lib/test/basic-argv-grammar.test.ts @@ -5,17 +5,23 @@ import { ContractDefinitionError, defineCli, executeCli, - helpCapability, - outputCapability, parseCliInvocation, } from "@ykdz/cli-contract"; import { z } from "zod"; +import { englishHelpCapability } from "./english-help.ts"; + function createCopyCli() { + const sharedInput0 = z.object({ + source: z.string(), + destination: z.string().optional(), + force: z.boolean().optional(), + label: z.string().optional(), + }); return defineCli()({ root: "copyFiles", - help: helpCapability(), - output: outputCapability({ defaultFormat: "structured" }), + help: englishHelpCapability(), + usageFailureExitCode: 64, commands: { copyFiles: { @@ -44,15 +50,20 @@ function createCopyCli() { description: "复制标签", }, }, - input: z.object({ - source: z.string(), - destination: z.string().optional(), - force: z.boolean().optional(), - label: z.string().optional(), - }), - success: { kind: "completion" }, - failures: {}, - handler: ({ outcome }) => outcome.completion(), + input: sharedInput0, + + outputs: defineCli().outputModes("copyFiles", sharedInput0, (mode) => ({ + default: "structured", + modes: { + structured: mode.atomic({ + description: "结构化输出", + format: "structured", + success: { kind: "completion" }, + failures: {}, + handler: ({ outcome }) => outcome.completion(), + }), + }, + })), }, }, }); @@ -79,20 +90,20 @@ void test("基础 argv 语法归一化 positional、flag、option、alias 与 -- force: true, label: "release", }, - outputFormat: "structured", + outputMode: "structured", }, ); assert.deepEqual(parseCliInvocation(cli, ["source.txt", "--label=nightly"]), { kind: "parsed", command: "copyFiles", input: { source: "source.txt", label: "nightly" }, - outputFormat: "structured", + outputMode: "structured", }); assert.deepEqual(parseCliInvocation(cli, ["source.txt", "-f"]), { kind: "parsed", command: "copyFiles", input: { source: "source.txt", force: true }, - outputFormat: "structured", + outputMode: "structured", }); assert.deepEqual(parseCliInvocation(cli, ["source.txt", "--help"]), { kind: "help", @@ -102,7 +113,7 @@ void test("基础 argv 语法归一化 positional、flag、option、alias 与 -- kind: "parsed", command: "copyFiles", input: { source: "source.txt", destination: "--help" }, - outputFormat: "structured", + outputMode: "structured", }); }); @@ -110,7 +121,8 @@ void test("词法问题保留首个可靠 token、字段与 spelling 事实", () const cli = createCopyCli(); const usage = { command: "copyFiles", - synopsis: "copy-files [] [--force] [--label