mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
ci: harden deterministic PR quality gates
Route required checks by changed surface, add offline provider and chat contracts, and keep fork PRs independent of live credentials. Layer agent guidance by subtree and enforce a compact instruction budget. Tested: bun run check:policy Confidence: high Scope-risk: broad
This commit is contained in:
parent
a6964f1932
commit
435e4ccc9f
12
.github/AGENTS.md
vendored
Normal file
12
.github/AGENTS.md
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# CI and Repository Policy
|
||||
|
||||
These rules apply to `.github/` changes in addition to the root instructions.
|
||||
|
||||
- Treat workflow changes as product changes. Run `bun run check:policy`; run `actionlint` when available.
|
||||
- `scripts/pr/change-policy.ts` is the source of truth for path-to-check routing. Do not duplicate the routing graph in advisory automation.
|
||||
- Keep `pr-quality-gate` as the stable required status. Selected jobs must succeed and unselected jobs must be explicitly skipped; never convert failures into success.
|
||||
- Required PR jobs must remain offline and must not receive provider credentials or depend on paid/live services.
|
||||
- A `pull_request_target` workflow may inspect PR metadata using trusted base code, but must never execute the PR head, install PR-controlled dependencies, or expose secrets to contributor code.
|
||||
- Keep Bun aligned with `package.json#packageManager` and use frozen lockfile installs in required jobs.
|
||||
- Do not weaken coverage, test routing, CODEOWNERS, or branch protections to make another change pass. Maintainer override labels require an explicit, documented decision.
|
||||
- Repository settings, rulesets, secrets, and required-check changes require explicit user authorization; editing workflow files alone does not grant it.
|
||||
40
.github/CODEOWNERS
vendored
Normal file
40
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
# Quality policy and required-check topology are maintainer-owned.
|
||||
/.github/workflows/ @NanmiCoder
|
||||
/.github/CODEOWNERS @NanmiCoder
|
||||
/.github/copilot-instructions.md @NanmiCoder
|
||||
/.github/pull_request_template.md @NanmiCoder
|
||||
/AGENTS.md @NanmiCoder
|
||||
**/AGENTS.md @NanmiCoder
|
||||
/CONTRIBUTING.md @NanmiCoder
|
||||
/docs/en/guide/contributing.md @NanmiCoder
|
||||
/docs/guide/contributing.md @NanmiCoder
|
||||
/scripts/pr/ @NanmiCoder
|
||||
/scripts/quality-gate/ @NanmiCoder
|
||||
|
||||
# Cross-process and provider boundaries carry a wider blast radius.
|
||||
/desktop/electron/ @NanmiCoder
|
||||
/desktop/scripts/ @NanmiCoder
|
||||
/desktop/src/api/providers* @NanmiCoder
|
||||
/desktop/src/api/websocket* @NanmiCoder
|
||||
/desktop/src/lib/providerSettingsJson* @NanmiCoder
|
||||
/desktop/src/pages/ActiveSession* @NanmiCoder
|
||||
/desktop/src/pages/EmptySession* @NanmiCoder
|
||||
/desktop/src/stores/chatStore* @NanmiCoder
|
||||
/desktop/src/stores/providerStore* @NanmiCoder
|
||||
/desktop/src/stores/sessionRuntimeStore* @NanmiCoder
|
||||
/desktop/src/lib/persistenceMigrations* @NanmiCoder
|
||||
/src/server/api/providers* @NanmiCoder
|
||||
/src/server/config/provider* @NanmiCoder
|
||||
/src/server/proxy/ @NanmiCoder
|
||||
/src/server/services/conversationService* @NanmiCoder
|
||||
/src/server/services/persistentStorageMigrations* @NanmiCoder
|
||||
/src/server/services/openaiOfficialProvider* @NanmiCoder
|
||||
/src/server/services/provider* @NanmiCoder
|
||||
/src/server/types/provider* @NanmiCoder
|
||||
/src/server/ws/ @NanmiCoder
|
||||
/src/services/api/client* @NanmiCoder
|
||||
/src/services/compact/autoCompact* @NanmiCoder
|
||||
/src/services/openaiAuth/ @NanmiCoder
|
||||
/src/utils/managedEnv* @NanmiCoder
|
||||
/src/utils/model/ @NanmiCoder
|
||||
/src/utils/providerManagedEnvCompat* @NanmiCoder
|
||||
17
.github/copilot-instructions.md
vendored
17
.github/copilot-instructions.md
vendored
@ -1,13 +1,18 @@
|
||||
# AI Coding Instructions
|
||||
|
||||
Follow the repository contract in `AGENTS.md` before editing code.
|
||||
Follow the root `AGENTS.md` and the nearest nested `AGENTS.md` for the files you edit.
|
||||
|
||||
For every feature or bugfix:
|
||||
For every feature, bugfix, refactor, or workflow change:
|
||||
|
||||
- Identify the changed surface before coding: `desktop`, `server`, `adapter`, `native`, `docs`, `provider/runtime`, `agent-loop`, or `release`.
|
||||
- Treat tool access as capability, not authorization. Do not commit, push, open/merge a PR, release, run live providers, or change repository settings unless explicitly requested.
|
||||
- Inspect `git status --short`, identify the changed surface, define the intended behavior and failure signal, and inspect the nearest implementation and tests before editing.
|
||||
- Identify the changed surface before coding: `desktop`, `server/runtime`, `adapter`, `native`, `docs`, `provider/runtime`, `agent-loop`, `persistence`, `policy/ci`, or `release`.
|
||||
- Add same-area tests with the production change. Do not leave production behavior untested unless the PR explicitly carries the maintainer override `allow-missing-tests`.
|
||||
- Preserve or improve the coverage ratchet. New or changed executable production lines must pass the changed-line coverage threshold in `scripts/quality-gate/coverage-thresholds.json`; do not edit coverage baselines or thresholds without maintainer approval via `allow-coverage-baseline-change`.
|
||||
- Use unit tests for pure logic, API/request-shape tests for server/provider/runtime behavior, Testing Library/Vitest for desktop UI and stores, and E2E or agent-browser smoke for user-visible cross-boundary flows.
|
||||
- For agent loop, tool execution, provider routing, model selection, file editing, permissions, session resume, and desktop chat changes, include mock/fixture tests and provide live smoke or baseline evidence when provider access is available.
|
||||
- Before marking work complete, run the narrow relevant check and then `bun run verify`; for high-risk Coding Agent paths, also run `bun run quality:providers` plus the appropriate `quality:smoke`, `quality:baseline`, or `quality:release` command.
|
||||
- In the final handoff or PR description, include changed files, tests added, coverage report path, E2E/live report path or blocker, and known residual risk.
|
||||
- Provider/auth/runtime-env/model-window/proxy changes require offline `bun run check:provider-contract`; desktop chat/WebSocket/session-runtime changes require `bun run check:chat-contract`.
|
||||
- Required PR evidence must be deterministic: use fake credentials, temporary config/home paths, mocked or loopback transports, explicit cleanup, and restored environment state. Never call a real provider or use saved machine credentials in required tests.
|
||||
- For agent loop, tool execution, provider routing, model selection, file editing, permissions, session resume, and desktop chat changes, include mock/fixture/contract tests. Live smoke is trusted-maintainer evidence only and requires explicit authorization; finding local credentials is not authorization.
|
||||
- Run the focused regression first, then `bun run check:impact` and every selected surface/contract check. Run `bun run verify` only before claiming PR-ready/push-ready or when full validation was requested.
|
||||
- Do not present skipped, blocked, not-run, mock, build-only, or stale evidence as passed live/runtime verification.
|
||||
- In the final handoff or PR description, include changed files, tests added, commands actually run with pass/fail counts, checks not run, coverage report path when generated, deterministic E2E evidence, live report path or explicit maintainer-only deferral, and known residual risk.
|
||||
|
||||
10
.github/pull_request_template.md
vendored
10
.github/pull_request_template.md
vendored
@ -17,17 +17,15 @@
|
||||
|
||||
- [ ] I ran the relevant local checks, or explained why they do not apply.
|
||||
- [ ] I added or updated same-area tests for every production behavior change.
|
||||
- [ ] I ran `bun run verify` for code changes, including the coverage gate.
|
||||
- [ ] I ran the checks selected by `bun run check:impact`; if I claim PR-ready/full validation, I also ran `bun run verify`.
|
||||
- [ ] New or changed executable production lines meet the changed-line coverage threshold, or the blocker/maintainer override is documented.
|
||||
- [ ] I attached or summarized the quality report path, JUnit/log artifact path, and pass/fail/skip counts.
|
||||
- [ ] I ran E2E/live smoke for cross-boundary, provider/runtime, desktop chat, agent-loop, native, or release changes, or documented the blocker.
|
||||
- [ ] I ran deterministic E2E/contract checks for cross-boundary changes. Live-model evidence is attached when a trusted maintainer ran it, otherwise it is explicitly marked not run.
|
||||
|
||||
## Risk
|
||||
|
||||
- [ ] This PR does not touch CLI core paths, or it has maintainer approval for `allow-cli-core-change`.
|
||||
- [ ] Production code changes include matching tests, or have maintainer approval for `allow-missing-tests`.
|
||||
- [ ] Coverage baseline/threshold changes have maintainer approval for `allow-coverage-baseline-change`.
|
||||
- [ ] Quarantined tests still have owners, exit criteria, and unexpired review windows.
|
||||
- [ ] Provider/runtime changes were covered by mock contract tests, and live smoke was run or explicitly deferred.
|
||||
|
||||
@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.
|
||||
- [ ] Any quarantine-policy change has maintainer approval; deterministic provider/chat contract tests were not quarantined.
|
||||
- [ ] Provider/runtime changes are covered by offline mock/fixture contract tests; live smoke was run by a trusted maintainer or explicitly deferred.
|
||||
|
||||
268
.github/workflows/pr-quality.yml
vendored
268
.github/workflows/pr-quality.yml
vendored
@ -13,17 +13,22 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
change-policy:
|
||||
name: change-policy
|
||||
scope-plan:
|
||||
name: scope-plan
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
areas: ${{ steps.policy.outputs.areas }}
|
||||
area_labels: ${{ steps.policy.outputs.area_labels }}
|
||||
blocked: ${{ steps.policy.outputs.blocked }}
|
||||
blocking_reasons: ${{ steps.policy.outputs.blocking_reasons }}
|
||||
desktop_checks: ${{ steps.policy.outputs.desktop_checks }}
|
||||
server_checks: ${{ steps.policy.outputs.server_checks }}
|
||||
adapter_checks: ${{ steps.policy.outputs.adapter_checks }}
|
||||
desktop_native_checks: ${{ steps.policy.outputs.desktop_native_checks }}
|
||||
provider_contract_checks: ${{ steps.policy.outputs.provider_contract_checks }}
|
||||
chat_contract_checks: ${{ steps.policy.outputs.chat_contract_checks }}
|
||||
persistence_checks: ${{ steps.policy.outputs.persistence_checks }}
|
||||
policy_checks: ${{ steps.policy.outputs.policy_checks }}
|
||||
docs_checks: ${{ steps.policy.outputs.docs_checks }}
|
||||
coverage_checks: ${{ steps.policy.outputs.coverage_checks }}
|
||||
steps:
|
||||
@ -35,10 +40,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Run policy tests
|
||||
run: bun run check:policy
|
||||
bun-version: 1.3.12
|
||||
|
||||
- name: Collect changed files
|
||||
env:
|
||||
@ -49,86 +51,134 @@ jobs:
|
||||
gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' > changed-files.txt
|
||||
jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH" > labels.txt
|
||||
|
||||
- name: Evaluate change policy
|
||||
- name: Build deterministic scope plan
|
||||
id: policy
|
||||
run: bun run scripts/pr/change-policy.ts --files changed-files.txt --labels-file labels.txt
|
||||
run: bun run scripts/pr/change-policy.ts --files changed-files.txt --labels-file labels.txt --plan-only
|
||||
|
||||
policy-enforcement:
|
||||
name: policy-enforcement
|
||||
needs: scope-plan
|
||||
if: ${{ always() && needs.scope-plan.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout policy sources
|
||||
if: needs.scope-plan.outputs.policy_checks == 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
if: needs.scope-plan.outputs.policy_checks == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.12
|
||||
|
||||
- name: Install root dependencies
|
||||
if: needs.scope-plan.outputs.policy_checks == 'true'
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run policy regression tests
|
||||
if: needs.scope-plan.outputs.policy_checks == 'true'
|
||||
run: bun run check:policy
|
||||
|
||||
- name: Enforce change policy
|
||||
env:
|
||||
BLOCKED: ${{ needs.scope-plan.outputs.blocked }}
|
||||
BLOCKING_REASONS: ${{ needs.scope-plan.outputs.blocking_reasons }}
|
||||
run: |
|
||||
if [ "$BLOCKED" = "true" ]; then
|
||||
echo "::error::${BLOCKING_REASONS:-Change policy blocked this pull request}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Change policy passed"
|
||||
|
||||
desktop-checks:
|
||||
name: desktop-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.desktop_checks == 'true'
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.desktop_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
bun-version: 1.3.12
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run desktop checks
|
||||
run: bun run check:desktop
|
||||
|
||||
server-checks:
|
||||
name: server-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.server_checks == 'true'
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.server_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
bun-version: 1.3.12
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run server checks
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run root runtime checks
|
||||
run: bun run check:server
|
||||
|
||||
provider-contract-checks:
|
||||
name: provider-contract-checks
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.provider_contract_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.12
|
||||
- name: Install root dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run offline provider contracts
|
||||
run: bun run check:provider-contract
|
||||
|
||||
chat-contract-checks:
|
||||
name: chat-contract-checks
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.chat_contract_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.12
|
||||
- name: Install root dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run desktop-server chat contracts
|
||||
run: bun run check:chat-contract
|
||||
|
||||
adapter-checks:
|
||||
name: adapter-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.adapter_checks == 'true'
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.adapter_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
bun-version: 1.3.12
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run adapter checks
|
||||
run: bun run check:adapters
|
||||
|
||||
desktop-native-checks:
|
||||
name: desktop-native-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.desktop_native_checks == 'true'
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.desktop_native_checks == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@ -136,72 +186,73 @@ jobs:
|
||||
build-essential curl wget file \
|
||||
libxdo-dev libssl-dev patchelf \
|
||||
libfuse2
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
bun-version: 1.3.12
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run desktop native checks
|
||||
run: bun run check:native
|
||||
|
||||
docs-checks:
|
||||
name: docs-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.docs_checks == 'true'
|
||||
persistence-checks:
|
||||
name: persistence-checks
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.persistence_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.12
|
||||
- name: Install root dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run persistence upgrade contracts
|
||||
run: bun run check:persistence-upgrade
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
docs-checks:
|
||||
name: docs-checks
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.docs_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Run docs checks
|
||||
run: npm ci && npm run docs:build
|
||||
|
||||
coverage-checks:
|
||||
name: coverage-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.coverage_checks == 'true'
|
||||
needs: scope-plan
|
||||
if: needs.scope-plan.outputs.coverage_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
bun-version: 1.3.12
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run coverage checks
|
||||
env:
|
||||
COVERAGE_BASE_REF: origin/${{ github.base_ref }}
|
||||
run: bun run check:coverage
|
||||
|
||||
- name: Summarize coverage report
|
||||
if: always()
|
||||
run: |
|
||||
@ -209,7 +260,6 @@ jobs:
|
||||
if [ -n "$latest_report" ]; then
|
||||
cat "$latest_report" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
@ -221,17 +271,21 @@ jobs:
|
||||
pr-quality-gate:
|
||||
name: pr-quality-gate
|
||||
needs:
|
||||
- change-policy
|
||||
- scope-plan
|
||||
- policy-enforcement
|
||||
- desktop-checks
|
||||
- server-checks
|
||||
- provider-contract-checks
|
||||
- chat-contract-checks
|
||||
- adapter-checks
|
||||
- desktop-native-checks
|
||||
- persistence-checks
|
||||
- docs-checks
|
||||
- coverage-checks
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require all selected quality jobs to pass
|
||||
- name: Require every selected quality job to pass
|
||||
run: |
|
||||
failures=0
|
||||
|
||||
@ -246,27 +300,31 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
allow_skip_or_success() {
|
||||
require_selected() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "$result" in
|
||||
success|skipped)
|
||||
echo "$name: $result"
|
||||
;;
|
||||
*)
|
||||
echo "::error::$name ended with result: $result"
|
||||
local expected="$2"
|
||||
local result="$3"
|
||||
if [ "$expected" = "true" ]; then
|
||||
require_success "$name" "$result"
|
||||
elif [ "$result" != "skipped" ]; then
|
||||
echo "::error::$name was not selected but ended with result: $result"
|
||||
failures=$((failures + 1))
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "$name: skipped by scope plan"
|
||||
fi
|
||||
}
|
||||
|
||||
require_success "change-policy" "${{ needs.change-policy.result }}"
|
||||
allow_skip_or_success "desktop-checks" "${{ needs.desktop-checks.result }}"
|
||||
allow_skip_or_success "server-checks" "${{ needs.server-checks.result }}"
|
||||
allow_skip_or_success "adapter-checks" "${{ needs.adapter-checks.result }}"
|
||||
allow_skip_or_success "desktop-native-checks" "${{ needs.desktop-native-checks.result }}"
|
||||
allow_skip_or_success "docs-checks" "${{ needs.docs-checks.result }}"
|
||||
allow_skip_or_success "coverage-checks" "${{ needs.coverage-checks.result }}"
|
||||
require_success "scope-plan" "${{ needs.scope-plan.result }}"
|
||||
require_success "policy-enforcement" "${{ needs.policy-enforcement.result }}"
|
||||
require_selected "desktop-checks" "${{ needs.scope-plan.outputs.desktop_checks }}" "${{ needs.desktop-checks.result }}"
|
||||
require_selected "server-checks" "${{ needs.scope-plan.outputs.server_checks }}" "${{ needs.server-checks.result }}"
|
||||
require_selected "provider-contract-checks" "${{ needs.scope-plan.outputs.provider_contract_checks }}" "${{ needs.provider-contract-checks.result }}"
|
||||
require_selected "chat-contract-checks" "${{ needs.scope-plan.outputs.chat_contract_checks }}" "${{ needs.chat-contract-checks.result }}"
|
||||
require_selected "adapter-checks" "${{ needs.scope-plan.outputs.adapter_checks }}" "${{ needs.adapter-checks.result }}"
|
||||
require_selected "desktop-native-checks" "${{ needs.scope-plan.outputs.desktop_native_checks }}" "${{ needs.desktop-native-checks.result }}"
|
||||
require_selected "persistence-checks" "${{ needs.scope-plan.outputs.persistence_checks }}" "${{ needs.persistence-checks.result }}"
|
||||
require_selected "docs-checks" "${{ needs.scope-plan.outputs.docs_checks }}" "${{ needs.docs-checks.result }}"
|
||||
require_selected "coverage-checks" "${{ needs.scope-plan.outputs.coverage_checks }}" "${{ needs.coverage-checks.result }}"
|
||||
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
exit 1
|
||||
|
||||
53
.github/workflows/pr-triage.yml
vendored
53
.github/workflows/pr-triage.yml
vendored
@ -97,6 +97,7 @@ jobs:
|
||||
)
|
||||
const changedProduct = (predicate) => filenames.filter((file) =>
|
||||
predicate(file) &&
|
||||
/\.[cm]?[jt]sx?$/.test(file) &&
|
||||
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
|
||||
!file.includes('/__tests__/') &&
|
||||
!file.includes('/fixtures/')
|
||||
@ -105,11 +106,15 @@ jobs:
|
||||
const serverProduct = changedProduct((file) => file.startsWith('src/server/'))
|
||||
const adapterProduct = changedProduct((file) => file.startsWith('adapters/'))
|
||||
const agentRuntimeProduct = changedProduct((file) =>
|
||||
file.startsWith('src/server/ws/') ||
|
||||
file.startsWith('src/server/services/conversation') ||
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/')
|
||||
)
|
||||
const rootRuntimeProduct = changedProduct((file) =>
|
||||
file.startsWith('src/') &&
|
||||
!file.startsWith('src/server/') &&
|
||||
!file.startsWith('src/tools/') &&
|
||||
!file.startsWith('src/utils/')
|
||||
)
|
||||
const missingTestSignals = []
|
||||
if (desktopProduct.length > 0 && !hasTest('desktop/src/')) {
|
||||
missingTestSignals.push('Desktop product files changed without a desktop test file in the PR.')
|
||||
@ -126,6 +131,15 @@ jobs:
|
||||
)) {
|
||||
missingTestSignals.push('Agent/runtime product files changed without a tools/utils test file in the PR.')
|
||||
}
|
||||
if (rootRuntimeProduct.length > 0 && !filenames.some((file) =>
|
||||
file.startsWith('src/') &&
|
||||
!file.startsWith('src/server/') &&
|
||||
!file.startsWith('src/tools/') &&
|
||||
!file.startsWith('src/utils/') &&
|
||||
(/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
|
||||
)) {
|
||||
missingTestSignals.push('Root runtime product files changed without a matching root runtime test file in the PR.')
|
||||
}
|
||||
if (missingTestSignals.length > 0 && !currentLabels.has('allow-missing-tests')) {
|
||||
areas.add('needs-maintainer-approval')
|
||||
}
|
||||
@ -137,31 +151,6 @@ jobs:
|
||||
areas.add('needs-maintainer-approval')
|
||||
}
|
||||
|
||||
const requiredChecks = ['change-policy']
|
||||
if (areas.has('area:desktop') || areas.has('area:server')) requiredChecks.push('desktop-checks')
|
||||
if (areas.has('area:server') || filenames.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) requiredChecks.push('server-checks')
|
||||
if (areas.has('area:adapters')) requiredChecks.push('adapter-checks')
|
||||
if (
|
||||
filenames.some((file) =>
|
||||
file.startsWith('desktop/') ||
|
||||
file.startsWith('adapters/') ||
|
||||
file.startsWith('src/server/') ||
|
||||
['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/electron/tsconfig.json', 'desktop/scripts/build-linux.sh', 'desktop/scripts/build-macos-arm64.sh', 'desktop/scripts/build-windows-x64.ps1'].includes(file)
|
||||
)
|
||||
) requiredChecks.push('desktop-native-checks')
|
||||
if (areas.has('area:docs')) requiredChecks.push('docs-checks')
|
||||
if (
|
||||
filenames.some((file) =>
|
||||
file.startsWith('desktop/src/') ||
|
||||
file.startsWith('src/server/') ||
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/') ||
|
||||
file.startsWith('adapters/') ||
|
||||
file.startsWith('scripts/quality-gate/') ||
|
||||
['package.json', 'desktop/package.json', 'desktop/bun.lock'].includes(file)
|
||||
)
|
||||
) requiredChecks.push('coverage-checks')
|
||||
|
||||
const testSignals = []
|
||||
testSignals.push(...missingTestSignals.map((signal) => `BLOCKING unless \`allow-missing-tests\` is applied: ${signal}`))
|
||||
if (agentRuntimeProduct.length > 0) {
|
||||
@ -266,8 +255,8 @@ jobs:
|
||||
'**Coverage policy files:**',
|
||||
coveragePolicyFilesText,
|
||||
'',
|
||||
'**Expected checks:**',
|
||||
requiredChecks.map((check) => `- \`${check}\``).join('\n'),
|
||||
'**Required check plan:**',
|
||||
'- See `PR Quality / scope-plan`; it is the single source of truth for selected jobs.',
|
||||
'',
|
||||
'**Test coverage signals:**',
|
||||
testSignals.map((signal) => `- ${signal}`).join('\n'),
|
||||
@ -275,11 +264,7 @@ jobs:
|
||||
'**Risk notes:**',
|
||||
riskNotes.map((note) => `- ${note}`).join('\n'),
|
||||
'',
|
||||
'Hard merge gates still come from GitHub Actions, not AI review.',
|
||||
'',
|
||||
'**Dosu handoff:** Dosu can be used as the AI reviewer for risk explanation, missing-test prompts, and maintainer Q&A. If it does not comment automatically from the PR template, ask:',
|
||||
'',
|
||||
'@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.',
|
||||
'Hard merge gates come from the deterministic GitHub Actions contract lanes above.',
|
||||
].join('\n')
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
|
||||
175
AGENTS.md
175
AGENTS.md
@ -1,144 +1,67 @@
|
||||
# Repository Agent Contract
|
||||
# Repository Instructions
|
||||
|
||||
This file is the operating contract for AI coding agents and human contributors working in this repository. Treat it as executable guidance: inspect the real code, make narrow changes, verify the affected behavior, and leave a handoff that another maintainer can trust.
|
||||
This file is the entry point for coding agents. Keep it short: it should route an agent to the right code, tests, and deeper documentation rather than duplicate them.
|
||||
|
||||
## Agent Operating Rules
|
||||
- Work autonomously on clear, reversible tasks. Do not stop to ask whether to proceed with obvious next steps; ask only for destructive actions, missing authority, or genuinely branching product decisions.
|
||||
- Start every task by identifying the changed surface: `desktop`, `server`, `adapter`, `native`, `docs`, `provider/runtime`, `agent-loop`, or `release`.
|
||||
- Check `git status --short` before editing. The worktree may already contain user changes; never revert, overwrite, restage, or reformat unrelated files.
|
||||
- Keep diffs small and owned. Stage or commit only files you intentionally changed for the current task.
|
||||
- Prefer existing utilities, stores, services, command patterns, and test harnesses over new abstractions. Do not add dependencies unless the task explicitly requires them.
|
||||
- For cleanup/refactor/deslop work, write the cleanup plan first, lock existing behavior with regression tests when it is not already protected, then make one smell-focused pass at a time.
|
||||
- Do not commit generated artifacts: `artifacts/quality-runs/`, `artifacts/coverage/`, `.omx/`, `node_modules/`, `desktop/node_modules/`, `adapters/node_modules/`, `desktop/src-tauri/target/`, or local build outputs.
|
||||
Rules closer to the code take precedence. Before editing `.github/`, `src/`, `desktop/`, `adapters/`, or `docs/`, read the nested `AGENTS.md` in that directory.
|
||||
|
||||
## Engineering Behavior Guardrails
|
||||
These rules are adapted from Karpathy-style coding-agent guidelines. They bias toward caution and simplicity, but do not override the autonomy rule for clear, reversible work.
|
||||
## Start Here
|
||||
|
||||
- Think before coding. State assumptions when they matter, surface tradeoffs once, and do not hide confusion. If the request has multiple materially different interpretations, clarify the branch before editing.
|
||||
- Prefer the simplest working change. Do not add features beyond the request, speculative flexibility, single-use abstractions, or configurability that has no current caller.
|
||||
- Keep changes surgical. Match existing style even when you would design it differently, and do not "improve" adjacent code, comments, formatting, or dead code unless it directly serves the task.
|
||||
- Clean up only your own mess. Remove imports, variables, functions, files, and tests that your change made obsolete; mention unrelated dead code rather than deleting it.
|
||||
- Every changed line should trace back to the user request, a failing test, a verified bug, or a required compatibility constraint.
|
||||
- Define success criteria before implementation. For bugs, first identify or add the test/repro that fails; for refactors, know which checks prove behavior did not change; for features, map each step to a verification command or smoke path.
|
||||
- If an implementation grows much larger than the problem, stop and simplify before continuing. A senior maintainer should be able to explain why the size and shape of the diff are necessary.
|
||||
- Run `git status --short` before editing. Preserve all existing user changes and never revert, restage, reformat, or overwrite unrelated work.
|
||||
- Identify the affected surface and inspect its production path, nearest tests, and existing implementation pattern before proposing a change. Check recent history when regression context matters.
|
||||
- For bugs, reproduce the failure or add a regression test that fails for the intended reason. If reproduction is impossible, state the limitation instead of guessing.
|
||||
- Define the smallest behavior change and the proof that will demonstrate it. Stop and re-scope if the diff crosses an unplanned surface, adds a dependency, or grows beyond the verified seam.
|
||||
- For broad investigation, parallel read-only subagents are encouraged. Give editing agents non-overlapping file ownership; the primary agent owns integration and final verification.
|
||||
- Tool access is capability, not authorization. Do not create/switch branches, commit, push, open or merge a PR, publish a release, change repository settings, or spend live-provider quota unless the user explicitly requests that operation.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This is a Bun-based Coding Agent product with a CLI, local server, desktop app, IM adapters, docs, and release automation.
|
||||
## Repository Map
|
||||
|
||||
- `bin/claude-haha` is the executable entrypoint; `bun run start` and `./bin/claude-haha` run the CLI locally.
|
||||
- `src/` contains the CLI/runtime surface: `entrypoints/` for startup paths, `screens/` and `components/` for the Ink TUI, `commands/` for slash commands, `services/` for API/MCP/OAuth logic, `tools/` for agent tools, `utils/` for shared runtime helpers, and `server/` for the local API/WebSocket service.
|
||||
- `desktop/` contains the desktop product: React UI in `desktop/src/`, API clients in `desktop/src/api/`, shared UI in `desktop/src/components/`, Electron host code in `desktop/electron/`, legacy Tauri resources/sidecar assets in `desktop/src-tauri/`, and desktop build scripts in `desktop/scripts/`.
|
||||
- `adapters/` contains IM adapter sidecars for Telegram, Feishu, WeChat, DingTalk, and shared adapter utilities.
|
||||
- `docs/` and `docs/en/` are VitePress documentation. Root screenshots and `docs/images/` are reference assets unless a task explicitly updates docs media.
|
||||
- `release-notes/`, `scripts/release.ts`, and `.github/workflows/` define release and CI behavior. Treat workflow changes as product changes because they alter what future agents and contributors can safely ship.
|
||||
- `src/`: CLI, Ink UI, commands, services, tools, shared runtime utilities, and the local API/WebSocket server.
|
||||
- `desktop/`: React desktop UI, Electron host, native/sidecar resources, and desktop build scripts.
|
||||
- `adapters/`: Telegram, Feishu, WeChat, DingTalk, and shared IM adapter utilities.
|
||||
- `docs/` and `docs/en/`: VitePress documentation; keep Chinese and English counterparts aligned when both exist.
|
||||
- `.github/workflows/`, `scripts/pr/`, and `scripts/quality-gate/`: CI routing and quality policy.
|
||||
- `release-notes/`, `scripts/release.ts`, and `.github/workflows/release-desktop.yml`: desktop release automation.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Install root dependencies with `bun install`. Install desktop dependencies in `desktop/` when touching desktop UI/native code, and adapter dependencies in `adapters/` when touching IM adapters.
|
||||
## Implementation Rules
|
||||
|
||||
- `./bin/claude-haha` or `bun run start`: run the CLI locally.
|
||||
- `SERVER_PORT=3456 bun run src/server/index.ts`: start the local API/WebSocket server used by `desktop/`.
|
||||
- `cd desktop && bun run dev`: run the desktop frontend in Vite.
|
||||
- `cd desktop && bun run build`: type-check and produce a production web build.
|
||||
- `cd desktop && bun run test`: run desktop Vitest suites.
|
||||
- `cd desktop && bun run lint`: run desktop TypeScript no-emit checks.
|
||||
- `cd adapters && bun run test`: run all adapter tests; use `test:telegram`, `test:feishu`, `test:wechat`, or `test:dingtalk` for focused adapter work.
|
||||
- `bun run docs:dev` / `bun run docs:build`: preview or build the VitePress docs.
|
||||
- `bun run check:impact`: print the changed-area impact report and recommended local checks.
|
||||
- Make narrow, owned diffs. Every changed line must trace to the request, a failing test, or a verified compatibility constraint.
|
||||
- Prefer existing utilities, stores, services, and test harnesses. Do not add dependencies or speculative abstractions unless the task requires them.
|
||||
- Production changes under `src/`, `desktop/src/`, or `adapters/` require a same-area regression test unless a maintainer explicitly approves an exception.
|
||||
- Keep TypeScript ESM style: 2-space indentation, no semicolons, `PascalCase` components, and `camelCase` functions/hooks/stores.
|
||||
- Use structured parsers and existing boundaries instead of ad hoc string manipulation. Add comments only for non-obvious control flow or external constraints.
|
||||
- Do not commit generated output such as `artifacts/`, coverage reports, `node_modules/`, build directories, or Rust `target/` trees.
|
||||
- When publishing is explicitly requested, use Conventional Commit subjects and normal product branch prefixes such as `fix/`, `feat/`, or `docs/`; do not create `codex/` branches in this repository.
|
||||
|
||||
## Verification Routing
|
||||
Use the narrowest meaningful verification while iterating, then run the correct gate for the actual handoff level. Do not run long gates after every small edit. In normal local-development handoff, prefer focused regression tests plus the single affected surface gate. Reserve `bun run verify`, `bun run check:coverage`, and other full quality gates for PR-ready, push/merge, release, explicitly requested full validation, or genuinely high-risk changes.
|
||||
## Verification
|
||||
|
||||
If a user asks for a small fix, local explanation, or quick iteration, do not silently escalate to full PR verification. State the targeted checks you ran and, if relevant, say that full `verify`/coverage was intentionally not run because the change is not being called PR-ready. If a full gate was started and the user asks to stop or says it is too expensive, stop it and continue with scoped verification.
|
||||
1. Run the narrowest relevant test while iterating.
|
||||
2. Run `bun run check:impact`; every command it selects is part of the minimum handoff for the current diff.
|
||||
3. Run `bun run verify` only when full validation is requested or before claiming a code change is PR-ready or push-ready.
|
||||
|
||||
| Situation | Command | Notes |
|
||||
| --- | --- | --- |
|
||||
| Fast inner loop for pure logic | Focused `bun test <file>` or nearest package test | Add/update the regression test first when behavior changes. |
|
||||
| Small scoped bugfix or local dev-flow fix | Focused regression test(s), then the narrowest affected surface gate if needed | Example: a CORS helper plus desktop bootstrap test should run those focused tests; add `check:server`/`check:desktop` only when the touched surface or handoff needs broader confidence. Do not run `verify`/coverage by default. |
|
||||
| Desktop UI/store/API work | `bun run check:desktop` | Runs desktop lint, Vitest, and production build. For visible UI flows, also use browser/agent-browser smoke when unit tests cannot prove the workflow. |
|
||||
| Server/API/provider/runtime/MCP/OAuth/WebSocket work | `bun run check:server` | Covers `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, and API behavior. |
|
||||
| IM adapter work | `bun run check:adapters` | On a fresh checkout, run `cd adapters && bun install` first if dependencies are missing. |
|
||||
| Electron/native/sidecar/packaging/version changes | `bun run check:native` | Runs sidecar build, Electron host checks, Electron `--dir` packaging, and current-platform package-smoke. |
|
||||
| Docs, README, release notes, or docs workflow changes | `bun run check:docs` | This runs `npm ci`; run it sequentially, not in parallel with commands that depend on root `node_modules`. |
|
||||
| Persistence shape changes | `bun run check:persistence-upgrade` | Required for local JSON, `localStorage`, app config migrations, and old-fixture upgrade behavior. |
|
||||
| PR-ready coverage | `bun run check:coverage` or `bun run verify` | Required before calling a change PR-ready, push-ready, mergeable, or release-ready. Not required for ordinary local handoff unless the user asks for PR-level proof or the changed surface is high-risk. |
|
||||
| Optional fast local check | `bun run quality:push` | Path-aware PR mode with coverage skipped by default; run manually when useful, not as a push-time blocker or substitute for PR-ready verification. |
|
||||
| PR-ready / push-ready / full validation handoff for code changes | `bun run verify` | Unified local entrypoint: `bun run verify` is equivalent to `bun run quality:pr`. Run it only when claiming PR-ready, push-ready, mergeable, or when the user explicitly asks for full validation. |
|
||||
| Live agent/provider confidence | `bun run quality:providers`, then `bun run quality:smoke --provider-model <provider:model[:label]>` | Quick live provider/proxy and desktop agent-browser smoke when provider access exists. |
|
||||
| High-risk pre-merge confidence | `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>` | Use for agent-loop, provider routing, model selection, tool execution, session resume, desktop chat, or other core Coding Agent paths. |
|
||||
| Release readiness | `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` | Required before calling a release ready when provider credentials/quota are available. If blocked, report the exact live-provider blocker. |
|
||||
Additional invariants:
|
||||
|
||||
If `bun run verify` is intentionally run and fails, do not stop at reporting the failure. Read the latest quality report, identify the failed lane in the Result Matrix, open the lane log under `artifacts/quality-runs/<timestamp>/logs/<lane>.log`, fix the concrete issue, rerun the narrow check, then rerun `bun run verify` only when the user still wants PR-level validation.
|
||||
- Required PR checks must be deterministic and work on an untrusted fork: no real models, public network, repository secrets, saved providers, or real user home/config. Use fake credentials, fixtures, mocked/loopback transports, temporary directories, and explicit cleanup.
|
||||
- Provider/auth/proxy/runtime changes may select `bun run check:provider-contract`; desktop chat/WebSocket/session changes may select `bun run check:chat-contract`. These contracts are offline and do not replace their selected surface checks.
|
||||
- Any persisted JSON, `localStorage`, or app-config shape change requires a forward migration, an old-fixture regression test, and `bun run check:persistence-upgrade`.
|
||||
- User-visible desktop or cross-process behavior needs an actual browser/desktop smoke path when unit tests cannot prove the workflow.
|
||||
- Live model checks are separate maintainer evidence. Run them only after deterministic checks pass and a maintainer explicitly authorizes quota use; finding credentials on the machine is not authorization.
|
||||
- `bun run check:docs` runs `npm ci`; run it sequentially with checks that rely on root `node_modules`.
|
||||
|
||||
## Feature Quality Contract
|
||||
Every feature, bugfix, and behavior change must ship with proof that matches the changed surface. Treat this as the implementation contract for both human authors and AI coding agents.
|
||||
## User-State Safety
|
||||
|
||||
- Start by naming the behavior surface: `desktop`, `server`, `adapter`, `native`, `docs`, `provider/runtime`, `agent-loop`, or `release`.
|
||||
- Production code changes under `desktop/src`, `src/server`, `src/tools`, `src/utils`, or `adapters` must include a same-area test file in the same PR unless a maintainer explicitly approves `allow-missing-tests`.
|
||||
- Pure logic requires unit tests. Server/API/provider/runtime changes require server or request-shape tests. Desktop UI/store/API changes require Vitest or Testing Library coverage. User-facing desktop flows require browser/agent-browser smoke when the flow cannot be trusted through unit tests alone.
|
||||
- Agent loop, tool execution, provider routing, model selection, file editing, permissions, session resume, and desktop chat changes require mock/fixture tests in PR plus live smoke or baseline evidence from a maintainer machine when provider access exists.
|
||||
- Coverage is part of PR readiness, not an afterthought. Generated/build output is excluded, maintained product areas should move toward 75-80%+, and every changed executable production line must meet the changed-line coverage gate in `scripts/quality-gate/coverage-thresholds.json` before push/PR readiness. For local non-PR handoff, focused regression tests are acceptable; record that coverage was not run instead of running it by default.
|
||||
- Do not lower `scripts/quality-gate/coverage-baseline.json` or `coverage-thresholds.json` unless the PR carries maintainer approval via `allow-coverage-baseline-change` and explains why. Legacy areas below target are debt; new work must leave the touched area higher than it found it.
|
||||
- E2E is required when the feature crosses process boundaries, browser UI, WebSocket/session state, provider proxying, native sidecars, or release packaging. Use the narrowest meaningful E2E lane first, then `quality:baseline` or `quality:release` for core Coding Agent paths.
|
||||
- A PR is not ready until the author records changed files, tests added, coverage report path, E2E/live evidence or explicit blocker, and remaining risk. AI agents must include this evidence before saying "PR-ready", "push-ready", "mergeable", or "release-ready". For ordinary local handoff, include changed files, targeted tests/checks run, tests not run, and remaining risk without escalating to PR-level gates.
|
||||
- Never use or mutate the developer's real `~/.claude`, keychain, tokens, transcripts, providers, or project settings in tests. Redirect every relevant path to a temporary directory.
|
||||
- Treat `~/.claude/settings.json` as user-owned shared state: preserve unknown fields, merge additively, and never add a repository-owned global schema marker.
|
||||
- Repair/Doctor flows are deny-by-default. They may automatically change only explicitly allowlisted, regenerable desktop UI state; protected user data requires a reviewed, backup-first manual flow.
|
||||
|
||||
## Persistent Storage Compatibility
|
||||
- Any change to local JSON, `localStorage`, or app config persistence formats must ship with a forward migration, an old-fixture regression test, and a persistence upgrade gate.
|
||||
- Run `bun run check:persistence-upgrade` for storage-shape changes. The change is blocked until migration tests, old fixtures, backup behavior, and unknown-field preservation pass.
|
||||
- `~/.claude/settings.json` is user-owned shared state: preserve unknown fields on read/write, merge additively, and never write a repo-owned global `schemaVersion` into it.
|
||||
- Desktop Doctor and any automatic repair path must be deny-by-default. One-click repair may only mutate allowlisted, regenerable desktop UI state such as `cc-haha-*` `localStorage` keys or native window state.
|
||||
- Doctor and repair flows must never mutate chat transcripts, model/provider config, Skills, MCP config, plugin state, IM bindings, adapter sessions, OAuth tokens, or team/session records unless a future task explicitly adds a reviewed, backup-first manual repair flow.
|
||||
- Protected files include `~/.claude/projects/**/*.jsonl`, `~/.claude/settings.json`, project `.claude/settings.json`, `~/.claude/cc-haha/providers.json`, `~/.claude/cc-haha/settings.json`, `~/.claude/adapters.json`, `~/.claude/adapter-sessions.json`, `~/.claude/skills`, project `.claude/skills`, `.mcp.json`, managed MCP config, `~/.claude/plugins/**`, `~/.claude/teams/**`, and `~/.claude/cc-haha/*oauth*.json`. Diagnose these paths only with redaction by default.
|
||||
- If a persistence shape cannot be upgraded in place, the implementation is blocked until the upgrade path is explicit and tested.
|
||||
## Handoff
|
||||
|
||||
## Desktop & UX Expectations
|
||||
- Match the existing desktop design system and component patterns before adding new UI primitives.
|
||||
- Use `lucide-react` icons for common actions when an icon exists. Use familiar controls: icon buttons for tool actions, toggles/checkboxes for binary settings, tabs for views, menus for option sets, and sliders/inputs for numeric values.
|
||||
- Keep operational desktop UI dense, readable, and work-focused. Avoid marketing-style hero layouts, decorative cards, gradient-orb backgrounds, and oversized type inside app panels.
|
||||
- Text must fit in its container on mobile and desktop viewports. Stable controls such as tab bars, toolbars, status chips, and buttons should not resize or shift when labels, hover states, or loading text change.
|
||||
- For visible UI changes, validate with an actual browser/desktop smoke path when feasible and include screenshots or a short visual-evidence note in the handoff.
|
||||
- Review `git diff --check`, `git diff`, and `git status --short` before reporting completion.
|
||||
- Report only evidence from the current worktree: changed files, tests added, commands actually run and their observed results, checks not run, blockers, and remaining risk.
|
||||
- `passed`, `failed`, `skipped`, `blocked`, and `not run` are different states. A build is not E2E, a mock is not live-provider evidence, and an older report becomes stale after relevant edits.
|
||||
|
||||
## Release Workflow
|
||||
- Desktop releases are built remotely by GitHub Actions from tags matching `v*.*.*`; do not upload local build artifacts as the release source of truth.
|
||||
- The release workflow `.github/workflows/release-desktop.yml` runs a non-live PR-quality preflight, validates that the tag matches `desktop/package.json`, loads `release-notes/vX.Y.Z.md`, builds sidecars, packages the Electron desktop app across the matrix, uploads updater metadata, and only publishes the GitHub Release after all assets are uploaded.
|
||||
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename, app version in `desktop/package.json`, and tag aligned exactly.
|
||||
- Before cutting a release, commit the product/workflow changes that should ship in the release onto `main`. The release script creates a separate `release: vX.Y.Z` commit; it is not a substitute for committing the bugfix or feature work first.
|
||||
- Release notes must exist before the release script is run, for example `release-notes/v0.4.6.md`. Keep them user-facing and include install/update caveats when the release changes packaging, signing, updater behavior, or platform support.
|
||||
- Required local release runbook for `vX.Y.Z`:
|
||||
1. Confirm the worktree is on `main` and only intended release changes are staged or committed: `git status --short`.
|
||||
2. Create or update `release-notes/vX.Y.Z.md`.
|
||||
3. Run `bun run scripts/release.ts X.Y.Z --dry` and verify the printed tag, version, and notes path.
|
||||
4. Run the relevant local gates before tagging. At minimum for desktop/native/release changes: `bun run check:policy`, `bun run check:desktop`, and `bun run check:native`. Before calling the release ready, run `bun run verify`.
|
||||
5. When live provider access exists, run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>`. If no provider key/quota is available, run the non-live gates anyway and report the live-release blocker explicitly.
|
||||
6. Run `bun run scripts/release.ts X.Y.Z` to update `desktop/package.json`, stage the release notes, create the `release: vX.Y.Z` commit, and create the annotated `vX.Y.Z` tag.
|
||||
7. Push with `git push origin main --tags`.
|
||||
8. Watch the `Release Desktop` workflow for the pushed tag. Do not announce the release until the workflow succeeds and `gh release view vX.Y.Z --json isDraft,url,assets` shows a public release with the expected macOS, Windows, Linux, and updater metadata assets.
|
||||
- Do not manually create or move a release tag unless recovering from a failed release with maintainer intent. The normal path is `bun run scripts/release.ts X.Y.Z`; if a tag must be repaired, document the old tag target, the new target, and why the repair is safe.
|
||||
- Non-draft desktop releases require macOS signing and notarization secrets: `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`. Missing macOS secrets must block publishing, because auto-update and Gatekeeper require a signed, notarized release.
|
||||
- Windows signing secrets, `WINDOWS_CERTIFICATE` and `WINDOWS_CERTIFICATE_PASSWORD`, are recommended but not currently release-blocking. Without them, Windows auto-update can still work, but users may see SmartScreen warnings.
|
||||
- Manual `workflow_dispatch` runs are for draft/testing builds by default. A draft run may be unsigned while signing setup is being tested; it must not be treated as the public update source.
|
||||
- For local macOS test packaging, `desktop/scripts/build-macos-arm64.sh` is the canonical Apple Silicon build entrypoint, with outputs under `desktop/build-artifacts/macos-arm64/`.
|
||||
## Deeper Guides
|
||||
|
||||
## Docs Workflow Notes
|
||||
- The docs workflow `.github/workflows/deploy-docs.yml` uses `npm ci`, not Bun. When root `package.json` dependencies change, keep `package-lock.json` in the same commit or the docs build will fail.
|
||||
- The docs workflow currently runs on Node 22. Avoid reintroducing older Node assumptions without checking dependency engine requirements.
|
||||
- Because `bun run check:docs` can rebuild root `node_modules`, run docs checks sequentially rather than in parallel with `verify`, `quality:pr`, `check:native`, or other commands that rely on the same dependency tree.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Use TypeScript with 2-space indentation, ESM imports, and no semicolons.
|
||||
- Prefer `PascalCase` for React components, `camelCase` for functions/hooks/stores, and descriptive file names such as `teamWatcher.ts` or `AgentTranscript.tsx`.
|
||||
- Keep shared desktop UI in `desktop/src/components/`, desktop API clients in `desktop/src/api/`, server behavior under `src/server/`, and agent/runtime utilities under `src/tools/` or `src/utils/` according to existing boundaries.
|
||||
- For structured data, use structured parsers or existing helpers instead of ad hoc string manipulation.
|
||||
- Add succinct comments only where they clarify non-obvious control flow or external constraints.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `docs:`. Keep the subject imperative and scoped to one change.
|
||||
- Branch names should use normal product prefixes such as `fix/xxx`, `feat/xxx`, or `docs/xxx`; do not create `codex/`-prefixed branches in this repository.
|
||||
- When creating commits, use a Conventional Commit subject plus a useful body when the decision needs context. Prefer git-native trailers for durable decision notes:
|
||||
- `Constraint:` for external constraints.
|
||||
- `Rejected:` for alternatives considered and why they were not used.
|
||||
- `Confidence:` as `low`, `medium`, or `high`.
|
||||
- `Scope-risk:` as `narrow`, `moderate`, or `broad`.
|
||||
- `Directive:` for forward-looking warnings.
|
||||
- `Tested:` and `Not-tested:` for verification evidence and gaps.
|
||||
- PRs should explain user-visible impact, link related issues, list verification steps, include screenshots for desktop/docs UI changes, and call out follow-up work or known gaps.
|
||||
- A PR description must include changed files, tests added or updated, coverage report path, E2E/live evidence or blocker, pass/fail/skip counts from the quality report when available, and remaining risk/rollback notes. A normal local agent handoff may be lighter: summarize changed files, focused tests/checks run, skipped full gates, and remaining risk/rollback notes.
|
||||
- Contributor workflow and quality lanes: `CONTRIBUTING.md` and `docs/guide/contributing.md`
|
||||
- Package scripts and path routing: `package.json` and `scripts/pr/change-policy.ts`
|
||||
- PR evidence contract: `.github/pull_request_template.md`
|
||||
- Desktop release and auto-update runbook: `docs/desktop/10-release-auto-update.md`
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
# 贡献指南
|
||||
|
||||
提交 PR 前需要完成以下三步。
|
||||
提交 PR 前请先确认改动影响面,再运行对应的确定性检查。外部贡献者不需要提供真实模型账号或额度。
|
||||
|
||||
## 1. 运行本地门禁
|
||||
## 1. 按影响面运行本地检查
|
||||
|
||||
```bash
|
||||
bun run check:impact
|
||||
```
|
||||
|
||||
`check:impact` 会列出这次改动选中的检查。先运行对应的窄命令;准备声明 PR-ready、改动风险较高,或维护者需要复现完整 CI 时,再运行统一入口:
|
||||
|
||||
```bash
|
||||
bun run verify
|
||||
```
|
||||
|
||||
`bun run verify` 会一键检查 policy、desktop、server、adapters、native、docs、quarantine 和 coverage。非 0 退出就说明当前分支还不能提交 PR。
|
||||
`bun run verify` 会按改动路径执行被选中的 policy、产品、契约、持久化和 coverage lane,但不会调用真实模型。小范围贡献无需在本机重复所有无关模块;GitHub PR gate 会再次执行并严格核对 selected / skipped 状态。
|
||||
|
||||
`git push` 不再自动运行本地质量门禁。需要质量检查时请手动运行 `bun run quality:push` 或 `bun run verify`;完整覆盖率仍以 `bun run verify` 为准。
|
||||
|
||||
@ -20,6 +26,9 @@ bun run verify
|
||||
| 桌面端 | `bun run check:desktop` |
|
||||
| IM Adapter | `bun run check:adapters` |
|
||||
| 桌面 Electron / 原生打包 | `bun run check:native` |
|
||||
| Provider / runtime / proxy | `bun run check:provider-contract` |
|
||||
| 桌面聊天 / WebSocket / 会话 | `bun run check:chat-contract` |
|
||||
| 持久化格式与迁移 | `bun run check:persistence-upgrade` |
|
||||
| 文档 | `bun run check:docs` |
|
||||
|
||||
门禁失败时,查看最新质量报告和对应 lane 日志定位问题:
|
||||
@ -29,9 +38,9 @@ artifacts/quality-runs/<timestamp>/report.md
|
||||
artifacts/quality-runs/<timestamp>/logs/<lane>.log
|
||||
```
|
||||
|
||||
## 2. 桌面端功能必须手工测试
|
||||
## 2. 用户可见或跨进程桌面改动需要手工测试
|
||||
|
||||
改动涉及 `desktop/` 的 UI、store、API、Electron host 或 native/packaging 层时,除了自动门禁外,还必须在真机上做手工测试:
|
||||
改动涉及用户可见 UI、跨 WebSocket/进程流程、Electron host 或 native/packaging 时,除了自动门禁外,还应在真机上验证相关流程。纯样式、纯工具或已有组件单元测试能够完整证明的改动,不要求重复无关流程:
|
||||
|
||||
- 起本地服务 `SERVER_PORT=3456 bun run src/server/index.ts`
|
||||
- 起桌面端 `cd desktop && bun run dev`
|
||||
@ -43,16 +52,18 @@ artifacts/quality-runs/<timestamp>/logs/<lane>.log
|
||||
每个 PR 的描述里必须包含:
|
||||
|
||||
- **影响范围**:改了哪些模块(desktop / server / adapter / native / docs / provider / agent-loop)
|
||||
- **测试说明**:跑了哪些测试、覆盖率情况、手工测试了哪些流程(桌面端改动必须有真机测试记录)
|
||||
- **测试说明**:跑了哪些测试、覆盖率情况、手工测试了哪些流程(用户可见或跨进程桌面改动需有真机记录)
|
||||
- **剩余风险**:已知未覆盖的边界或需要后续跟进的点
|
||||
|
||||
如果改动了 provider/runtime、agent-loop、文件编辑、权限、session 等核心路径,还需要跑真实模型验证:
|
||||
provider/runtime、agent-loop、文件编辑、权限、session 等核心路径必须先通过离线 mock/fixture/contract 测试。真实模型验证只由拥有可信 secrets 和额度的维护者在高风险合并或发版前运行:
|
||||
|
||||
```bash
|
||||
bun run quality:providers
|
||||
bun run quality:smoke --provider-model <provider:model>
|
||||
```
|
||||
|
||||
来自 fork 的 PR 不会获得仓库 secrets,也不应被要求运行真实模型。贡献者只需在 PR 中明确写 `live model: not run (untrusted fork / no provider)`;这不是确定性 PR gate 的失败理由。
|
||||
|
||||
## 更多
|
||||
|
||||
完整质量门禁和覆盖率说明见 [AGENTS.md](AGENTS.md)。
|
||||
完整质量门禁和覆盖率说明见 [贡献指南](docs/guide/contributing.md);`AGENTS.md` 仅保留 Agent 的高信号入口与路由。
|
||||
|
||||
9
adapters/AGENTS.md
Normal file
9
adapters/AGENTS.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Adapter Instructions
|
||||
|
||||
These rules apply to `adapters/` changes in addition to the root instructions.
|
||||
|
||||
- Reuse shared adapter utilities and keep platform-specific behavior within the relevant Telegram, Feishu, WeChat, or DingTalk package.
|
||||
- Install adapter dependencies in `adapters/` on a fresh checkout; do not change root dependencies for an adapter-only task without a verified need.
|
||||
- Add focused tests for the affected adapter, then follow `bun run check:impact`; adapter changes normally select `bun run check:adapters`.
|
||||
- Required tests must not call real messaging platforms or use saved accounts/tokens. Use fixtures, mocked transports, and temporary session/config paths.
|
||||
- Do not read or mutate the user's real adapter bindings or session files during tests.
|
||||
10
desktop/AGENTS.md
Normal file
10
desktop/AGENTS.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Desktop Instructions
|
||||
|
||||
These rules apply to `desktop/` changes in addition to the root instructions.
|
||||
|
||||
- Reuse the existing desktop design system and component/store/API patterns. Use `lucide-react` for common icons and keep operational UI dense, stable, and readable.
|
||||
- Add focused Vitest or Testing Library coverage for UI, store, or API behavior. Run it first, then follow `bun run check:impact`; desktop product changes normally select `bun run check:desktop`.
|
||||
- Chat transport, WebSocket lifecycle, first-turn runtime selection, reconnect, or session changes also require the offline `bun run check:chat-contract` when selected.
|
||||
- Electron host, sidecar, packaging, or version changes require `bun run check:native` when selected.
|
||||
- Validate user-visible flows in a real browser/desktop session when unit tests cannot prove layout or cross-process behavior, and record the path exercised.
|
||||
- `localStorage` or native settings shape changes require a migration, an old fixture, and `bun run check:persistence-upgrade`.
|
||||
9
docs/AGENTS.md
Normal file
9
docs/AGENTS.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Documentation Instructions
|
||||
|
||||
These rules apply to `docs/` changes in addition to the root instructions.
|
||||
|
||||
- Keep Chinese pages and their `docs/en/` counterparts aligned when both versions exist.
|
||||
- Preserve VitePress structure and existing terminology; do not replace reference screenshots or media unless the task requires it.
|
||||
- Run `bun run check:docs` when selected by `bun run check:impact`.
|
||||
- `check:docs` runs `npm ci` and may rebuild root `node_modules`; run it sequentially with other checks that use the root dependency tree.
|
||||
- Release instructions must stay consistent with `scripts/release.ts`, `.github/workflows/release-desktop.yml`, and the versioned release-notes convention.
|
||||
@ -26,15 +26,21 @@ bun install
|
||||
|
||||
Do not commit local artifacts such as `artifacts/quality-runs/`, `node_modules/`, or `desktop/node_modules/`.
|
||||
|
||||
## Required PR Gate
|
||||
## Path-Aware PR Checks
|
||||
|
||||
Before opening a normal PR, contributors and AI coding agents should run the single entrypoint:
|
||||
First ask the repository which deterministic checks match the changed paths:
|
||||
|
||||
```bash
|
||||
bun run check:impact
|
||||
```
|
||||
|
||||
Run the selected focused commands while developing. Before claiming PR-ready, for a high-risk change, or when reproducing the full hosted CI locally, use the unified entrypoint:
|
||||
|
||||
```bash
|
||||
bun run verify
|
||||
```
|
||||
|
||||
`bun run verify` is the one-command entrypoint and is equivalent to `bun run quality:pr`. This gate does not call real models, so every contributor can run it locally. It starts with an impact report, then actually runs the selected local gates for the changed paths: policy, desktop, server, adapters, native, docs, quarantine, and coverage. A non-zero exit means the branch is not ready for PR or push.
|
||||
`bun run verify` is equivalent to `bun run quality:pr`. It runs the selected policy, desktop, server, adapter, native, provider contract, chat contract, persistence, docs, and coverage lanes, without calling real models. Small external contributions do not need to run unrelated modules locally; GitHub CI runs the exact path-aware gate again.
|
||||
|
||||
The main quality report embeds the current test scope, result matrix, coverage summary, and links to the full coverage/JUnit/log artifacts:
|
||||
|
||||
@ -49,14 +55,15 @@ artifacts/coverage/<timestamp>/coverage-report.json
|
||||
|
||||
Include the commands you ran and the report summary in your PR description. `quality:pr` / `quality:verify` remain available for contributors who prefer explicit quality command names, but docs and AI prompts should prefer `bun run verify`.
|
||||
|
||||
The coverage gate does four things: measures source-only coverage, enforces the baseline ratchet, reports target gaps against 75-80%+ maintained-area goals, and enforces changed-line coverage for new or modified executable production lines. The current baseline lives in `scripts/quality-gate/coverage-baseline.json`, and CI compares against the base branch baseline when available. New PRs must not lower coverage beyond the allowed window. Changes to `coverage-baseline.json` or `coverage-thresholds.json` require the maintainer-only `allow-coverage-baseline-change` label. Quarantine entries must keep an owner, reviewAfter date, and exit criteria; once reviewAfter expires, the default server and coverage gates fail until maintainers review the entry.
|
||||
The coverage gate does four things: measures source-only coverage, enforces the baseline ratchet, reports target gaps against 75-80%+ maintained-area goals, and enforces changed-line coverage for new or modified executable production lines. The current baseline lives in `scripts/quality-gate/coverage-baseline.json`, and CI compares against the base branch baseline when available. New PRs must not lower coverage beyond the allowed window. Changes to `coverage-baseline.json` or `coverage-thresholds.json` require the maintainer-only `allow-coverage-baseline-change` label. Quarantine is reserved for maintainer baseline/release tracking and must never hide deterministic provider/chat contract tests; the normal PR gate does not depend on quarantine to pass.
|
||||
|
||||
## AI Coding Agent Fix Loop
|
||||
|
||||
When asking an AI coding agent to work in this repo, use this as the acceptance instruction:
|
||||
|
||||
```text
|
||||
Run `bun run verify`. If it fails, read the latest
|
||||
Run `bun run check:impact`, then run the selected focused checks. If the task
|
||||
requires PR-ready/full validation, run `bun run verify`. If it fails, read the latest
|
||||
`artifacts/quality-runs/<timestamp>/report.md` and the relevant lane log,
|
||||
fix the missing tests, coverage failures, type/lint/build errors, or docs/native
|
||||
failures, then rerun `bun run verify` until it passes. Do not lower coverage
|
||||
@ -69,7 +76,7 @@ Agents should handle failures in this order:
|
||||
2. If `Path-aware PR checks` failed, check for missing same-area tests, CLI core changes, or coverage policy changes. Do not bypass normal feature PRs with maintainer overrides.
|
||||
3. If `Coverage gate` failed, open `artifacts/coverage/<timestamp>/coverage-report.md` or `coverage-report.json`, then fix `changedLines.failures` and `failures` first. `targetGaps` are technical-debt signals; touched areas should still improve.
|
||||
4. If desktop/server/adapters/native/docs failed, read `artifacts/quality-runs/<timestamp>/logs/<lane>.log`, add tests or fix the build, then rerun the narrow command.
|
||||
5. After narrow checks pass, run `bun run verify` again. The agent may only claim ready when the final Summary has `failed=0`.
|
||||
5. After narrow checks pass, run `bun run verify` when claiming PR-ready/full validation. The agent may only make that claim when the final Summary has `failed=0`.
|
||||
|
||||
External reference points:
|
||||
|
||||
@ -105,7 +112,7 @@ You can still install the local pre-push hook, but it only prints a non-blocking
|
||||
bun run hooks:install
|
||||
```
|
||||
|
||||
Maintainers or contributors with model quota can run real provider smoke and desktop agent-browser smoke manually:
|
||||
Maintainers with a trusted repository environment and model quota can run real provider smoke and desktop agent-browser smoke manually:
|
||||
|
||||
```bash
|
||||
bun run quality:providers
|
||||
@ -120,9 +127,9 @@ bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:
|
||||
|
||||
## PR CI Merge Gate
|
||||
|
||||
`.github/workflows/pr-quality.yml` runs for PR `opened`, `synchronize`, `reopened`, `ready_for_review`, `labeled`, and `unlabeled` events. It starts with `change-policy`, which maps changed files to the desktop, server, adapter, native, docs, and coverage lanes. The final `pr-quality-gate` job aggregates every selected job: failed selected jobs fail `pr-quality-gate`, while unselected jobs may be skipped.
|
||||
`.github/workflows/pr-quality.yml` runs for PR `opened`, `synchronize`, `reopened`, `ready_for_review`, `labeled`, and `unlabeled` events. `scope-plan` installs no dependencies and only produces the stable impact plan. `policy-enforcement` installs the frozen dependency graph independently and runs policy, so a policy failure cannot swallow product-test results. Product jobs depend only on `scope-plan` and select desktop, server, adapter, native, provider contract, chat contract, persistence, docs, and coverage lanes by path. The final `pr-quality-gate` validates every result strictly: selected jobs must succeed, unselected jobs must be skipped, and cancelled or missing results cannot be mistaken for success.
|
||||
|
||||
Repository settings should protect `main` with GitHub branch protection / rulesets and require the `pr-quality-gate` status check. The local hook blocks low-quality pushes; the PR gate blocks low-quality merges.
|
||||
Repository settings should protect `main` with GitHub branch protection / rulesets and require the `pr-quality-gate` status check. CODEOWNERS requires maintainer review for workflows, quality policy, and high-risk provider/WebSocket boundaries. The local hook only reminds; the PR gate is what blocks low-quality merges.
|
||||
|
||||
## Area-Specific Checks
|
||||
|
||||
@ -133,12 +140,15 @@ bun run check:server # Server API, WebSocket, providers, sessions, and rela
|
||||
bun run check:desktop # Desktop lint, Vitest, and production build
|
||||
bun run check:adapters # IM adapter tests
|
||||
bun run check:native # Desktop sidecars, Electron host, and package-smoke checks
|
||||
bun run check:provider-contract # Offline provider/runtime/proxy contract tests
|
||||
bun run check:chat-contract # WebSocket, session, and desktop chat-store contracts
|
||||
bun run check:persistence-upgrade # Persistence migrations and old-fixture compatibility
|
||||
bun run check:docs # Docs build, using npm ci + docs:build
|
||||
bun run check:quarantine # Quarantine owners, exit criteria, and review windows
|
||||
bun run check:quarantine # Maintainer baseline/release quarantine audit
|
||||
bun run check:coverage # Root, desktop, and adapter coverage reports plus ratchet enforcement
|
||||
```
|
||||
|
||||
Focused tests are fine while developing, but run `bun run verify` before sending the PR.
|
||||
Focused tests are the normal development loop. Run `bun run verify` locally when claiming PR-ready/full validation; hosted CI still executes every selected required lane.
|
||||
|
||||
Production code changes must include matching tests. Changes under `desktop/src/**`, `src/server/**`, `src/tools/**`, `src/utils/**`, or `adapters/**` without a same-area test file are blocked unless a maintainer applies `allow-missing-tests`. Coverage baseline/threshold changes are also blocked unless a maintainer applies `allow-coverage-baseline-change`.
|
||||
|
||||
@ -204,7 +214,7 @@ bun run quality:gate --mode baseline --allow-live
|
||||
|
||||
## When To Run The Baseline
|
||||
|
||||
Run the live baseline for changes touching:
|
||||
After deterministic contract/E2E checks pass, a trusted maintainer should run the live baseline for changes touching:
|
||||
|
||||
- Desktop chat, session resume, WebSocket, or the CLI bridge
|
||||
- Provider, model, or runtime selection
|
||||
@ -212,7 +222,7 @@ Run the live baseline for changes touching:
|
||||
- agent-browser smoke, Computer Use, Skills, or MCP
|
||||
- Release preparation or broad cross-module refactors
|
||||
|
||||
If you do not have model access, still run `bun run verify` and state in the PR why the live baseline was not run.
|
||||
External PRs from forks do not receive repository secrets, and contributors are not expected to pay for model calls. Record `live model: not run (untrusted fork / no provider)` in the PR. A maintainer should add live evidence before merging or releasing high-risk changes; missing live evidence must not make deterministic PR lanes flaky.
|
||||
|
||||
## Release Gate
|
||||
|
||||
@ -232,22 +242,22 @@ In release mode, live lanes are not allowed to be silently skipped. Missing prov
|
||||
2. Install dependencies and make the change.
|
||||
3. Add tests for behavior changes.
|
||||
4. Run focused checks for the affected area.
|
||||
5. Optional but recommended: run `bun run hooks:install` so later pushes are blocked by failing gates.
|
||||
6. Run `bun run verify`.
|
||||
7. Run the live baseline for high-risk changes.
|
||||
5. Optional: run `bun run hooks:install` to show a non-blocking reminder before later pushes.
|
||||
6. Run `bun run verify` if you are claiming PR-ready/full validation.
|
||||
7. A trusted maintainer runs the live baseline for high-risk changes; external contributors only record why it was not run.
|
||||
8. In the PR description, include user impact, verification commands, coverage/quality report summary, and known risks.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Can I run checks without a provider?
|
||||
|
||||
Yes. Run the normal PR gate:
|
||||
Yes. Run the impact report and its selected deterministic checks:
|
||||
|
||||
```bash
|
||||
bun run verify
|
||||
bun run check:impact
|
||||
```
|
||||
|
||||
Only the live baseline needs a real model. Add your provider in Desktop Settings > Providers, then run:
|
||||
`bun run verify` also needs no real model. Only the live baseline does. Maintainers can add a provider in Desktop Settings > Providers, then run:
|
||||
|
||||
```bash
|
||||
bun run quality:providers
|
||||
|
||||
@ -26,15 +26,21 @@ bun install
|
||||
|
||||
不要提交本地运行产物,例如 `artifacts/quality-runs/`、`node_modules/`、`desktop/node_modules/`。
|
||||
|
||||
## 普通 PR 必跑门禁
|
||||
## 普通 PR 的影响面检查
|
||||
|
||||
普通贡献者和 AI Coding Agent 在提交 PR 前统一运行:
|
||||
先让仓库按变更路径列出需要运行的检查:
|
||||
|
||||
```bash
|
||||
bun run check:impact
|
||||
```
|
||||
|
||||
开发时运行 impact report 选中的窄命令即可。准备声明 PR-ready、改动风险较高,或需要完整复现托管 CI 时,再运行统一入口:
|
||||
|
||||
```bash
|
||||
bun run verify
|
||||
```
|
||||
|
||||
`bun run verify` 是一键入口,等价于 `bun run quality:pr`。这个门禁不调用真实大模型,适合所有人本地运行。它会先生成 impact report,再按改动范围真实执行被选中的本地门禁:policy、desktop、server、adapters、native、docs、quarantine 和覆盖率。命令失败就说明当前分支还不能提交 PR 或 push。
|
||||
`bun run verify` 等价于 `bun run quality:pr`,会按改动范围执行被选中的 policy、desktop、server、adapter、native、provider contract、chat contract、persistence、docs 和 coverage lane。它不调用真实大模型。小范围外部贡献者不需要在本机运行无关模块;GitHub CI 会再次执行精确的 path-aware gate。
|
||||
|
||||
主质量报告会内嵌当前测试范围、结果矩阵、覆盖率摘要,并链接完整 coverage/JUnit/log artifact:
|
||||
|
||||
@ -49,14 +55,15 @@ artifacts/coverage/<timestamp>/coverage-report.json
|
||||
|
||||
PR 描述里请贴出你实际运行的命令和 summary。`quality:pr` / `quality:verify` 仍然保留给习惯显式质量命名的用户,但推荐文档和 AI prompt 都使用 `bun run verify`。
|
||||
|
||||
覆盖率门禁同时执行四件事:按源码口径统计覆盖率、执行 baseline ratchet、报告 75-80%+ 的目标差距,并对新增/变更的可执行生产代码行执行 changed-line coverage。当前 baseline 记录在 `scripts/quality-gate/coverage-baseline.json`,CI 会优先对比 base branch 的 baseline,新增 PR 不允许覆盖率下降超过允许窗口。`coverage-baseline.json` 或 `coverage-thresholds.json` 变更必须由维护者加 `allow-coverage-baseline-change` 后才能合并。Quarantine 条目必须有 owner、reviewAfter 和 exitCriteria;reviewAfter 过期后默认 server/coverage gate 会阻断,直到维护者处理。
|
||||
覆盖率门禁同时执行四件事:按源码口径统计覆盖率、执行 baseline ratchet、报告 75-80%+ 的目标差距,并对新增/变更的可执行生产代码行执行 changed-line coverage。当前 baseline 记录在 `scripts/quality-gate/coverage-baseline.json`,CI 会优先对比 base branch 的 baseline,新增 PR 不允许覆盖率下降超过允许窗口。`coverage-baseline.json` 或 `coverage-thresholds.json` 变更必须由维护者加 `allow-coverage-baseline-change` 后才能合并。Quarantine 只用于维护者的 baseline/release 追踪,不得隐藏确定性的 provider/chat 契约测试;当前普通 PR gate 不依赖 quarantine 才能通过。
|
||||
|
||||
## AI Coding Agent 修复循环
|
||||
|
||||
给 AI 写代码时,可以直接把这段作为验收指令:
|
||||
|
||||
```text
|
||||
Run `bun run verify`. If it fails, read the latest
|
||||
Run `bun run check:impact`, then run the selected focused checks. If the task
|
||||
requires PR-ready/full validation, run `bun run verify`. If it fails, read the latest
|
||||
`artifacts/quality-runs/<timestamp>/report.md` and the relevant lane log,
|
||||
fix the missing tests, coverage failures, type/lint/build errors, or docs/native
|
||||
failures, then rerun `bun run verify` until it passes. Do not lower coverage
|
||||
@ -69,7 +76,7 @@ Agent 应按这个顺序处理失败:
|
||||
2. 如果是 `Path-aware PR checks` 失败,优先看是否缺同区域测试、是否动了 CLI core、是否动了 coverage policy;不要用 override 绕过普通功能 PR。
|
||||
3. 如果是 `Coverage gate` 失败,打开 `artifacts/coverage/<timestamp>/coverage-report.md` 或 `coverage-report.json`,优先修 `changedLines.failures` 和 `failures`;`targetGaps` 是技术债提示,新改动应让触达区域变好。
|
||||
4. 如果是 desktop/server/adapters/native/docs 失败,读对应 `artifacts/quality-runs/<timestamp>/logs/<lane>.log`,补测试或修构建,再跑相关窄命令。
|
||||
5. 窄命令通过后,最后必须再跑一次 `bun run verify`。只有最终 Summary 是 `failed=0`,才可以说 ready。
|
||||
5. 窄命令通过后,如果要声明 PR-ready/full validation,再跑一次 `bun run verify`。只有最终 Summary 是 `failed=0`,才可以这样声明。
|
||||
|
||||
外部参考口径:
|
||||
|
||||
@ -105,7 +112,7 @@ bun run quality:push
|
||||
bun run hooks:install
|
||||
```
|
||||
|
||||
维护者或有模型额度的贡献者可以手动运行真实 provider smoke 和桌面 agent-browser smoke:
|
||||
拥有可信仓库环境和模型额度的维护者可以手动运行真实 provider smoke 和桌面 agent-browser smoke:
|
||||
|
||||
```bash
|
||||
bun run quality:providers
|
||||
@ -120,9 +127,9 @@ bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:
|
||||
|
||||
## PR CI 合并门禁
|
||||
|
||||
`.github/workflows/pr-quality.yml` 会在 PR `opened`、`synchronize`、`reopened`、`ready_for_review`、`labeled`、`unlabeled` 时触发。流程先运行 `change-policy`,根据变更文件决定是否需要 desktop、server、adapter、native、docs 和 coverage lane。最后的 `pr-quality-gate` 会汇总所有被选中的 job:选中 job 失败则 `pr-quality-gate` 失败,未选中的 job 可以是 skipped。
|
||||
`.github/workflows/pr-quality.yml` 会在 PR `opened`、`synchronize`、`reopened`、`ready_for_review`、`labeled`、`unlabeled` 时触发。`scope-plan` 不安装依赖,只负责稳定地产生影响面计划;`policy-enforcement` 独立安装锁定依赖并执行 policy,因此 policy 失败也不会吞掉产品测试结果。产品 job 只依赖 `scope-plan`,按路径选择 desktop、server、adapter、native、provider contract、chat contract、persistence、docs 和 coverage lane。最后的 `pr-quality-gate` 会严格核对每个 job:选中的必须 success,未选中的必须 skipped,cancelled 或缺失结果都不能误判为通过。
|
||||
|
||||
仓库侧应在 GitHub branch protection / ruleset 中保护 `main`,并把 `pr-quality-gate` 设为 required status check。这样本机 hook 防止低质量 push,PR 侧防止低质量 merge。
|
||||
仓库侧应在 GitHub branch protection / ruleset 中保护 `main`,并把 `pr-quality-gate` 设为 required status check。CODEOWNERS 要求维护者审查 workflow、quality policy 以及 provider/WebSocket 等高风险边界;本机 hook 只做提醒,真正阻止低质量 merge 的是 PR gate。
|
||||
|
||||
## 按改动范围补充测试
|
||||
|
||||
@ -133,12 +140,15 @@ bun run check:server # 服务端 API、WebSocket、provider、会话等测
|
||||
bun run check:desktop # 桌面端 lint、Vitest、生产构建
|
||||
bun run check:adapters # IM adapter 测试
|
||||
bun run check:native # 桌面 sidecar、Electron host 与 package-smoke 检查
|
||||
bun run check:provider-contract # Provider/runtime/proxy 的离线契约测试
|
||||
bun run check:chat-contract # WebSocket、会话与桌面 chat store 契约测试
|
||||
bun run check:persistence-upgrade # 持久化迁移和旧 fixture 兼容性
|
||||
bun run check:docs # 文档构建,使用 npm ci + docs:build
|
||||
bun run check:quarantine # quarantine owner、退出条件和复审日期
|
||||
bun run check:quarantine # 维护者 baseline/release quarantine 审计
|
||||
bun run check:coverage # root、desktop、adapters 覆盖率报告和 ratchet 门禁
|
||||
```
|
||||
|
||||
如果只改了很窄的文件,也可以先跑对应的定向测试,但 PR 前仍应跑 `bun run verify`。
|
||||
如果只改了很窄的文件,先跑对应的定向测试即可;只有在声明 PR-ready/full validation 时才需要本地再跑 `bun run verify`,托管 CI 仍会执行所有被选中的必需 lane。
|
||||
|
||||
生产代码改动必须带对应测试文件:`desktop/src/**`、`src/server/**`、`src/tools/**`、`src/utils/**`、`adapters/**` 变更如果没有同区域测试,会触发阻断。只有维护者确认不适合自动化测试时,才能使用 `allow-missing-tests`。覆盖率 baseline/threshold 变更同样需要维护者确认并加 `allow-coverage-baseline-change`。
|
||||
|
||||
@ -204,7 +214,7 @@ bun run quality:gate --mode baseline --allow-live
|
||||
|
||||
## 什么时候必须跑 Baseline
|
||||
|
||||
以下改动建议跑 live baseline:
|
||||
以下改动在确定性 contract/E2E 通过后,建议由可信维护者补跑 live baseline:
|
||||
|
||||
- 桌面聊天、会话恢复、WebSocket、CLI bridge
|
||||
- provider/model/runtime 选择
|
||||
@ -212,7 +222,7 @@ bun run quality:gate --mode baseline --allow-live
|
||||
- agent-browser smoke、Computer Use、Skills、MCP
|
||||
- release 前或风险较大的跨模块重构
|
||||
|
||||
如果没有模型额度,至少运行 `bun run verify`,并在 PR 里说明未跑 live baseline 的原因。
|
||||
来自 fork 的外部 PR 不会获得仓库 secrets,也不要求贡献者自费调用模型。请在 PR 里写明 `live model: not run (untrusted fork / no provider)`;高风险变更由维护者在合并或发版前补跑 live baseline。没有 live 证据不应让确定性 PR lane 产生随机失败。
|
||||
|
||||
## Release 门禁
|
||||
|
||||
@ -232,22 +242,22 @@ release 模式下 live lane 不允许静默跳过。缺少 provider、真实模
|
||||
2. 安装依赖并完成改动。
|
||||
3. 为行为变化补测试。
|
||||
4. 运行相关定向测试。
|
||||
5. 可选但推荐:运行 `bun run hooks:install`,让后续 push 自动卡住失败门禁。
|
||||
6. 运行 `bun run verify`。
|
||||
7. 对高风险改动运行 live baseline。
|
||||
5. 可选:运行 `bun run hooks:install`,让后续 push 显示非阻塞提醒。
|
||||
6. 如果要声明 PR-ready/full validation,运行 `bun run verify`。
|
||||
7. 高风险改动由可信维护者运行 live baseline;外部贡献者记录未运行原因即可。
|
||||
8. 在 PR 描述里写清楚用户影响、测试命令、覆盖率/质量报告 summary、已知风险。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 没有 provider 可以跑吗?
|
||||
|
||||
可以跑普通门禁:
|
||||
可以。运行影响面检查和它选中的确定性命令:
|
||||
|
||||
```bash
|
||||
bun run verify
|
||||
bun run check:impact
|
||||
```
|
||||
|
||||
只有 live baseline 需要真实模型。先在桌面端 Settings > Providers 添加自己的 provider,再运行:
|
||||
`bun run verify` 也不需要真实模型;只有 live baseline 需要。维护者可以先在桌面端 Settings > Providers 添加自己的 provider,再运行:
|
||||
|
||||
```bash
|
||||
bun run quality:providers
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"version": "999.0.0-local",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.12",
|
||||
"bin": {
|
||||
"claude-haha": "./bin/claude-haha"
|
||||
},
|
||||
@ -11,8 +12,10 @@
|
||||
"start": "bun run ./bin/claude-haha",
|
||||
"check:pr": "bun run scripts/pr/check-pr.ts",
|
||||
"check:impact": "bun run scripts/pr/impact-report.ts",
|
||||
"check:policy": "bun test scripts/pr/change-policy.test.ts scripts/pr/changed-files.test.ts scripts/pr/pr-triage-workflow.test.ts scripts/pr/pr-quality-workflow.test.ts scripts/pr/release-workflow.test.ts scripts/pr/quality-contract.test.ts scripts/release-update-metadata.test.ts scripts/git-hooks/install.test.ts scripts/quality-gate/quarantine.test.ts scripts/quality-gate/coverage.test.ts scripts/quality-gate/provider-smoke/execute.test.ts scripts/quality-gate/desktop-smoke/execute.test.ts scripts/quality-gate/providerTargets.test.ts scripts/quality-gate/runner.test.ts && bun run check:quarantine",
|
||||
"check:policy": "bun test scripts/pr/change-policy.test.ts scripts/pr/changed-files.test.ts scripts/pr/pr-triage-workflow.test.ts scripts/pr/pr-quality-workflow.test.ts scripts/pr/release-workflow.test.ts scripts/pr/quality-contract.test.ts scripts/release-update-metadata.test.ts scripts/git-hooks/install.test.ts scripts/quality-gate/quarantine.test.ts scripts/quality-gate/coverage.test.ts scripts/quality-gate/provider-smoke/execute.test.ts scripts/quality-gate/desktop-smoke/execute.test.ts scripts/quality-gate/providerTargets.test.ts scripts/quality-gate/runner.test.ts",
|
||||
"check:server": "bun run scripts/pr/run-server-tests.ts",
|
||||
"check:provider-contract": "bun run scripts/pr/run-provider-contract-tests.ts",
|
||||
"check:chat-contract": "bun run scripts/pr/run-chat-contract-tests.ts",
|
||||
"check:desktop": "cd desktop && bun run lint && bun run test -- --run && bun run build",
|
||||
"check:electron": "cd desktop && bun run check:electron",
|
||||
"check:adapters": "cd adapters && bun test",
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { evaluateChangePolicy } from './change-policy'
|
||||
|
||||
describe('evaluateChangePolicy', () => {
|
||||
@ -41,7 +44,7 @@ describe('evaluateChangePolicy', () => {
|
||||
expect(result.checks.desktopNative).toBe(false)
|
||||
})
|
||||
|
||||
test('routes desktop and server changes to desktop and native checks', () => {
|
||||
test('routes desktop and server changes without escalating renderer code to native packaging', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'desktop/src/pages/Settings.tsx',
|
||||
'src/server/ws/handler.ts',
|
||||
@ -50,18 +53,19 @@ describe('evaluateChangePolicy', () => {
|
||||
expect(result.areas).toEqual(['desktop', 'server'])
|
||||
expect(result.checks.desktop).toBe(true)
|
||||
expect(result.checks.server).toBe(true)
|
||||
expect(result.checks.desktopNative).toBe(true)
|
||||
expect(result.checks.desktopNative).toBe(false)
|
||||
expect(result.checks.chatContract).toBe(true)
|
||||
expect(result.checks.coverage).toBe(true)
|
||||
expect(result.missingTestSignals).toContain('Desktop product files changed without a desktop test file in the PR.')
|
||||
expect(result.missingTestSignals).toContain('Server product files changed without a server test file in the PR.')
|
||||
})
|
||||
|
||||
test('routes adapter changes to adapter and native checks', () => {
|
||||
test('keeps adapter changes off the desktop native packaging lane', () => {
|
||||
const result = evaluateChangePolicy(['adapters/telegram/index.ts'])
|
||||
|
||||
expect(result.areas).toEqual(['adapters'])
|
||||
expect(result.checks.adapters).toBe(true)
|
||||
expect(result.checks.desktopNative).toBe(true)
|
||||
expect(result.checks.desktopNative).toBe(false)
|
||||
expect(result.checks.coverage).toBe(true)
|
||||
expect(result.blocked).toBe(true)
|
||||
expect(result.missingTestSignals).toEqual(['Adapter product files changed without an adapter test file in the PR.'])
|
||||
@ -77,6 +81,96 @@ describe('evaluateChangePolicy', () => {
|
||||
expect(result.missingTestSignals).toEqual([])
|
||||
})
|
||||
|
||||
test('routes Electron and packaging changes to the native lane', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'desktop/electron/main.ts',
|
||||
'desktop/electron/main.test.ts',
|
||||
])
|
||||
|
||||
expect(result.checks.desktop).toBe(false)
|
||||
expect(result.checks.desktopNative).toBe(true)
|
||||
})
|
||||
|
||||
test('routes provider runtime changes to the offline provider contract', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'src/server/services/providerRuntimeEnv.ts',
|
||||
'src/server/__tests__/provider-runtime-env.test.ts',
|
||||
])
|
||||
|
||||
expect(result.checks.server).toBe(true)
|
||||
expect(result.checks.providerContract).toBe(true)
|
||||
expect(result.checks.chatContract).toBe(false)
|
||||
})
|
||||
|
||||
test('routes persistence and policy changes to their dedicated checks', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'desktop/src/lib/persistenceMigrations.ts',
|
||||
'desktop/src/lib/persistenceMigrations.test.ts',
|
||||
'.github/workflows/pr-quality.yml',
|
||||
])
|
||||
|
||||
expect(result.checks.desktop).toBe(true)
|
||||
expect(result.checks.persistence).toBe(true)
|
||||
expect(result.checks.policy).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps quality ownership and contributor contracts on the policy lane', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'.github/CODEOWNERS',
|
||||
'.github/copilot-instructions.md',
|
||||
'docs/guide/contributing.md',
|
||||
])
|
||||
|
||||
expect(result.checks.policy).toBe(true)
|
||||
expect(result.checks.docs).toBe(true)
|
||||
})
|
||||
|
||||
test('routes root and nested agent guidance only to the policy lane', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'AGENTS.md',
|
||||
'.github/AGENTS.md',
|
||||
'src/AGENTS.md',
|
||||
'desktop/AGENTS.md',
|
||||
'adapters/AGENTS.md',
|
||||
'docs/AGENTS.md',
|
||||
])
|
||||
|
||||
expect(result.areas).toEqual([])
|
||||
expect(result.checks.policy).toBe(true)
|
||||
expect(result.checks.desktop).toBe(false)
|
||||
expect(result.checks.server).toBe(false)
|
||||
expect(result.checks.adapters).toBe(false)
|
||||
expect(result.checks.docs).toBe(false)
|
||||
expect(result.checks.coverage).toBe(false)
|
||||
})
|
||||
|
||||
test('does not require a test file for non-executable desktop assets', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'desktop/src/styles/chat.css',
|
||||
'desktop/src/assets/logo.svg',
|
||||
])
|
||||
|
||||
expect(result.blocked).toBe(false)
|
||||
expect(result.missingTestSignals).toEqual([])
|
||||
expect(result.checks.desktop).toBe(true)
|
||||
expect(result.checks.coverage).toBe(false)
|
||||
})
|
||||
|
||||
test('covers root runtime code outside src/server with tests and coverage', () => {
|
||||
const missing = evaluateChangePolicy(['src/services/api/client.ts'])
|
||||
expect(missing.checks.server).toBe(true)
|
||||
expect(missing.checks.providerContract).toBe(true)
|
||||
expect(missing.checks.coverage).toBe(true)
|
||||
expect(missing.missingTestSignals).toContain('Root runtime product files changed without a matching root runtime test file in the PR.')
|
||||
|
||||
const covered = evaluateChangePolicy([
|
||||
'src/services/api/client.ts',
|
||||
'src/services/api/client.test.ts',
|
||||
])
|
||||
expect(covered.blocked).toBe(false)
|
||||
expect(covered.missingTestSignals).toEqual([])
|
||||
})
|
||||
|
||||
test('blocks coverage baseline and threshold changes without maintainer override', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'scripts/quality-gate/coverage-baseline.json',
|
||||
@ -113,4 +207,39 @@ describe('evaluateChangePolicy', () => {
|
||||
expect(result.checks.coverage).toBe(true)
|
||||
expect(result.blocked).toBe(false)
|
||||
})
|
||||
|
||||
test('plan-only mode publishes a blocked scope without preventing product jobs', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'change-policy-plan-'))
|
||||
try {
|
||||
const filesPath = join(dir, 'files.txt')
|
||||
const labelsPath = join(dir, 'labels.txt')
|
||||
const outputPath = join(dir, 'github-output.txt')
|
||||
writeFileSync(filesPath, 'desktop/src/pages/Settings.tsx\n')
|
||||
writeFileSync(labelsPath, '')
|
||||
|
||||
const proc = Bun.spawn([
|
||||
'bun',
|
||||
'run',
|
||||
'scripts/pr/change-policy.ts',
|
||||
'--files',
|
||||
filesPath,
|
||||
'--labels-file',
|
||||
labelsPath,
|
||||
'--plan-only',
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, GITHUB_OUTPUT: outputPath },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
expect(await proc.exited).toBe(0)
|
||||
const outputs = readFileSync(outputPath, 'utf8')
|
||||
expect(outputs).toContain('blocked=true')
|
||||
expect(outputs).toContain('desktop_checks=true')
|
||||
expect(outputs).toContain('desktop_native_checks=false')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -26,6 +26,10 @@ export type ChangePolicyResult = {
|
||||
server: boolean
|
||||
adapters: boolean
|
||||
desktopNative: boolean
|
||||
providerContract: boolean
|
||||
chatContract: boolean
|
||||
persistence: boolean
|
||||
policy: boolean
|
||||
docs: boolean
|
||||
coverage: boolean
|
||||
}
|
||||
@ -66,6 +70,76 @@ const desktopNativeExactPaths = new Set([
|
||||
'desktop/scripts/build-linux.sh',
|
||||
])
|
||||
|
||||
const desktopWebExactPaths = new Set([
|
||||
'desktop/bun.lock',
|
||||
'desktop/package.json',
|
||||
'desktop/package-lock.json',
|
||||
'desktop/tsconfig.json',
|
||||
'desktop/tsconfig.app.json',
|
||||
'desktop/tsconfig.node.json',
|
||||
'desktop/vite.config.ts',
|
||||
'desktop/vitest.config.ts',
|
||||
])
|
||||
|
||||
const providerContractPrefixes = [
|
||||
'src/server/__tests__/network-settings',
|
||||
'src/server/__tests__/provider',
|
||||
'src/server/__tests__/providers',
|
||||
'src/server/__tests__/proxy-',
|
||||
'src/server/api/providers',
|
||||
'src/server/config/provider',
|
||||
'src/server/proxy/',
|
||||
'src/server/services/provider',
|
||||
'src/server/types/provider',
|
||||
'src/services/api/client',
|
||||
'src/services/compact/autoCompact',
|
||||
'src/services/openaiAuth/',
|
||||
'src/utils/model/',
|
||||
'src/utils/__tests__/providerManagedEnvCompat',
|
||||
'src/utils/managedEnv',
|
||||
'src/utils/providerManagedEnvCompat',
|
||||
'src/utils/proxy.test',
|
||||
]
|
||||
|
||||
const chatContractPrefixes = [
|
||||
'src/server/__tests__/conversations',
|
||||
'src/server/__tests__/websocket-handler',
|
||||
'src/server/ws/',
|
||||
'src/server/services/conversationService',
|
||||
'desktop/src/api/websocket',
|
||||
'desktop/src/components/chat/ChatInput',
|
||||
'desktop/src/pages/ActiveSession',
|
||||
'desktop/src/pages/EmptySession',
|
||||
'desktop/src/stores/chatStore',
|
||||
'desktop/src/stores/sessionRuntimeStore',
|
||||
'desktop/src/types/chat',
|
||||
]
|
||||
|
||||
const persistencePrefixes = [
|
||||
'src/server/services/persistentStorageMigrations',
|
||||
'src/server/__tests__/persistence-upgrade',
|
||||
'desktop/src/lib/persistenceMigrations',
|
||||
'scripts/quality-gate/persistence-upgrade',
|
||||
]
|
||||
|
||||
const policyPrefixes = [
|
||||
'.github/workflows/',
|
||||
'scripts/git-hooks/',
|
||||
'scripts/pr/',
|
||||
'scripts/quality-gate/',
|
||||
]
|
||||
|
||||
const policyExactPaths = new Set([
|
||||
'.github/CODEOWNERS',
|
||||
'.github/copilot-instructions.md',
|
||||
'.github/pull_request_template.md',
|
||||
'AGENTS.md',
|
||||
'CONTRIBUTING.md',
|
||||
'docs/en/guide/contributing.md',
|
||||
'docs/guide/contributing.md',
|
||||
'package.json',
|
||||
])
|
||||
|
||||
const docsExactPaths = new Set([
|
||||
'README.md',
|
||||
'README.en.md',
|
||||
@ -107,9 +181,17 @@ function isCliCorePath(path: string) {
|
||||
return startsWithAny(path, cliCorePrefixes)
|
||||
}
|
||||
|
||||
function isAgentInstructionPath(path: string) {
|
||||
return /(^|\/)AGENTS(?:\.override)?\.md$/.test(path)
|
||||
}
|
||||
|
||||
function areasForPath(path: string): ChangeArea[] {
|
||||
const areas = new Set<ChangeArea>()
|
||||
|
||||
if (isAgentInstructionPath(path)) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (path.startsWith('desktop/')) {
|
||||
areas.add('desktop')
|
||||
}
|
||||
@ -148,9 +230,14 @@ function hasMatchingTest(files: string[], predicate: (file: string) => boolean)
|
||||
))
|
||||
}
|
||||
|
||||
function isExecutableSourcePath(path: string) {
|
||||
return /\.[cm]?[jt]sx?$/.test(path)
|
||||
}
|
||||
|
||||
function changedProductionFiles(files: string[], predicate: (file: string) => boolean) {
|
||||
return files.filter((file) => (
|
||||
predicate(file) &&
|
||||
isExecutableSourcePath(file) &&
|
||||
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
|
||||
!file.includes('/__tests__/') &&
|
||||
!file.includes('/fixtures/')
|
||||
@ -166,6 +253,12 @@ function missingTestSignals(files: string[]) {
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/')
|
||||
))
|
||||
const rootRuntimeProd = changedProductionFiles(files, (file) => (
|
||||
file.startsWith('src/') &&
|
||||
!file.startsWith('src/server/') &&
|
||||
!file.startsWith('src/tools/') &&
|
||||
!file.startsWith('src/utils/')
|
||||
))
|
||||
|
||||
if (desktopProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('desktop/src/'))) {
|
||||
signals.push('Desktop product files changed without a desktop test file in the PR.')
|
||||
@ -179,6 +272,14 @@ function missingTestSignals(files: string[]) {
|
||||
if (agentRuntimeProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) {
|
||||
signals.push('Agent/runtime product files changed without a tools/utils test file in the PR.')
|
||||
}
|
||||
if (rootRuntimeProd.length > 0 && !hasMatchingTest(files, (file) => (
|
||||
file.startsWith('src/') &&
|
||||
!file.startsWith('src/server/') &&
|
||||
!file.startsWith('src/tools/') &&
|
||||
!file.startsWith('src/utils/')
|
||||
))) {
|
||||
signals.push('Root runtime product files changed without a matching root runtime test file in the PR.')
|
||||
}
|
||||
|
||||
return signals
|
||||
}
|
||||
@ -217,25 +318,38 @@ export function evaluateChangePolicy(
|
||||
}
|
||||
const blocked = blockingReasons.length > 0
|
||||
|
||||
const touchesDesktopWeb = files.some((file) => (
|
||||
file.startsWith('desktop/src/') || desktopWebExactPaths.has(file)
|
||||
))
|
||||
const touchesDesktopNative = files.some((file) => (
|
||||
file.startsWith('desktop/') ||
|
||||
file.startsWith('adapters/') ||
|
||||
file.startsWith('src/server/') ||
|
||||
file.startsWith('desktop/electron/') ||
|
||||
file.startsWith('desktop/scripts/') ||
|
||||
file.startsWith('desktop/src-tauri/') ||
|
||||
desktopNativeExactPaths.has(file)
|
||||
))
|
||||
const touchesProviderContract = files.some((file) => startsWithAny(file, providerContractPrefixes))
|
||||
const touchesChatContract = files.some((file) => startsWithAny(file, chatContractPrefixes))
|
||||
const touchesPersistence = files.some((file) => startsWithAny(file, persistencePrefixes))
|
||||
const touchesPolicy = files.some((file) => (
|
||||
startsWithAny(file, policyPrefixes) ||
|
||||
policyExactPaths.has(file) ||
|
||||
isAgentInstructionPath(file)
|
||||
))
|
||||
|
||||
const touchesDocs = files.some((file) => (
|
||||
!isAgentInstructionPath(file) && (
|
||||
file.startsWith('docs/') ||
|
||||
file.startsWith('release-notes/') ||
|
||||
docsExactPaths.has(file)
|
||||
)
|
||||
))
|
||||
const touchesCoverage = files.some((file) => (
|
||||
(isExecutableSourcePath(file) && (
|
||||
file.startsWith('desktop/src/') ||
|
||||
file.startsWith('src/server/') ||
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/') ||
|
||||
file.startsWith('adapters/') ||
|
||||
file.startsWith('scripts/quality-gate/') ||
|
||||
file.startsWith('src/') ||
|
||||
file.startsWith('adapters/')
|
||||
)) ||
|
||||
file.startsWith('scripts/quality-gate/coverage') ||
|
||||
file === 'package.json' ||
|
||||
file === 'desktop/package.json' ||
|
||||
file === 'desktop/bun.lock'
|
||||
@ -255,10 +369,14 @@ export function evaluateChangePolicy(
|
||||
coveragePolicyFiles,
|
||||
missingTestSignals: missingTests,
|
||||
checks: {
|
||||
desktop: areas.has('desktop') || areas.has('server'),
|
||||
server: areas.has('server') || files.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/')),
|
||||
desktop: touchesDesktopWeb,
|
||||
server: files.some((file) => file.startsWith('src/') && !isAgentInstructionPath(file)),
|
||||
adapters: areas.has('adapters'),
|
||||
desktopNative: touchesDesktopNative,
|
||||
providerContract: touchesProviderContract,
|
||||
chatContract: touchesChatContract,
|
||||
persistence: touchesPersistence,
|
||||
policy: touchesPolicy,
|
||||
docs: touchesDocs,
|
||||
coverage: touchesCoverage,
|
||||
},
|
||||
@ -302,7 +420,7 @@ function formatSummary(result: ChangePolicyResult) {
|
||||
'PR change policy',
|
||||
` Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`,
|
||||
` Labels: ${result.labels.length ? result.labels.join(', ') : 'none'}`,
|
||||
` Checks: desktop=${result.checks.desktop}, server=${result.checks.server}, adapters=${result.checks.adapters}, desktopNative=${result.checks.desktopNative}, docs=${result.checks.docs}, coverage=${result.checks.coverage}`,
|
||||
` Checks: desktop=${result.checks.desktop}, server=${result.checks.server}, adapters=${result.checks.adapters}, desktopNative=${result.checks.desktopNative}, providerContract=${result.checks.providerContract}, chatContract=${result.checks.chatContract}, persistence=${result.checks.persistence}, policy=${result.checks.policy}, docs=${result.checks.docs}, coverage=${result.checks.coverage}`,
|
||||
]
|
||||
|
||||
if (result.cliCoreFiles.length > 0) {
|
||||
@ -346,10 +464,15 @@ function writeGithubOutputs(result: ChangePolicyResult) {
|
||||
areas: result.areas.join(','),
|
||||
area_labels: result.areaLabels.join(','),
|
||||
blocked: String(result.blocked),
|
||||
blocking_reasons: result.blockingReasons.join(' | '),
|
||||
desktop_checks: String(result.checks.desktop),
|
||||
server_checks: String(result.checks.server),
|
||||
adapter_checks: String(result.checks.adapters),
|
||||
desktop_native_checks: String(result.checks.desktopNative),
|
||||
provider_contract_checks: String(result.checks.providerContract),
|
||||
chat_contract_checks: String(result.checks.chatContract),
|
||||
persistence_checks: String(result.checks.persistence),
|
||||
policy_checks: String(result.checks.policy),
|
||||
docs_checks: String(result.checks.docs),
|
||||
coverage_checks: String(result.checks.coverage),
|
||||
}
|
||||
@ -382,7 +505,7 @@ if (import.meta.main) {
|
||||
console.log(formatSummary(result))
|
||||
writeGithubOutputs(result)
|
||||
|
||||
if (result.blocked) {
|
||||
if (result.blocked && !args.has('--plan-only')) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +50,9 @@ if (result.blockingReason) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (result.checks.policy) {
|
||||
await run(['bun', 'run', 'check:policy'])
|
||||
}
|
||||
|
||||
if (result.checks.desktop) {
|
||||
await run(['bun', 'run', 'check:desktop'])
|
||||
@ -60,6 +62,14 @@ if (result.checks.server) {
|
||||
await run(['bun', 'run', 'check:server'])
|
||||
}
|
||||
|
||||
if (result.checks.providerContract) {
|
||||
await run(['bun', 'run', 'check:provider-contract'])
|
||||
}
|
||||
|
||||
if (result.checks.chatContract) {
|
||||
await run(['bun', 'run', 'check:chat-contract'])
|
||||
}
|
||||
|
||||
if (result.checks.adapters) {
|
||||
await run(['bun', 'run', 'check:adapters'])
|
||||
}
|
||||
@ -68,6 +78,10 @@ if (result.checks.desktopNative) {
|
||||
await run(['bun', 'run', 'check:native'])
|
||||
}
|
||||
|
||||
if (result.checks.persistence) {
|
||||
await run(['bun', 'run', 'check:persistence-upgrade'])
|
||||
}
|
||||
|
||||
if (result.checks.docs) {
|
||||
await run(['bun', 'run', 'check:docs'])
|
||||
}
|
||||
|
||||
@ -23,7 +23,11 @@ async function changedFiles() {
|
||||
}
|
||||
|
||||
function commandList(result: ReturnType<typeof evaluateChangePolicy>) {
|
||||
const commands = ['bun run check:policy']
|
||||
const commands: string[] = []
|
||||
|
||||
if (result.checks.policy) {
|
||||
commands.push('bun run check:policy')
|
||||
}
|
||||
|
||||
if (result.checks.desktop) {
|
||||
commands.push('bun run check:desktop')
|
||||
@ -31,12 +35,21 @@ function commandList(result: ReturnType<typeof evaluateChangePolicy>) {
|
||||
if (result.checks.server) {
|
||||
commands.push('bun run check:server')
|
||||
}
|
||||
if (result.checks.providerContract) {
|
||||
commands.push('bun run check:provider-contract')
|
||||
}
|
||||
if (result.checks.chatContract) {
|
||||
commands.push('bun run check:chat-contract')
|
||||
}
|
||||
if (result.checks.adapters) {
|
||||
commands.push('bun run check:adapters')
|
||||
}
|
||||
if (result.checks.desktopNative) {
|
||||
commands.push('bun run check:native')
|
||||
}
|
||||
if (result.checks.persistence) {
|
||||
commands.push('bun run check:persistence-upgrade')
|
||||
}
|
||||
if (result.checks.docs) {
|
||||
commands.push('bun run check:docs')
|
||||
}
|
||||
@ -57,6 +70,7 @@ function hasMatchingTest(files: string[], predicate: (file: string) => boolean)
|
||||
function changedProductionFiles(files: string[], predicate: (file: string) => boolean) {
|
||||
return files.filter((file) => (
|
||||
predicate(file) &&
|
||||
/\.[cm]?[jt]sx?$/.test(file) &&
|
||||
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
|
||||
!file.includes('/__tests__/') &&
|
||||
!file.includes('/fixtures/')
|
||||
|
||||
@ -1,16 +1,57 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
type WorkflowJob = {
|
||||
needs?: string | string[]
|
||||
steps?: Array<{ name?: string; run?: string }>
|
||||
}
|
||||
|
||||
function workflowJobs(workflow: string) {
|
||||
return (parse(workflow) as { jobs: Record<string, WorkflowJob> }).jobs
|
||||
}
|
||||
|
||||
describe('PR quality workflow', () => {
|
||||
test('routes policy outputs into conditional check jobs', () => {
|
||||
test('builds scope before routing independent quality jobs', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.desktop_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.server_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.adapter_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.desktop_native_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.docs_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.coverage_checks == 'true'")
|
||||
expect(workflow).toContain('scope-plan:')
|
||||
expect(workflow).toContain('--plan-only')
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.desktop_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.server_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.provider_contract_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.chat_contract_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.persistence_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.adapter_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.desktop_native_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.docs_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.scope-plan.outputs.coverage_checks == 'true'")
|
||||
})
|
||||
|
||||
test('installs frozen dependencies before policy regressions without blocking product routing', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
|
||||
const jobs = workflowJobs(workflow)
|
||||
const policySteps = jobs['policy-enforcement'].steps ?? []
|
||||
const installIndex = policySteps.findIndex((step) => step.name === 'Install root dependencies')
|
||||
const regressionIndex = policySteps.findIndex((step) => step.name === 'Run policy regression tests')
|
||||
|
||||
expect(jobs['policy-enforcement'].needs).toBe('scope-plan')
|
||||
expect(installIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(installIndex).toBeLessThan(regressionIndex)
|
||||
for (const jobId of [
|
||||
'desktop-checks',
|
||||
'server-checks',
|
||||
'provider-contract-checks',
|
||||
'chat-contract-checks',
|
||||
'adapter-checks',
|
||||
'desktop-native-checks',
|
||||
'persistence-checks',
|
||||
'docs-checks',
|
||||
'coverage-checks',
|
||||
]) {
|
||||
expect(jobs[jobId].needs).toBe('scope-plan')
|
||||
}
|
||||
expect(workflow).toContain('bun-version: 1.3.12')
|
||||
})
|
||||
|
||||
test('keeps coverage artifacts observable in CI', () => {
|
||||
@ -23,13 +64,25 @@ describe('PR quality workflow', () => {
|
||||
expect(workflow).toContain('retention-days: 14')
|
||||
})
|
||||
|
||||
test('keeps required PR checks deterministic and secret-free', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
|
||||
|
||||
expect(workflow).not.toContain('--allow-live')
|
||||
expect(workflow).not.toContain('QUALITY_GATE_PROVIDER_API_KEY')
|
||||
expect(workflow).not.toContain('secrets.')
|
||||
expect(workflow).not.toContain('pull_request_target')
|
||||
})
|
||||
|
||||
test('exposes a single required gate job for branch protection', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain('pr-quality-gate:')
|
||||
expect(workflow).toContain('name: pr-quality-gate')
|
||||
expect(workflow).toContain('if: always()')
|
||||
expect(workflow).toContain('require_success "change-policy" "${{ needs.change-policy.result }}"')
|
||||
expect(workflow).toContain('allow_skip_or_success "coverage-checks" "${{ needs.coverage-checks.result }}"')
|
||||
expect(workflow).toContain('require_success "scope-plan" "${{ needs.scope-plan.result }}"')
|
||||
expect(workflow).toContain('require_success "policy-enforcement" "${{ needs.policy-enforcement.result }}"')
|
||||
expect(workflow).toContain('require_selected "provider-contract-checks"')
|
||||
expect(workflow).toContain('require_selected "chat-contract-checks"')
|
||||
expect(workflow).toContain('require_selected "coverage-checks"')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,39 +1,35 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const DOSU_PROMPT = '@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.'
|
||||
|
||||
function extractCommentBodyEntries(workflow: string) {
|
||||
const match = workflow.match(/const body = \[([\s\S]*?)\n\s*\]\.join\('\\n'\)/)
|
||||
if (!match) {
|
||||
throw new Error('Could not find PR triage comment body array')
|
||||
}
|
||||
|
||||
return match[1]
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("'") || line.startsWith('`'))
|
||||
}
|
||||
|
||||
describe('PR triage workflow comment', () => {
|
||||
test('ends with a plain-text Dosu mention so the bot is triggered', () => {
|
||||
test('keeps AI review out of the deterministic quality contract', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-triage.yml', 'utf8')
|
||||
const entries = extractCommentBodyEntries(workflow)
|
||||
const nonEmptyEntries = entries.filter((entry) => !/^['`]['`],?$/.test(entry))
|
||||
const lastEntry = nonEmptyEntries.at(-1)
|
||||
|
||||
expect(lastEntry).toBe(`'${DOSU_PROMPT}',`)
|
||||
expect(workflow).not.toContain(`\`${DOSU_PROMPT}\``)
|
||||
expect(workflow).toContain('Hard merge gates come from the deterministic GitHub Actions contract lanes above.')
|
||||
expect(workflow).not.toContain('@dosubot')
|
||||
})
|
||||
|
||||
test('surfaces missing-test, coverage-check, and coverage-baseline policy branches', () => {
|
||||
test('does not execute untrusted PR code from pull_request_target', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-triage.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain('pull_request_target:')
|
||||
expect(workflow).not.toContain('actions/checkout')
|
||||
expect(workflow).not.toContain('bun install')
|
||||
expect(workflow).not.toContain('pull_request.head.sha')
|
||||
expect(workflow).not.toContain('--allow-live')
|
||||
expect(workflow).not.toContain('secrets.')
|
||||
})
|
||||
|
||||
test('surfaces missing-test and coverage-baseline policy branches', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-triage.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain("'allow-missing-tests': 'c2e0c6'")
|
||||
expect(workflow).toContain("'allow-coverage-baseline-change': 'c2e0c6'")
|
||||
expect(workflow).toContain("requiredChecks.push('coverage-checks')")
|
||||
expect(workflow).toContain('See `PR Quality / scope-plan`; it is the single source of truth for selected jobs.')
|
||||
expect(workflow).not.toContain('requiredChecks.push(')
|
||||
expect(workflow).toContain('Coverage baseline policy')
|
||||
expect(workflow).toContain('coveragePolicyFiles')
|
||||
expect(workflow).toContain('Root runtime product files changed without a matching root runtime test file in the PR.')
|
||||
expect(workflow).toContain('BLOCKING unless \\`allow-missing-tests\\` is applied')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,21 +2,46 @@ import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
describe('feature quality contract', () => {
|
||||
test('keeps the agent-facing implementation contract explicit', () => {
|
||||
test('keeps root agent guidance small, high-signal, and layered', () => {
|
||||
const agents = readFileSync('AGENTS.md', 'utf8')
|
||||
|
||||
expect(agents).toContain('## Feature Quality Contract')
|
||||
expect(agents).toContain('## Persistent Storage Compatibility')
|
||||
expect(agents).toContain('Any change to local JSON, `localStorage`, or app config persistence formats must ship with a forward migration')
|
||||
expect(agents).toContain('`~/.claude/settings.json` is user-owned shared state')
|
||||
expect(agents).toContain('persistence upgrade gate')
|
||||
expect(agents).toContain('Production code changes under `desktop/src`, `src/server`, `src/tools`, `src/utils`, or `adapters` must include a same-area test file')
|
||||
expect(agents).toContain('Coverage is part of PR readiness, not an afterthought.')
|
||||
expect(agents).toContain('changed executable production line must meet the changed-line coverage gate')
|
||||
expect(agents).toContain('E2E is required when the feature crosses process boundaries')
|
||||
expect(agents).toContain('AI agents must include this evidence')
|
||||
expect(agents).toContain('Unified local entrypoint: `bun run verify`')
|
||||
expect(agents).toContain('If `bun run verify` is intentionally run and fails, do not stop at reporting the failure')
|
||||
// Codex has a 32 KiB default budget for the complete instruction chain.
|
||||
// Keep the root well below that limit so nested guidance has room to load.
|
||||
expect(Buffer.byteLength(agents)).toBeLessThan(16 * 1024)
|
||||
expect(agents).toContain('## Start Here')
|
||||
expect(agents).toContain('## Repository Map')
|
||||
expect(agents).toContain('## Verification')
|
||||
expect(agents).toContain('## User-State Safety')
|
||||
expect(agents).toContain('## Handoff')
|
||||
expect(agents).toContain('read the nested `AGENTS.md` in that directory')
|
||||
expect(agents).toContain('Tool access is capability, not authorization.')
|
||||
expect(agents).toContain('same-area regression test')
|
||||
expect(agents).toContain('`bun run check:impact`')
|
||||
expect(agents).toContain('`bun run verify`')
|
||||
expect(agents).toContain('Required PR checks must be deterministic')
|
||||
expect(agents).toContain('finding credentials on the machine is not authorization')
|
||||
expect(agents).toContain('`bun run check:persistence-upgrade`')
|
||||
expect(agents).toContain('`~/.claude/settings.json` as user-owned shared state')
|
||||
expect(agents).toContain('commands actually run and their observed results')
|
||||
})
|
||||
|
||||
test('keeps specialized agent guidance next to the affected code', () => {
|
||||
const policy = readFileSync('.github/AGENTS.md', 'utf8')
|
||||
const runtime = readFileSync('src/AGENTS.md', 'utf8')
|
||||
const desktop = readFileSync('desktop/AGENTS.md', 'utf8')
|
||||
const adapters = readFileSync('adapters/AGENTS.md', 'utf8')
|
||||
const docs = readFileSync('docs/AGENTS.md', 'utf8')
|
||||
|
||||
expect(policy).toContain('`scripts/pr/change-policy.ts` is the source of truth')
|
||||
expect(policy).toContain('`pull_request_target`')
|
||||
expect(policy).toContain('`pr-quality-gate`')
|
||||
expect(runtime).toContain('`bun run check:server`')
|
||||
expect(runtime).toContain('temporary `HOME`/`CLAUDE_CONFIG_DIR`')
|
||||
expect(runtime).toContain('`bun run check:provider-contract`')
|
||||
expect(desktop).toContain('`bun run check:desktop`')
|
||||
expect(desktop).toContain('`bun run check:chat-contract`')
|
||||
expect(adapters).toContain('`bun run check:adapters`')
|
||||
expect(docs).toContain('`bun run check:docs`')
|
||||
})
|
||||
|
||||
test('keeps PR authors accountable for tests, coverage, E2E, and risk', () => {
|
||||
@ -32,6 +57,24 @@ describe('feature quality contract', () => {
|
||||
expect(template).toContain('I added or updated same-area tests')
|
||||
})
|
||||
|
||||
test('keeps quality policy and cross-process boundaries maintainer-owned', () => {
|
||||
const codeowners = readFileSync('.github/CODEOWNERS', 'utf8')
|
||||
|
||||
expect(codeowners).toContain('/.github/workflows/ @NanmiCoder')
|
||||
expect(codeowners).toContain('/AGENTS.md @NanmiCoder')
|
||||
expect(codeowners).toContain('**/AGENTS.md @NanmiCoder')
|
||||
expect(codeowners).toContain('/CONTRIBUTING.md @NanmiCoder')
|
||||
expect(codeowners).toContain('/scripts/pr/ @NanmiCoder')
|
||||
expect(codeowners).toContain('/scripts/quality-gate/ @NanmiCoder')
|
||||
expect(codeowners).toContain('/desktop/src/api/websocket* @NanmiCoder')
|
||||
expect(codeowners).toContain('/desktop/src/lib/persistenceMigrations* @NanmiCoder')
|
||||
expect(codeowners).toContain('/src/server/services/conversationService* @NanmiCoder')
|
||||
expect(codeowners).toContain('/src/server/proxy/ @NanmiCoder')
|
||||
expect(codeowners).toContain('/src/server/ws/ @NanmiCoder')
|
||||
expect(codeowners).toContain('/src/services/openaiAuth/ @NanmiCoder')
|
||||
expect(codeowners).toContain('/src/utils/model/ @NanmiCoder')
|
||||
})
|
||||
|
||||
test('keeps the one-command verification entrypoint documented', () => {
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as {
|
||||
scripts?: Record<string, string>
|
||||
@ -45,6 +88,8 @@ describe('feature quality contract', () => {
|
||||
expect(packageJson.scripts?.['quality:verify']).toBe('bun run quality:pr')
|
||||
expect(packageJson.scripts?.['quality:push']).toBe('bun run quality:gate --mode pr --skip coverage')
|
||||
expect(packageJson.scripts?.['check:persistence-upgrade']).toBe('bun run scripts/quality-gate/persistence-upgrade.ts')
|
||||
expect(packageJson.scripts?.['check:provider-contract']).toBe('bun run scripts/pr/run-provider-contract-tests.ts')
|
||||
expect(packageJson.scripts?.['check:chat-contract']).toBe('bun run scripts/pr/run-chat-contract-tests.ts')
|
||||
expect(packageJson.scripts?.['check:native']).toContain('electron:package:dir')
|
||||
expect(packageJson.scripts?.['check:native']).toContain('test:package-smoke:current')
|
||||
expect(packageJson.scripts?.['test:package-smoke:current']).toBe('bun run scripts/quality-gate/package-smoke/current.ts')
|
||||
@ -82,11 +127,13 @@ describe('feature quality contract', () => {
|
||||
test('keeps general AI coding tools pointed at the same quality bar', () => {
|
||||
const instructions = readFileSync('.github/copilot-instructions.md', 'utf8')
|
||||
|
||||
expect(instructions).toContain('Follow the repository contract in `AGENTS.md`')
|
||||
expect(instructions).toContain('Follow the root `AGENTS.md` and the nearest nested `AGENTS.md`')
|
||||
expect(instructions).toContain('Add same-area tests with the production change')
|
||||
expect(instructions).toContain('Preserve or improve the coverage ratchet')
|
||||
expect(instructions).toContain('changed-line coverage threshold')
|
||||
expect(instructions).toContain('E2E or agent-browser smoke')
|
||||
expect(instructions).toContain('include changed files, tests added, coverage report path')
|
||||
expect(instructions).toContain('Provider/auth/runtime-env/model-window/proxy changes require offline `bun run check:provider-contract`')
|
||||
expect(instructions).toContain('Live smoke is trusted-maintainer evidence only and requires explicit authorization')
|
||||
expect(instructions).toContain('include changed files, tests added, commands actually run with pass/fail counts')
|
||||
})
|
||||
})
|
||||
|
||||
68
scripts/pr/run-chat-contract-tests.ts
Normal file
68
scripts/pr/run-chat-contract-tests.ts
Normal file
@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
interface ContractSuite {
|
||||
name: string
|
||||
cwd: string
|
||||
command: string[]
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dir, '../..')
|
||||
const suites: ContractSuite[] = [
|
||||
{
|
||||
name: 'server mock CLI and WebSocket contracts',
|
||||
cwd: root,
|
||||
command: [
|
||||
'bun',
|
||||
'test',
|
||||
'src/server/__tests__/websocket-handler.test.ts',
|
||||
'src/server/__tests__/conversations.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'desktop transport, store, and first-turn contracts',
|
||||
cwd: resolve(root, 'desktop'),
|
||||
command: [
|
||||
'bun',
|
||||
'run',
|
||||
'test',
|
||||
'--',
|
||||
'--run',
|
||||
'src/api/websocket.test.ts',
|
||||
'src/stores/chatStore.test.ts',
|
||||
'src/pages/EmptySession.test.tsx',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function formatDuration(startedAt: number): string {
|
||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const runStartedAt = performance.now()
|
||||
|
||||
for (const [index, suite] of suites.entries()) {
|
||||
const suiteStartedAt = performance.now()
|
||||
console.log(`\n[chat-contract] ${index + 1}/${suites.length}: ${suite.name}`)
|
||||
console.log(`[chat-contract] cwd: ${suite.cwd}`)
|
||||
console.log(`[chat-contract] $ ${suite.command.join(' ')}`)
|
||||
|
||||
const proc = Bun.spawn(suite.command, {
|
||||
cwd: suite.cwd,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error(
|
||||
`[chat-contract] FAILED: ${suite.name} (exit ${exitCode}, ${formatDuration(suiteStartedAt)})`,
|
||||
)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
console.log(`[chat-contract] PASSED: ${suite.name} (${formatDuration(suiteStartedAt)})`)
|
||||
}
|
||||
|
||||
console.log(`\n[chat-contract] All chat contract suites passed (${formatDuration(runStartedAt)}).`)
|
||||
42
scripts/pr/run-provider-contract-tests.ts
Normal file
42
scripts/pr/run-provider-contract-tests.ts
Normal file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = join(import.meta.dir, '..', '..')
|
||||
// Keep this lane deterministic. Real credentials and third-party connectivity
|
||||
// belong to the trusted provider-smoke lanes, never to external PR checks.
|
||||
const testFiles = [
|
||||
'src/server/__tests__/provider-settings-isolation.test.ts',
|
||||
'src/server/__tests__/provider-presets.test.ts',
|
||||
'src/server/__tests__/provider-runtime-env.test.ts',
|
||||
'src/services/api/client.test.ts',
|
||||
'src/services/compact/autoCompact.test.ts',
|
||||
'src/services/openaiAuth/client.test.ts',
|
||||
'src/services/openaiAuth/fetch.test.ts',
|
||||
'src/services/openaiAuth/models.test.ts',
|
||||
'src/services/openaiAuth/storage.test.ts',
|
||||
'src/utils/__tests__/providerManagedEnvCompat.test.ts',
|
||||
'src/utils/managedEnv.test.ts',
|
||||
'src/utils/model/modelContextWindows.test.ts',
|
||||
'src/server/__tests__/providers.test.ts',
|
||||
'src/server/__tests__/proxy-transform.test.ts',
|
||||
'src/server/__tests__/proxy-streaming.test.ts',
|
||||
'src/server/__tests__/network-settings.test.ts',
|
||||
'src/server/__tests__/proxy-network-settings.test.ts',
|
||||
'src/utils/proxy.test.ts',
|
||||
]
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
console.log(`\n[provider-contract] ${testFile}`)
|
||||
const proc = Bun.spawn(['bun', 'test', testFile], {
|
||||
cwd: root,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n[provider-contract] ${testFiles.length} deterministic suites passed`)
|
||||
@ -5,7 +5,11 @@ import { join, relative, sep } from 'node:path'
|
||||
import { loadQuarantineManifest, quarantinedPathSet } from '../quality-gate/quarantine'
|
||||
|
||||
const root = process.cwd()
|
||||
const roots = ['src/server', 'src/tools', 'src/utils']
|
||||
// The root runtime is wider than src/server: CLI commands, query handling,
|
||||
// shared services, tools, and utils all ship in the same Bun product. Keeping a
|
||||
// single src root prevents approved CLI/core changes from receiving a green
|
||||
// server check without their existing tests ever being discovered.
|
||||
const roots = ['src']
|
||||
const excludedFiles = quarantinedPathSet(loadQuarantineManifest())
|
||||
|
||||
function normalize(path: string) {
|
||||
|
||||
@ -66,6 +66,26 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar
|
||||
requiredForModes: ['pr', 'release'],
|
||||
category: 'unit',
|
||||
},
|
||||
{
|
||||
id: 'provider-contract-checks',
|
||||
title: 'Provider contract checks',
|
||||
description: 'Validate provider persistence, runtime env, proxy request/response transforms, streaming, and network failure semantics without live credentials.',
|
||||
kind: 'command',
|
||||
command: ['bun', 'run', 'check:provider-contract'],
|
||||
impactRequiredCheck: 'bun run check:provider-contract',
|
||||
requiredForModes: ['pr', 'release'],
|
||||
category: 'integration',
|
||||
},
|
||||
{
|
||||
id: 'chat-contract-checks',
|
||||
title: 'Desktop/server chat contract checks',
|
||||
description: 'Exercise WebSocket, first-turn runtime selection, reconnect, and mock CLI integration without a real model.',
|
||||
kind: 'command',
|
||||
command: ['bun', 'run', 'check:chat-contract'],
|
||||
impactRequiredCheck: 'bun run check:chat-contract',
|
||||
requiredForModes: ['pr', 'release'],
|
||||
category: 'integration',
|
||||
},
|
||||
{
|
||||
id: 'adapter-checks',
|
||||
title: 'Adapter checks',
|
||||
@ -102,6 +122,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar
|
||||
description: 'Validate local JSON and desktop localStorage migrations against old-version fixtures.',
|
||||
kind: 'command',
|
||||
command: ['bun', 'run', 'check:persistence-upgrade'],
|
||||
impactRequiredCheck: 'bun run check:persistence-upgrade',
|
||||
requiredForModes: ['pr', 'release'],
|
||||
category: 'governance',
|
||||
},
|
||||
@ -111,7 +132,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar
|
||||
description: 'Validate quarantined tests still have owners, exit criteria, and active review windows.',
|
||||
kind: 'command',
|
||||
command: ['bun', 'run', 'check:quarantine'],
|
||||
requiredForModes: ['pr', 'baseline', 'release'],
|
||||
requiredForModes: ['baseline', 'release'],
|
||||
category: 'governance',
|
||||
},
|
||||
{
|
||||
@ -120,6 +141,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar
|
||||
description: 'Run unit/component coverage suites and enforce the ratcheted coverage baseline.',
|
||||
kind: 'command',
|
||||
command: ['bun', 'run', 'check:coverage'],
|
||||
impactRequiredCheck: 'bun run check:coverage',
|
||||
requiredForModes: ['pr', 'baseline', 'release'],
|
||||
category: 'coverage',
|
||||
},
|
||||
|
||||
@ -1,12 +1,3 @@
|
||||
{
|
||||
"quarantined": [
|
||||
{
|
||||
"id": "server:providers-real",
|
||||
"path": "src/server/__tests__/providers-real.test.ts",
|
||||
"reason": "Reviewed 2026-07-01: suite still performs a live MiniMax connectivity request with a fake key. Default server/PR gates must stay non-live and should not depend on third-party network responses.",
|
||||
"owner": "maintainers",
|
||||
"reviewAfter": "2026-07-15",
|
||||
"exitCriteria": "Move the live connectivity assertion into provider-smoke/release-live lanes, or replace it with a deterministic fetch mock so only non-live provider contract coverage remains in check:server."
|
||||
}
|
||||
]
|
||||
"quarantined": []
|
||||
}
|
||||
|
||||
@ -4,16 +4,17 @@ import { expiredQuarantineEntries, loadQuarantineManifest, quarantinedPathSet, r
|
||||
describe('quarantine manifest', () => {
|
||||
test('loads the default manifest', () => {
|
||||
const manifest = loadQuarantineManifest()
|
||||
expect(manifest.quarantined.length).toBeGreaterThan(0)
|
||||
expect(Array.isArray(manifest.quarantined)).toBe(true)
|
||||
})
|
||||
|
||||
test('default manifest review dates are still active', () => {
|
||||
validateQuarantineManifest(loadQuarantineManifest(), { enforceReviewDate: true })
|
||||
})
|
||||
|
||||
test('exposes quarantined paths as a set', () => {
|
||||
test('does not quarantine deterministic provider contract tests', () => {
|
||||
const paths = quarantinedPathSet()
|
||||
expect(paths.has('src/server/__tests__/providers-real.test.ts')).toBe(true)
|
||||
expect(paths.has('src/server/__tests__/provider-settings-isolation.test.ts')).toBe(false)
|
||||
expect(paths.has('src/server/__tests__/providers-real.test.ts')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects duplicate ids', () => {
|
||||
|
||||
@ -14,11 +14,13 @@ describe('quality gate modes', () => {
|
||||
expect(lanes).toContain('policy-checks')
|
||||
expect(lanes).toContain('desktop-checks')
|
||||
expect(lanes).toContain('server-checks')
|
||||
expect(lanes).toContain('provider-contract-checks')
|
||||
expect(lanes).toContain('chat-contract-checks')
|
||||
expect(lanes).toContain('adapter-checks')
|
||||
expect(lanes).toContain('native-checks')
|
||||
expect(lanes).toContain('docs-checks')
|
||||
expect(lanes).toContain('persistence-upgrade')
|
||||
expect(lanes).toContain('quarantine')
|
||||
expect(lanes).not.toContain('quarantine')
|
||||
expect(lanes).toContain('coverage')
|
||||
expect(lanes.some((lane) => lane.startsWith('baseline:'))).toBe(false)
|
||||
})
|
||||
@ -43,6 +45,8 @@ describe('quality gate modes', () => {
|
||||
expect(lanes).toContain('policy-checks')
|
||||
expect(lanes).toContain('desktop-checks')
|
||||
expect(lanes).toContain('server-checks')
|
||||
expect(lanes).toContain('provider-contract-checks')
|
||||
expect(lanes).toContain('chat-contract-checks')
|
||||
expect(lanes).toContain('adapter-checks')
|
||||
expect(lanes).toContain('docs-checks')
|
||||
expect(lanes).toContain('persistence-upgrade')
|
||||
|
||||
10
src/AGENTS.md
Normal file
10
src/AGENTS.md
Normal file
@ -0,0 +1,10 @@
|
||||
# CLI and Runtime Instructions
|
||||
|
||||
These rules apply to `src/` changes in addition to the root instructions.
|
||||
|
||||
- Keep CLI/runtime behavior under the existing `entrypoints`, `commands`, `services`, `tools`, `utils`, and `server` boundaries. Prefer the nearest existing seam over a new cross-cutting helper.
|
||||
- Add a focused `*.test.ts` regression beside the affected area. Run it first, then follow `bun run check:impact`; ordinary `src/` changes normally select `bun run check:server`.
|
||||
- Provider presets, authentication, managed environment variables, model metadata, proxy transforms, or streaming changes also require the offline `bun run check:provider-contract` when selected.
|
||||
- Keep tests hermetic: use fake credentials, intercepted requests or loopback servers, temporary `HOME`/`CLAUDE_CONFIG_DIR`, and restored environment/global state.
|
||||
- Never mutate real user configuration or transcripts. Persistence format changes require forward migration, unknown-field preservation, an old fixture, and `bun run check:persistence-upgrade`.
|
||||
- Agent-loop, tool execution, provider routing, permissions, resume, and file-editing behavior need deterministic mock/fixture coverage before any separately authorized live smoke.
|
||||
213
src/server/__tests__/provider-settings-isolation.test.ts
Normal file
213
src/server/__tests__/provider-settings-isolation.test.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
|
||||
const MODEL_MAPPING = {
|
||||
main: 'MiniMax-M3',
|
||||
haiku: 'MiniMax-M3',
|
||||
sonnet: 'MiniMax-M3',
|
||||
opus: 'MiniMax-M3',
|
||||
}
|
||||
|
||||
describe('provider settings isolation', () => {
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let service: ProviderService
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-settings-isolation-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
service = new ProviderService()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function readCcHahaSettings(): Promise<Record<string, unknown>> {
|
||||
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
async function originalSettingsExists(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(path.join(tmpDir, 'settings.json'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
test('activating a provider writes only cc-haha/settings.json', async () => {
|
||||
const minimax = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-fake-test-key-for-testing-only',
|
||||
models: MODEL_MAPPING,
|
||||
notes: 'MiniMax official Anthropic-compatible endpoint',
|
||||
})
|
||||
|
||||
await service.activateProvider(minimax.id)
|
||||
|
||||
const settings = await readCcHahaSettings()
|
||||
const env = settings.env as Record<string, string>
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('')
|
||||
expect(env.ANTHROPIC_MODEL).toBe('MiniMax-M3')
|
||||
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
|
||||
'MiniMax-M3': 1000000,
|
||||
'MiniMax-M2.7': 204800,
|
||||
'MiniMax-M2.7-highspeed': 204800,
|
||||
})
|
||||
expect(await originalSettingsExists()).toBe(false)
|
||||
})
|
||||
|
||||
test('switching providers replaces managed env without creating settings.json', async () => {
|
||||
const minimax = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-api-test-minimax',
|
||||
models: MODEL_MAPPING,
|
||||
})
|
||||
const relay = await service.addProvider({
|
||||
presetId: 'custom',
|
||||
name: 'Relay',
|
||||
baseUrl: 'https://api.jiekou.ai/anthropic',
|
||||
apiKey: 'sk-fake-test-key-for-testing-only',
|
||||
models: {
|
||||
main: 'claude-opus-4-7',
|
||||
haiku: 'claude-haiku-4-5',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
opus: 'claude-opus-4-7',
|
||||
},
|
||||
})
|
||||
|
||||
await service.activateProvider(minimax.id)
|
||||
let settings = await readCcHahaSettings()
|
||||
let env = settings.env as Record<string, string>
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
|
||||
'MiniMax-M3': 1000000,
|
||||
'MiniMax-M2.7': 204800,
|
||||
'MiniMax-M2.7-highspeed': 204800,
|
||||
})
|
||||
|
||||
await service.activateProvider(relay.id)
|
||||
settings = await readCcHahaSettings()
|
||||
env = settings.env as Record<string, string>
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('')
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-7')
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
|
||||
|
||||
const list = await service.listProviders()
|
||||
expect(list.activeId).toBe(relay.id)
|
||||
expect(await originalSettingsExists()).toBe(false)
|
||||
})
|
||||
|
||||
test('activation preserves unrelated cc-haha settings and env', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'settings.json'),
|
||||
JSON.stringify({
|
||||
customField: 'should_be_preserved',
|
||||
env: {
|
||||
EXISTING_VAR: 'should_be_preserved',
|
||||
},
|
||||
}, null, 2),
|
||||
)
|
||||
|
||||
const provider = await service.addProvider({
|
||||
presetId: 'custom',
|
||||
name: 'Relay',
|
||||
baseUrl: 'https://api.jiekou.ai/anthropic',
|
||||
apiKey: 'sk_test',
|
||||
models: {
|
||||
main: 'claude-opus-4-7',
|
||||
haiku: 'claude-haiku-4-5',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
opus: 'claude-opus-4-7',
|
||||
},
|
||||
})
|
||||
await service.activateProvider(provider.id)
|
||||
|
||||
const settings = await readCcHahaSettings()
|
||||
const env = settings.env as Record<string, string>
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk_test')
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('')
|
||||
expect(settings.customField).toBe('should_be_preserved')
|
||||
expect(env.EXISTING_VAR).toBe('should_be_preserved')
|
||||
})
|
||||
|
||||
test('activateOfficial removes only provider-managed env', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'settings.json'),
|
||||
JSON.stringify({ env: { EXISTING_VAR: 'keep-me' } }, null, 2),
|
||||
)
|
||||
const provider = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-test',
|
||||
models: MODEL_MAPPING,
|
||||
})
|
||||
|
||||
await service.activateProvider(provider.id)
|
||||
await service.activateOfficial()
|
||||
|
||||
const settings = await readCcHahaSettings()
|
||||
const env = settings.env as Record<string, string> | undefined
|
||||
expect(env?.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
expect(env?.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
expect(env?.ANTHROPIC_MODEL).toBeUndefined()
|
||||
expect(env?.EXISTING_VAR).toBe('keep-me')
|
||||
})
|
||||
|
||||
test('providers.json and cc-haha/settings.json stay isolated from Claude settings.json', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
effortLevel: 'high',
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://original-claude-code.api.com',
|
||||
ANTHROPIC_API_KEY: 'original-key',
|
||||
},
|
||||
}, null, 2),
|
||||
)
|
||||
|
||||
const provider = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-haha-key',
|
||||
models: MODEL_MAPPING,
|
||||
})
|
||||
await service.activateProvider(provider.id)
|
||||
|
||||
const original = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8'))
|
||||
expect(original.env.ANTHROPIC_BASE_URL).toBe('https://original-claude-code.api.com')
|
||||
expect(original.env.ANTHROPIC_API_KEY).toBe('original-key')
|
||||
expect(original.effortLevel).toBe('high')
|
||||
|
||||
const haha = await readCcHahaSettings()
|
||||
const env = haha.env as Record<string, string>
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key')
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('')
|
||||
})
|
||||
})
|
||||
@ -1,261 +0,0 @@
|
||||
/**
|
||||
* 用真实的 Provider 配置测试 ProviderService
|
||||
* 验证添加、激活、cc-haha/settings.json 同步是否正确
|
||||
* (provider env 写到 ~/.claude/cc-haha/settings.json,不污染原版 settings.json)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
|
||||
const MODEL_MAPPING = {
|
||||
main: 'MiniMax-M3',
|
||||
haiku: 'MiniMax-M3',
|
||||
sonnet: 'MiniMax-M3',
|
||||
opus: 'MiniMax-M3',
|
||||
}
|
||||
|
||||
describe('Real Provider Configs', () => {
|
||||
let tmpDir: string
|
||||
let service: ProviderService
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-real-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
service = new ProviderService()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Helper: read the Haha-specific settings file
|
||||
async function readCcHahaSettings(): Promise<Record<string, unknown>> {
|
||||
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
// Helper: check original settings.json is NOT modified
|
||||
async function originalSettingsExists(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(path.join(tmpDir, 'settings.json'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
test('添加 MiniMax Provider 并激活 — 写入 cc-haha/settings.json', async () => {
|
||||
const minimax = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-fake-test-key-for-testing-only',
|
||||
models: MODEL_MAPPING,
|
||||
notes: 'MiniMax 官方 Anthropic 兼容接口',
|
||||
})
|
||||
|
||||
expect(minimax.name).toBe('MiniMax')
|
||||
|
||||
// 激活 provider
|
||||
await service.activateProvider(minimax.id)
|
||||
|
||||
// 验证写入 cc-haha/settings.json
|
||||
const settings = await readCcHahaSettings()
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('MiniMax-M3')
|
||||
expect(JSON.parse((settings.env as Record<string, string>).CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
|
||||
'MiniMax-M3': 1000000,
|
||||
'MiniMax-M2.7': 204800,
|
||||
'MiniMax-M2.7-highspeed': 204800,
|
||||
})
|
||||
|
||||
// 验证原版 settings.json 没有被创建
|
||||
expect(await originalSettingsExists()).toBe(false)
|
||||
|
||||
console.log('✅ Provider 写入 cc-haha/settings.json,原版 settings.json 未被污染')
|
||||
})
|
||||
|
||||
test('切换 Provider — 更新 cc-haha/settings.json', async () => {
|
||||
const minimax = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-api-test-minimax',
|
||||
models: MODEL_MAPPING,
|
||||
})
|
||||
|
||||
const jiekou = await service.addProvider({
|
||||
presetId: 'custom',
|
||||
name: '接口AI中转站',
|
||||
baseUrl: 'https://api.jiekou.ai/anthropic',
|
||||
apiKey: 'sk-fake-test-key-for-testing-only',
|
||||
models: {
|
||||
main: 'claude-opus-4-7',
|
||||
haiku: 'claude-haiku-4-5',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
opus: 'claude-opus-4-7',
|
||||
},
|
||||
})
|
||||
|
||||
// 先激活 MiniMax
|
||||
await service.activateProvider(minimax.id)
|
||||
let settings = await readCcHahaSettings()
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect(JSON.parse((settings.env as Record<string, string>).CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
|
||||
'MiniMax-M3': 1000000,
|
||||
'MiniMax-M2.7': 204800,
|
||||
'MiniMax-M2.7-highspeed': 204800,
|
||||
})
|
||||
|
||||
// 切换到接口AI中转站
|
||||
await service.activateProvider(jiekou.id)
|
||||
settings = await readCcHahaSettings()
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('claude-opus-4-7')
|
||||
expect((settings.env as Record<string, string>).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
|
||||
|
||||
// 验证 activeId 正确
|
||||
const list = await service.listProviders()
|
||||
expect(list.activeId).toBe(jiekou.id)
|
||||
|
||||
// 原版 settings.json 依然不存在
|
||||
expect(await originalSettingsExists()).toBe(false)
|
||||
|
||||
console.log('✅ 切换 Provider 成功,cc-haha/settings.json 更新正确')
|
||||
})
|
||||
|
||||
test('cc-haha/settings.json 保留已有字段', async () => {
|
||||
// 预写一个有内容的 cc-haha/settings.json(模拟用户已有配置)
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'settings.json'),
|
||||
JSON.stringify({
|
||||
customField: 'should_be_preserved',
|
||||
env: {
|
||||
EXISTING_VAR: 'should_be_preserved',
|
||||
},
|
||||
}, null, 2),
|
||||
)
|
||||
|
||||
// 添加并激活 provider
|
||||
const provider = await service.addProvider({
|
||||
presetId: 'custom',
|
||||
name: '接口AI中转站',
|
||||
baseUrl: 'https://api.jiekou.ai/anthropic',
|
||||
apiKey: 'sk_test',
|
||||
models: {
|
||||
main: 'claude-opus-4-7',
|
||||
haiku: 'claude-haiku-4-5',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
opus: 'claude-opus-4-7',
|
||||
},
|
||||
})
|
||||
await service.activateProvider(provider.id)
|
||||
|
||||
const settings = await readCcHahaSettings()
|
||||
|
||||
// 验证新字段写入
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk_test')
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
|
||||
|
||||
// 验证已有字段保留
|
||||
expect(settings.customField).toBe('should_be_preserved')
|
||||
expect((settings.env as Record<string, string>).EXISTING_VAR).toBe('should_be_preserved')
|
||||
|
||||
console.log('✅ cc-haha/settings.json 已有字段全部保留')
|
||||
})
|
||||
|
||||
test('activateOfficial 清除 provider env', async () => {
|
||||
const provider = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-test',
|
||||
models: MODEL_MAPPING,
|
||||
})
|
||||
|
||||
await service.activateProvider(provider.id)
|
||||
|
||||
// 确认写入了
|
||||
let settings = await readCcHahaSettings()
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBeDefined()
|
||||
|
||||
// 切换到 official
|
||||
await service.activateOfficial()
|
||||
|
||||
settings = await readCcHahaSettings()
|
||||
const env = settings.env as Record<string, string> | undefined
|
||||
expect(env?.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
expect(env?.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
expect(env?.ANTHROPIC_MODEL).toBeUndefined()
|
||||
|
||||
console.log('✅ activateOfficial 正确清除了 provider env')
|
||||
})
|
||||
|
||||
test('连通性测试 — 返回结构正确', async () => {
|
||||
const result = await service.testProviderConfig({
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-fake-test-key',
|
||||
modelId: 'MiniMax-M3',
|
||||
authStrategy: 'auth_token',
|
||||
})
|
||||
|
||||
// testProviderConfig 返回 { connectivity: { ... }, proxy?: { ... } }
|
||||
expect(result.connectivity).toBeDefined()
|
||||
expect(result.connectivity.latencyMs).toBeGreaterThanOrEqual(0)
|
||||
expect(result.connectivity.modelUsed).toBe('MiniMax-M3')
|
||||
|
||||
console.log('🔌 MiniMax 连通性测试结果:')
|
||||
console.log(' success:', result.connectivity.success)
|
||||
console.log(' latencyMs:', result.connectivity.latencyMs)
|
||||
console.log(' error:', result.connectivity.error)
|
||||
})
|
||||
|
||||
test('providers.json 和 cc-haha/settings.json 独立于 settings.json', async () => {
|
||||
// 模拟原版 Claude Code 的 settings.json 已存在
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
effortLevel: 'high',
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://original-claude-code.api.com',
|
||||
ANTHROPIC_API_KEY: 'original-key',
|
||||
},
|
||||
}, null, 2),
|
||||
)
|
||||
|
||||
// Haha 添加并激活自己的 provider
|
||||
const provider = await service.addProvider({
|
||||
presetId: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-haha-key',
|
||||
models: MODEL_MAPPING,
|
||||
})
|
||||
await service.activateProvider(provider.id)
|
||||
|
||||
// 验证原版 settings.json 没被修改
|
||||
const original = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8'))
|
||||
expect((original.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://original-claude-code.api.com')
|
||||
expect((original.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('original-key')
|
||||
expect(original.effortLevel).toBe('high')
|
||||
|
||||
// 验证 cc-haha/settings.json 是 Haha 自己的
|
||||
const haha = await readCcHahaSettings()
|
||||
expect((haha.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
|
||||
expect((haha.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key')
|
||||
expect((haha.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
|
||||
|
||||
console.log('✅ 原版 settings.json 完好无损,Haha 配置独立存储')
|
||||
})
|
||||
})
|
||||
@ -15,7 +15,7 @@ describe('resolveAnthropicClientApiKey', () => {
|
||||
|
||||
const apiKey = resolveAnthropicClientApiKey({
|
||||
envAuthToken: 'provider-bearer-token',
|
||||
envApiKey: undefined,
|
||||
envApiKey: '',
|
||||
getFallbackApiKey,
|
||||
})
|
||||
|
||||
@ -42,8 +42,8 @@ describe('resolveAnthropicClientApiKey', () => {
|
||||
const getFallbackApiKey = mock(() => 'sk-keychain-fallback')
|
||||
|
||||
const apiKey = resolveAnthropicClientApiKey({
|
||||
envAuthToken: undefined,
|
||||
envApiKey: undefined,
|
||||
envAuthToken: '',
|
||||
envApiKey: '',
|
||||
getFallbackApiKey,
|
||||
})
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ describe('model context window resolution', () => {
|
||||
expect(getContextWindowForModel('deepseek-v4-pro')).toBe(1_000_000)
|
||||
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
|
||||
expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144)
|
||||
expect(getContextWindowForModel('zai-org/GLM-5.2')).toBe(200_000)
|
||||
expect(getContextWindowForModel('zai-org/GLM-5.2')).toBe(1_000_000)
|
||||
expect(getContextWindowForModel('glm-5.1')).toBe(200_000)
|
||||
expect(getContextWindowForModel('glm-4.5-air')).toBe(128_000)
|
||||
})
|
||||
@ -62,7 +62,7 @@ describe('model context window resolution', () => {
|
||||
|
||||
test('derives auto-compact thresholds from provider context windows', () => {
|
||||
expect(getAutoCompactThreshold('deepseek-v4-pro')).toBe(967_000)
|
||||
expect(getAutoCompactThreshold('zai-org/GLM-5.2')).toBe(167_000)
|
||||
expect(getAutoCompactThreshold('zai-org/GLM-5.2')).toBe(967_000)
|
||||
expect(getAutoCompactThreshold('glm-5.1')).toBe(167_000)
|
||||
expect(getAutoCompactThreshold('glm-4.5-air')).toBe(95_000)
|
||||
expect(getAutoCompactThreshold('kimi-k2.6')).toBe(229_144)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user