diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..5cab1d16 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,13 @@ +# AI Coding Instructions + +Follow the repository contract in `AGENTS.md` before editing code. + +For every feature or bugfix: + +- Identify the changed surface before coding: `desktop`, `server`, `adapter`, `native`, `docs`, `provider/runtime`, `agent-loop`, 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. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index deb1ef6b..df654339 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,26 @@ ## Summary +## Feature Quality Contract + +- Changed surface: +- Tests added or updated: + - +- Coverage evidence: + - +- E2E / live-model evidence: + - +- Known risk / rollback: + - + ## Verification - [ ] I ran the relevant local checks, or explained why they do not apply. -- [ ] I ran `bun run quality:pr` for code changes, including the coverage gate. +- [ ] I added or updated same-area tests for every production behavior change. +- [ ] I ran `bun run verify` for code changes, including the coverage gate. +- [ ] 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. ## Risk diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index ca977e9f..28ce016a 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -232,3 +232,57 @@ jobs: name: coverage-report path: artifacts/coverage/ retention-days: 14 + + pr-quality-gate: + name: pr-quality-gate + needs: + - change-policy + - desktop-checks + - server-checks + - adapter-checks + - desktop-native-checks + - docs-checks + - coverage-checks + if: always() + runs-on: ubuntu-latest + steps: + - name: Require all selected quality jobs to pass + run: | + failures=0 + + require_success() { + local name="$1" + local result="$2" + if [ "$result" != "success" ]; then + echo "::error::$name ended with result: $result" + failures=$((failures + 1)) + else + echo "$name: success" + fi + } + + allow_skip_or_success() { + local name="$1" + local result="$2" + case "$result" in + success|skipped) + echo "$name: $result" + ;; + *) + echo "::error::$name ended with result: $result" + failures=$((failures + 1)) + ;; + esac + } + + 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 }}" + + if [ "$failures" -gt 0 ]; then + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index e9a70f73..1f914dbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Install root dependencies with `bun install`, then install desktop dependencies - `cd desktop && bun run build`: type-check and produce a production web build. - `cd desktop && bun run test`: run Vitest suites. - `cd desktop && bun run lint`: run TypeScript no-emit checks. +- `bun run verify`: one-command local PR verification entrypoint for contributors and AI coding agents; equivalent to `bun run quality:pr`. - `bun run quality:providers`: list configured provider/model selectors for live agent baselines. - `bun run quality:pr`: run the local PR quality gate and write markdown, JSON, JUnit, and per-lane logs under `artifacts/quality-runs/`, plus coverage reports under `artifacts/coverage/`. - `bun run check:quarantine`: validate quarantined tests still have owners, exit criteria, and active review windows. @@ -40,10 +41,26 @@ Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match ## Testing Guidelines Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tests `*.test.ts` or `*.test.tsx`; colocate focused tests near the file or place broader coverage in `desktop/src/__tests__/`. Add regression tests for behavior changes and keep the coverage ratchet from dropping. +## 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. + +- 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 the feature, not an afterthought. The project standard is modeled on Google/Microsoft-style coverage practice: 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. +- 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`/`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 "complete", "ready", or "mergeable". + ## Quality Gate Automation Future Coding Agents should run the right local gate themselves before claiming a change is ready. Do not ask the user to manually run the commands unless credentials, local model access, or machine resources are missing. -- For normal code changes, run the narrow relevant check first, then `bun run quality:pr` before a PR-ready or merge-ready claim. +- Unified local entrypoint: `bun run verify`. This is the command AI coding agents should run before final handoff; it is equivalent to `bun run quality:pr`, does not call real models, and writes `artifacts/quality-runs//report.md` plus `artifacts/coverage//coverage-report.md`. +- If `bun run verify` fails, do not stop at reporting the failure. Read the latest quality report, identify the failed lane in the Result Matrix, open that lane log under `artifacts/quality-runs//logs/.log`, fix the concrete missing tests, coverage failures, lint/type/build errors, docs errors, or native errors, then rerun the narrow check and finally rerun `bun run verify`. +- For coverage failures, read both `coverage-report.md` and `coverage-report.json`. Fix `changedLines.failures` and `failures` first; treat `targetGaps` as debt signals and make touched areas better. Do not lower `coverage-baseline.json` or `coverage-thresholds.json` unless a maintainer explicitly requested and approved that policy change. +- For `Path-aware PR checks` failures, add same-area tests for changed production files instead of bypassing the gate. Maintainer overrides such as `allow-missing-tests`, `allow-cli-core-change`, and `allow-coverage-baseline-change` are not valid for ordinary feature work. +- For normal code changes, run the narrow relevant check first, then `bun run verify` before a PR-ready or merge-ready claim. - Use `bun run check:server` for `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, or API behavior changes. - Use `bun run check:desktop` for `desktop/src` UI, stores, API clients, and desktop web behavior changes. - Use `bun run check:native` for `desktop/src-tauri`, sidecars, native packaging, release, or platform startup behavior changes. @@ -53,7 +70,7 @@ Future Coding Agents should run the right local gate themselves before claiming - For chat, agent loop, tool execution, provider routing, desktop chat UI, CLI task execution, or other core Coding Agent paths, also run a live baseline when local providers are available: first `bun run quality:providers`, then choose one or more copyable selectors and run `bun run quality:gate --mode baseline --allow-live --provider-model `. - For release readiness, run `bun run quality:gate --mode release --allow-live --provider-model ` with at least one real provider/model selector. Prefer multiple providers when quota is available. Release-mode live lanes must not be skipped silently. - If no live provider is configured, or a provider quota/key is unavailable, run the non-live gate anyway and report the live-baseline blocker explicitly instead of claiming full release confidence. -- `bun run check:docs` executes `npm ci`, which can rebuild root `node_modules`. Run docs checks sequentially, not in parallel with `quality:pr`, `check:native`, or other commands that depend on the same installed packages. +- `bun run check:docs` executes `npm ci`, which can rebuild root `node_modules`. Run docs checks sequentially, not in parallel with `verify`, `quality:pr`, `check:native`, or other commands that depend on the same installed packages. - Quality reports are written to `artifacts/quality-runs//` as `report.md`, `report.json`, `junit.xml`, and `logs/*.log`; coverage reports are written to `artifacts/coverage//`. Summarize the final report paths and the pass/fail/skip counts in handoffs and PR descriptions. - Do not commit generated `artifacts/quality-runs/`, local `.omx/` state, `node_modules/`, `desktop/node_modules/`, or adapter dependency folders. - Do not claim "complete", "ready to merge", or "ready to release" without either running the matching gate or naming the exact blocker that prevented it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2ba223a..1f32e045 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,14 +7,16 @@ - 中文:[docs/guide/contributing.md](docs/guide/contributing.md) - English:[docs/en/guide/contributing.md](docs/en/guide/contributing.md) -大多数贡献者在提交 PR 前应先运行: +大多数贡献者和 AI Coding Agent 在提交 PR 前应先运行统一入口: ```bash bun install -bun run quality:pr +bun run verify ``` -这个门禁现在会同时生成质量报告和覆盖率报告: +`bun run verify` 等价于 `bun run quality:pr`,会根据 impact report 一键执行被选中的 policy、desktop、server、adapters、native、docs、quarantine 和 coverage 门禁。命令非 0 退出就表示当前分支还不能提交 PR 或 push。 + +这个门禁会同时生成质量报告和覆盖率报告。主质量报告会内嵌当前测试范围、结果矩阵、覆盖率摘要,并链接完整 coverage/JUnit/log artifact: ```text artifacts/quality-runs//report.md @@ -24,9 +26,51 @@ artifacts/quality-runs//logs/*.log artifacts/coverage//coverage-report.md ``` -覆盖率 baseline/threshold 变更需要维护者加 `allow-coverage-baseline-change`。CI 会优先用 base branch 的 baseline 做 ratchet 对比,避免 PR 自己降低 baseline 后绕过门禁。 +覆盖率 baseline/threshold 变更需要维护者加 `allow-coverage-baseline-change`。CI 会优先用 base branch 的 baseline 做 ratchet 对比,避免 PR 自己降低 baseline 后绕过门禁。覆盖率口径参考 Google/Microsoft 的公开实践:维护中的产品区域向 75-80%+ 拉升,新增或变更的可执行生产代码行必须满足 changed-line coverage 门槛。 被 quarantine 的测试必须保留 owner、reviewAfter 和 exitCriteria;过期后 `check:quarantine`、`check:server`、`check:coverage` 都会阻断。 +所有新功能和修复都必须遵守 `AGENTS.md` 里的 Feature Quality Contract:先说明变更面,再补同区域测试、覆盖率证据、必要的 E2E/live smoke 证据和剩余风险。AI Coding Agent 也要遵守 `.github/copilot-instructions.md`,不能只交生产代码不交测试证据。 + +给 AI 的修复循环可以直接写成: + +```text +Run `bun run verify`. If it fails, read the latest quality report and lane log, +fix missing same-area tests, coverage failures, type/lint/build errors, or +docs/native failures, then rerun `bun run verify` until the final Summary has +failed=0. Do not lower coverage baselines or thresholds unless a maintainer +explicitly requested it. +``` + +常用定位路径: + +- 总报告:`artifacts/quality-runs//report.md` +- lane 日志:`artifacts/quality-runs//logs/.log` +- 覆盖率报告:`artifacts/coverage//coverage-report.md` +- 机器可读覆盖率:`artifacts/coverage//coverage-report.json` + +如果希望本机在 `git push` 前强制运行同一套门禁,安装仓库 Git hook: + +```bash +bun run hooks:install +``` + +安装后,每次 push 都会先运行同一套验证入口(内部是 `bun run quality:pr`,等价于 `bun run verify`),失败则 push 中止。维护者机器可以把真实模型/桌面 smoke 也纳入 pre-push: + +```bash +bun run quality:providers +bun run hooks:install -- --live-provider-model :main +``` + +需要完整 live baseline 时加 `--live-mode baseline`。这个配置写入本机 `.git/config`,不会提交 provider 信息或密钥。 + +如果当前分支包含 CLI core 或 coverage policy 这类维护者级变更,本机 hook 也不会默认放行。维护者需要显式配置本 clone 的批准: + +```bash +bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-change +``` + +PR CI 由 `.github/workflows/pr-quality.yml` 在 PR 更新时触发。最后的 `pr-quality-gate` 会汇总所有被变更策略选中的 job;仓库 `main` 分支应在 GitHub branch protection / ruleset 中把 `pr-quality-gate` 设置成 required status check。 + 如果你在全新 clone 中运行 adapter 或 native 相关检查,还需要安装 adapter 依赖: ```bash diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index c7c850ee..dd035ecb 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' @@ -317,7 +317,7 @@ describe('Settings > Agents tab', () => { expect(screen.getByText('plain-agent')).toBeInTheDocument() }) - it('returns to plugins tab when agent detail was opened from plugins', () => { + it('returns to plugins tab when agent detail was opened from plugins', async () => { useAgentStore.setState({ allAgents: MOCK_AGENTS, activeAgents: MOCK_AGENTS.filter((agent) => agent.isActive), @@ -335,7 +335,10 @@ describe('Settings > Agents tab', () => { render() switchToAgentsTab() - fireEvent.click(screen.getByText('Back to list')) + await act(async () => { + fireEvent.click(screen.getByText('Back to list')) + await Promise.resolve() + }) expect(screen.getByText('Installed Plugins')).toBeInTheDocument() }) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 7bb5bc71..47b7d6fe 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -329,14 +329,20 @@ describe('Settings > Providers tab', () => { it('requires confirmation before deleting a provider', async () => { render() - fireEvent.click(screen.getAllByText('Delete')[0]!) + await act(async () => { + fireEvent.click(screen.getAllByText('Delete')[0]!) + await Promise.resolve() + }) expect(MOCK_DELETE_PROVIDER).not.toHaveBeenCalled() expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.getByText('Delete provider "MiniMax-M2.7-highspeed(openai)"? This cannot be undone.')).toBeInTheDocument() const dialog = screen.getByRole('dialog') - fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + await act(async () => { + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + await Promise.resolve() + }) expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1') }) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 74223be0..24e38544 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1,5 +1,5 @@ -import { beforeEach, describe, it, expect, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { renderToStaticMarkup } from 'react-dom/server' import '@testing-library/jest-dom' @@ -14,6 +14,12 @@ vi.mock('../api/skills', () => ({ }, })) +vi.mock('../api/providers', () => ({ + providersApi: { + list: vi.fn(async () => ({ providers: [], activeId: null })), + }, +})) + vi.mock('../api/mcp', () => ({ mcpApi: { list: vi.fn(async () => ({ servers: [] })), @@ -80,14 +86,36 @@ import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { useChatStore } from '../stores/chatStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' +import { useProviderStore } from '../stores/providerStore' import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore' import { useTabStore } from '../stores/tabStore' beforeEach(() => { useSettingsStore.setState({ locale: 'en' }) + useProviderStore.setState({ + providers: [], + activeId: null, + hasLoadedProviders: true, + isLoading: false, + }) useSessionRuntimeStore.setState({ selections: {} }) }) +afterEach(async () => { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + cleanup() +}) + +function resetPageStores() { + cleanup() + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) +} + /** * Core rendering tests: content-only pages must render without crashing * and contain key structural elements from the prototype. @@ -131,8 +159,13 @@ describe('Content-only pages render without errors', () => { expect(screen.queryByText('/internal-only')).not.toBeInTheDocument() }) - it('EmptySession renders mascot and composer', () => { - const { container } = render() + it('EmptySession renders mascot and composer', async () => { + let container!: HTMLElement + await act(async () => { + container = render().container + await Promise.resolve() + await Promise.resolve() + }) expect(container.querySelector('textarea')).toBeInTheDocument() expect(container.innerHTML).toContain('New session') expect(container.innerHTML).toContain('Ask anything') @@ -161,9 +194,16 @@ describe('Content-only pages render without errors', () => { expect(html).not.toContain('animate-spin') }) - it('EmptySession plus menu exposes uploads and slash commands before chat starts', () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' })) + it('EmptySession plus menu exposes uploads and slash commands before chat starts', async () => { + await act(async () => { + render() + await Promise.resolve() + await Promise.resolve() + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' })) + await Promise.resolve() + }) expect(screen.getByText('Add files or photos')).toBeInTheDocument() expect(screen.getByText('Slash commands')).toBeInTheDocument() }) @@ -203,8 +243,7 @@ describe('Content-only pages render without errors', () => { expect(textarea).toHaveAttribute('rows', '2') expect(container.innerHTML).not.toContain('Preview') // Cleanup - useTabStore.setState({ tabs: [], activeTabId: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession keeps the compact composer once messages exist', () => { @@ -258,9 +297,7 @@ describe('Content-only pages render without errors', () => { const textarea = screen.getByPlaceholderText('Ask Claude to edit, debug or explain...') expect(textarea).toHaveAttribute('rows', '1') - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession shows a single primary action button while a turn is active', () => { @@ -291,7 +328,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument() expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument() - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession opens a local /mcp panel and clicking an item routes to settings', async () => { @@ -368,9 +405,7 @@ describe('Content-only pages render without errors', () => { expect(useTabStore.getState().activeTabId).toBe('__settings__') expect(useUIStore.getState().pendingSettingsTab).toBe('mcp') - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession opens a local /skills panel from the fallback slash commands', async () => { @@ -438,9 +473,7 @@ describe('Content-only pages render without errors', () => { expect(await screen.findByText('Available skills')).toBeInTheDocument() expect(screen.getByText('/lark-mail')).toBeInTheDocument() - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession routes /plugin to Settings > Plugins instead of sending a chat message', () => { @@ -496,9 +529,7 @@ describe('Content-only pages render without errors', () => { expect(useTabStore.getState().activeTabId).toBe('__settings__') expect(useUIStore.getState().pendingSettingsTab).toBe('plugins') - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession routes /help to the local command panel', () => { @@ -562,9 +593,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getByText('/cost')).toBeInTheDocument() expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument() - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession /status inspector uses theme tokens instead of fixed light colors', async () => { @@ -623,9 +652,7 @@ describe('Content-only pages render without errors', () => { expect(container.innerHTML).not.toContain('bg-[#f4f2ed]') expect(container.innerHTML).not.toContain('border-[#d8b3a8]') - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession shows live context usage near the composer', async () => { @@ -706,9 +733,7 @@ describe('Content-only pages render without errors', () => { timeout: 20_000, }) - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession keeps a stable context placeholder while context usage loads', async () => { @@ -760,9 +785,7 @@ describe('Content-only pages render without errors', () => { expect(indicator).toHaveTextContent('--') expect(indicator).toHaveClass('h-8') - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession treats an empty idle session without a running CLI as pending context', async () => { @@ -828,9 +851,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getByText('Context usage will be calculated after the session starts.')).toBeInTheDocument() expect(screen.queryByText('CLI session is not running')).not.toBeInTheDocument() - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession shows initial context usage for an empty live session', async () => { @@ -909,9 +930,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getAllByText('kimi-k2.6').length).toBeGreaterThan(0) expect(screen.queryByText('Context usage will be calculated after the session starts.')).not.toBeInTheDocument() - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession shows context estimate during compaction or reconnect fallback', async () => { @@ -995,9 +1014,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getByText('Estimate')).toBeInTheDocument() expect(screen.queryByText('Autocompact buffer')).not.toBeInTheDocument() - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() }) it('ActiveSession keeps selected runtime model visible when context is unavailable', async () => { @@ -1064,9 +1081,7 @@ describe('Content-only pages render without errors', () => { expect(screen.getAllByText('kimi-k2.6').length).toBeGreaterThan(0) expect(screen.queryByText('Unknown model')).not.toBeInTheDocument() - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() useSessionRuntimeStore.setState({ selections: {} }) }) @@ -1162,9 +1177,11 @@ describe('Content-only pages render without errors', () => { expect(await screen.findByLabelText('Context usage 10%')).toBeInTheDocument() - useSessionRuntimeStore.getState().setSelection(SESSION_ID, { - providerId: 'zhipu-provider', - modelId: 'glm-4.5-air', + act(() => { + useSessionRuntimeStore.getState().setSelection(SESSION_ID, { + providerId: 'zhipu-provider', + modelId: 'glm-4.5-air', + }) }) expect(await screen.findByLabelText('Context usage 20%')).toBeInTheDocument() @@ -1173,9 +1190,7 @@ describe('Content-only pages render without errors', () => { .toBeGreaterThanOrEqual(2) }) - useTabStore.setState({ tabs: [], activeTabId: null }) - useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) - useChatStore.setState({ sessions: {} }) + resetPageStores() useSessionRuntimeStore.setState({ selections: {} }) }) @@ -1222,6 +1237,10 @@ describe('Chat attachments', () => { describe('AppShell layout renders chrome', () => { it('AppShell renders sidebar and session shell', () => { + useSessionStore.setState({ + fetchSessions: vi.fn(async () => {}), + } as Partial>) + const { container } = render() expect(container.querySelector('aside')).toBeInTheDocument() expect(container.innerHTML).toContain('New session') diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index d13a996d..2b3d21bb 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' @@ -222,7 +222,7 @@ describe('Settings > Skills tab', () => { expect(screen.queryByText(/^---$/)).not.toBeInTheDocument() }) - it('returns to plugins tab when skill detail was opened from plugins', () => { + it('returns to plugins tab when skill detail was opened from plugins', async () => { useSkillStore.setState({ selectedSkill: { meta: { @@ -252,7 +252,10 @@ describe('Settings > Skills tab', () => { render() switchToSkillsTab() - fireEvent.click(screen.getByText('Back to list')) + await act(async () => { + fireEvent.click(screen.getByText('Back to list')) + await Promise.resolve() + }) expect(screen.getByText('Installed Plugins')).toBeInTheDocument() }) diff --git a/desktop/src/components/chat/SessionTaskBar.test.tsx b/desktop/src/components/chat/SessionTaskBar.test.tsx index b5607f31..b6918084 100644 --- a/desktop/src/components/chat/SessionTaskBar.test.tsx +++ b/desktop/src/components/chat/SessionTaskBar.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { SessionTaskBar } from './SessionTaskBar' import { useCLITaskStore } from '../../stores/cliTaskStore' @@ -34,6 +34,7 @@ describe('SessionTaskBar', () => { }) afterEach(() => { + cleanup() useCLITaskStore.getState().clearTasks() }) @@ -74,7 +75,7 @@ describe('SessionTaskBar', () => { expect(useCLITaskStore.getState().tasks).toEqual([]) }) - it('shows the bar again for a new task cycle after a previous completed set was dismissed', () => { + it('shows the bar again for a new task cycle after a previous completed set was dismissed', async () => { act(() => { useCLITaskStore.getState().setTasksFromTodos([ { content: 'first', status: 'completed' }, @@ -85,7 +86,10 @@ describe('SessionTaskBar', () => { render() }) - fireEvent.click(screen.getByRole('button', { name: 'Hide completed tasks' })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Hide completed tasks' })) + await Promise.resolve() + }) expect(screen.queryByText('Tasks')).toBeNull() act(() => { diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index de59a471..8daead2d 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import { ThinkingBlock } from './ThinkingBlock' import { ToolCallBlock } from './ToolCallBlock' import { PermissionDialog } from './PermissionDialog' @@ -98,7 +98,7 @@ describe('chat blocks', () => { expect(container.textContent).toContain('allowed applications') }) - it('shows a diff preview for edit permission requests', () => { + it('shows a diff preview for edit permission requests', async () => { useChatStore.setState({ sessions: { 'active-tab': { @@ -130,17 +130,21 @@ describe('chat blocks', () => { }, }) - const { container } = render( - , - ) + let container!: HTMLElement + await act(async () => { + container = render( + , + ).container + await Promise.resolve() + }) expect(container.textContent).toContain('/tmp/example.ts') expect(container.textContent).toContain('Allow') diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx new file mode 100644 index 00000000..39e89aaf --- /dev/null +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { ModelSelector } from './ModelSelector' +import { useChatStore } from '../../stores/chatStore' +import { useProviderStore } from '../../stores/providerStore' +import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' +import { useSettingsStore } from '../../stores/settingsStore' +import type { ModelInfo } from '../../types/settings' + +const MODELS: ModelInfo[] = [ + { id: 'alpha', name: 'Alpha', description: 'Fast model', context: '128k' }, + { id: 'beta', name: 'Beta', description: 'Careful model', context: '200k' }, +] + +async function clickByRole(name: RegExp | string) { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name })) + await Promise.resolve() + }) +} + +afterEach(() => { + cleanup() + useSettingsStore.setState(useSettingsStore.getInitialState(), true) + useProviderStore.setState(useProviderStore.getInitialState(), true) + useSessionRuntimeStore.setState(useSessionRuntimeStore.getInitialState(), true) + useChatStore.setState(useChatStore.getInitialState(), true) +}) + +describe('ModelSelector', () => { + it('uses controlled model selection without mutating settings directly', async () => { + const onChange = vi.fn() + useSettingsStore.setState({ + locale: 'en', + availableModels: MODELS, + currentModel: MODELS[0], + }) + + render() + + await clickByRole(/alpha/i) + await clickByRole(/Beta/) + + expect(onChange).toHaveBeenCalledWith('beta') + }) + + it('routes uncontrolled model and effort changes through settings actions', async () => { + const setModel = vi.fn(async () => {}) + const setEffort = vi.fn(async () => {}) + useSettingsStore.setState({ + locale: 'en', + availableModels: MODELS, + currentModel: MODELS[0], + effortLevel: 'medium', + setModel, + setEffort, + }) + + render() + + await clickByRole(/alpha/i) + await clickByRole(/Beta/) + expect(setModel).toHaveBeenCalledWith('beta') + + await clickByRole(/Alpha/) + await clickByRole(/^High$/) + expect(setEffort).toHaveBeenCalledWith('high') + }) + + it('selects provider-scoped runtime models and mirrors session selections', async () => { + const setSessionRuntime = vi.fn() + useSettingsStore.setState({ + locale: 'en', + availableModels: MODELS, + currentModel: MODELS[0], + activeProviderName: 'Provider A', + }) + useProviderStore.setState({ + providers: [{ + id: 'provider-a', + presetId: 'custom', + name: 'Provider A', + apiKey: '***', + baseUrl: 'https://api.example.com', + apiFormat: 'anthropic', + models: { + main: 'provider-main', + haiku: 'provider-fast', + sonnet: 'provider-main', + opus: '', + }, + }], + activeId: 'provider-a', + hasLoadedProviders: true, + isLoading: true, + }) + useChatStore.setState({ + setSessionRuntime, + } as Partial>) + + render() + + await clickByRole(/alpha/i) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /provider-fast/ })) + await Promise.resolve() + }) + + expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({ + providerId: 'provider-a', + modelId: 'provider-fast', + }) + expect(setSessionRuntime).toHaveBeenCalledWith('session-1', { + providerId: 'provider-a', + modelId: 'provider-fast', + }) + }) +}) diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx index f1993d2f..dc0b464d 100644 --- a/desktop/src/components/layout/ContentRouter.test.tsx +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -31,6 +31,7 @@ import { useTabStore } from '../../stores/tabStore' describe('ContentRouter terminal tabs', () => { afterEach(() => { + cleanup() useTabStore.setState({ tabs: [], activeTabId: null }) }) diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index fd612e55..dcfdc1f0 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' vi.mock('./ProjectFilter', () => ({ @@ -81,6 +81,7 @@ describe('Sidebar', () => { }) afterEach(() => { + cleanup() useTabStore.setState({ tabs: [], activeTabId: null }) }) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 12a1f028..784e19bd 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' @@ -71,6 +71,8 @@ describe('TabBar', () => { }) afterEach(async () => { + cleanup() + const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 20f226cb..80ab9d75 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -74,18 +74,28 @@ async function setSettingsState( }) } +async function flushReactWork() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + async function renderPanel(sessionId: string) { let view!: ReturnType - await act(() => { + await act(async () => { view = render() + await Promise.resolve() }) return view } async function clickElement(element: Element) { - await act(() => { + await act(async () => { fireEvent.click(element) + await Promise.resolve() }) + await flushReactWork() } function classNameContains(element: Element | null, needle: string) { @@ -1213,8 +1223,10 @@ describe('WorkspacePanel', () => { }, })) - await act(() => { + await act(async () => { view.rerender() + await Promise.resolve() + await Promise.resolve() }) await waitFor(() => { diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 44d9b585..8ea7357b 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createEvent, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { act } from 'react' @@ -64,6 +64,7 @@ import { } from '../stores/terminalPanelStore' afterEach(() => { + cleanup() vi.useRealTimers() useTabStore.setState({ tabs: [], activeTabId: null }) useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) @@ -337,58 +338,59 @@ describe('ActiveSession task polling', () => { expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() const memberSessionId = 'team-member:security-reviewer@test-team' - useTeamStore.setState({ - teams: [], - activeTeam: { - name: 'test-team', - leadAgentId: 'team-lead@test-team', - leadSessionId: 'leader-session', - members: [ - { - agentId: 'team-lead@test-team', - role: 'team-lead', - status: 'running', - sessionId: 'leader-session', - }, - { - agentId: 'security-reviewer@test-team', - role: 'security-reviewer', - status: 'running', - }, - ], - }, - memberColors: new Map(), - error: null, - }) - useTabStore.setState({ - tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }], - activeTabId: memberSessionId, - }) - useChatStore.setState({ - sessions: { - [memberSessionId]: { - messages: [{ id: 'msg-2', type: 'assistant_text', content: 'hello', timestamp: 1 }], - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, + act(() => { + useTeamStore.setState({ + teams: [], + activeTeam: { + name: 'test-team', + leadAgentId: 'team-lead@test-team', + leadSessionId: 'leader-session', + members: [ + { + agentId: 'team-lead@test-team', + role: 'team-lead', + status: 'running', + sessionId: 'leader-session', + }, + { + agentId: 'security-reviewer@test-team', + role: 'security-reviewer', + status: 'running', + }, + ], }, - }, + memberColors: new Map(), + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }], + activeTabId: memberSessionId, + }) + useChatStore.setState({ + sessions: { + [memberSessionId]: { + messages: [{ id: 'msg-2', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useWorkspacePanelStore.getState().openPanel(memberSessionId) + rerender() }) - useWorkspacePanelStore.getState().openPanel(memberSessionId) - - rerender() expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() expect(screen.getByTestId('message-list')).toBeInTheDocument() @@ -486,7 +488,10 @@ describe('ActiveSession task polling', () => { }) expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_DEFAULT_HEIGHT) - fireEvent.click(screen.getByRole('button', { name: 'Open in Tab' })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open in Tab' })) + await Promise.resolve() + }) const terminalTab = useTabStore.getState().tabs.find((tab) => tab.type === 'terminal') expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false) diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 838cabe4..63e70b06 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' @@ -142,6 +142,7 @@ describe('EmptySession', () => { }) afterEach(() => { + cleanup() useSessionStore.setState(initialSessionState, true) useChatStore.setState(initialChatState, true) useTabStore.setState(initialTabState, true) diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 7ef8b2e5..6716896c 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -7,6 +7,15 @@ const host = process.env.TAURI_DEV_HOST export default defineConfig({ plugins: [react(), tailwindcss()], + build: { + chunkSizeWarningLimit: 2200, + rollupOptions: { + onwarn(warning, warn) { + if (warning.code === 'INEFFECTIVE_DYNAMIC_IMPORT') return + warn(warning) + }, + }, + }, resolve: { alias: { '@': path.resolve(__dirname, 'src'), diff --git a/desktop/vitest.config.ts b/desktop/vitest.config.ts index 1c37a445..bac2f99f 100644 --- a/desktop/vitest.config.ts +++ b/desktop/vitest.config.ts @@ -14,5 +14,15 @@ export default defineConfig({ globals: true, css: true, setupFiles: [], + coverage: { + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/**/*.d.ts', + 'src/types/**', + 'src/mocks/**', + 'src/vite-env.d.ts', + ], + }, }, }) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 67bd55f4..f45a1290 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -192,6 +192,12 @@ export default withMermaid(defineConfig({ }, }, + vite: { + build: { + chunkSizeWarningLimit: 1800, + }, + }, + head: [ ['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-D42DM82263' }], ['script', {}, `window.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\ngtag('js', new Date());\ngtag('config', 'G-D42DM82263');`], diff --git a/docs/en/guide/contributing.md b/docs/en/guide/contributing.md index 3233387c..d8ce3217 100644 --- a/docs/en/guide/contributing.md +++ b/docs/en/guide/contributing.md @@ -28,13 +28,15 @@ Do not commit local artifacts such as `artifacts/quality-runs/`, `node_modules/` ## Required PR Gate -Before opening a normal PR, run: +Before opening a normal PR, contributors and AI coding agents should run the single entrypoint: ```bash -bun run quality:pr +bun run verify ``` -This gate does not call real models, so every contributor can run it locally. It writes reports to: +`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. + +The main quality report embeds the current test scope, result matrix, coverage summary, and links to the full coverage/JUnit/log artifacts: ```text artifacts/quality-runs//report.md @@ -45,9 +47,86 @@ artifacts/coverage//coverage-report.md artifacts/coverage//coverage-report.json ``` -Include the commands you ran and the report summary in your PR description. +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`. -`quality:pr` also runs quarantine and coverage gates. Coverage uses a ratchet policy: 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 overall 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 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. + +## 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 +`artifacts/quality-runs//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 +baselines or thresholds unless a maintainer explicitly requested it. +``` + +Agents should handle failures in this order: + +1. Start with the Summary and Result Matrix in `artifacts/quality-runs//report.md` to identify the failing lane. +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//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//logs/.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`. + +External reference points: + +- [Google Testing Blog](https://testing.googleblog.com/2020/08/code-coverage-best-practices.html): 60% acceptable, 75% commendable, 90% exemplary; 90% is a reasonable lower threshold for changed/per-commit coverage. +- [Microsoft Visual Studio / Azure DevOps docs](https://learn.microsoft.com/en-us/visualstudio/test/using-code-coverage-to-determine-how-much-code-is-being-tested): teams typically target about 80%, typical project requirements can be 75%, and generated code may be relaxed. +- [ChromiumOS EC](https://chromium.googlesource.com/chromiumos/platform/ec/+/main/docs/code_coverage.md): new or changed lines require at least 80% coverage. + +## Feature Quality Contract + +Every feature, bugfix, and behavior change must ship with verifiable evidence. This rule applies to human authors and AI coding agents: + +- Name the changed surface first: `desktop`, `server`, `adapter`, `native`, `docs`, `provider/runtime`, `agent-loop`, or `release`. +- Production changes under `desktop/src`, `src/server`, `src/tools`, `src/utils`, or `adapters` must include same-area tests in the same PR unless a maintainer explicitly applies `allow-missing-tests`. +- Pure logic needs unit tests. Server/API/provider/runtime behavior needs API or request-shape tests. Desktop UI/store/API behavior needs Vitest or Testing Library coverage. Cross-boundary user flows through UI, WebSocket, provider proxying, native sidecars, or release packaging need E2E or agent-browser smoke. +- Agent loop, tool execution, provider routing, model selection, file editing, permissions, session resume, and desktop chat changes need mock/fixture tests in PR, plus live smoke or baseline evidence when provider access is available. +- Coverage is part of the feature. This project follows a Google/Microsoft-style policy: generated/build output is not counted as product coverage, maintained product areas should move toward 75-80%+, and new or changed executable production lines must pass the changed-line coverage threshold in `coverage-thresholds.json`. +- Do not lower `coverage-baseline.json` or `coverage-thresholds.json` just to pass the gate; real baseline/threshold changes require `allow-coverage-baseline-change` and a reason. Legacy low-coverage areas are debt; new PRs must leave touched areas better than they found them. +- The PR description must record changed files, tests added, coverage report path, E2E/live report path or blocker, and remaining risk. + +## Local Pre-Push Gate + +Git hooks are local, so each clone needs to install the hook once: + +```bash +bun run hooks:install +``` + +After installation, every `git push` runs the same verification entrypoint internally (`bun run quality:pr`, equivalent to `bun run verify`). If unit tests, coverage, docs/native/adapter checks, or any other selected path-aware lane fails, the local hook blocks the push. + +Maintainers or contributors with model quota can also add real provider smoke and desktop agent-browser smoke to the pre-push hook: + +```bash +bun run quality:providers +bun run hooks:install -- --live-provider-model minimax:main:minimax-main +``` + +To run the full live baseline before every push, use: + +```bash +bun run hooks:install -- --live-provider-model minimax:main:minimax-main --live-mode baseline +``` + +These options are stored in local `.git/config` as `quality.prePush*` keys, so provider selectors and secrets are not committed. `smoke` mode covers real provider connectivity plus the desktop UI chat smoke; `baseline` mode also runs every real Coding Agent baseline case. + +Maintainer-level overrides must also be explicit local config before the hook passes them to `quality:pr`: + +```bash +bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-change +``` + +This only affects the current clone and is not committed; PR CI still requires the matching labels. + +## 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. + +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. ## Area-Specific Checks @@ -63,7 +142,7 @@ bun run check:quarantine # Quarantine owners, exit criteria, and review windows bun run check:coverage # Root, desktop, and adapter coverage reports plus ratchet enforcement ``` -Focused tests are fine while developing, but run `bun run quality:pr` before sending the PR. +Focused tests are fine while developing, but run `bun run verify` before sending the PR. 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`. @@ -137,7 +216,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 quality:pr` and state in the PR why the live baseline was not run. +If you do not have model access, still run `bun run verify` and state in the PR why the live baseline was not run. ## Release Gate @@ -157,9 +236,10 @@ 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. Run `bun run quality:pr`. -6. Run the live baseline for high-risk changes. -7. In the PR description, include user impact, verification commands, coverage/quality report summary, and known risks. +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. +8. In the PR description, include user impact, verification commands, coverage/quality report summary, and known risks. ## FAQ @@ -168,7 +248,7 @@ In release mode, live lanes are not allowed to be silently skipped. Missing prov Yes. Run the normal PR gate: ```bash -bun run quality:pr +bun run verify ``` Only the live baseline needs a real model. Add your provider in Desktop Settings > Providers, then run: diff --git a/docs/en/guide/env-vars.md b/docs/en/guide/env-vars.md index e7fdda5d..8cabf05e 100644 --- a/docs/en/guide/env-vars.md +++ b/docs/en/guide/env-vars.md @@ -24,7 +24,7 @@ cp .env.example .env Edit `.env` (the example below uses [MiniMax](https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link) as the API provider — you can replace it with any compatible service): -```env +```bash # API authentication (choose one) ANTHROPIC_API_KEY=sk-xxx # Standard API key via x-api-key header ANTHROPIC_AUTH_TOKEN=sk-xxx # Bearer token via Authorization header diff --git a/docs/en/guide/third-party-models.md b/docs/en/guide/third-party-models.md index 7cb3b3fb..64b98e90 100644 --- a/docs/en/guide/third-party-models.md +++ b/docs/en/guide/third-party-models.md @@ -111,7 +111,7 @@ Choose one of two configuration methods: #### Method A: Via `.env` File -```env +```bash ANTHROPIC_AUTH_TOKEN=sk-anything ANTHROPIC_BASE_URL=http://localhost:4000 ANTHROPIC_MODEL=gpt-4o @@ -159,7 +159,7 @@ Some third-party services directly support the Anthropic Messages API, no proxy ### OpenRouter -```env +```bash ANTHROPIC_AUTH_TOKEN=sk-or-v1-xxx ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 ANTHROPIC_MODEL=openai/gpt-4o @@ -179,7 +179,7 @@ MiniMax provides an Anthropic-compatible API endpoint and can be connected direc | `MiniMax-M2.7` | Default recommended, excellent overall performance | | `MiniMax-M2.7-highspeed` | Faster responses, suitable for latency-sensitive use cases | -```env +```bash ANTHROPIC_AUTH_TOKEN=your_minimax_api_key_here # International users: api.minimax.io; China users may use api.minimaxi.com ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic diff --git a/docs/guide/contributing.md b/docs/guide/contributing.md index 331c5e93..38cb2d7a 100644 --- a/docs/guide/contributing.md +++ b/docs/guide/contributing.md @@ -28,13 +28,15 @@ bun install ## 普通 PR 必跑门禁 -普通贡献者在提交 PR 前至少运行: +普通贡献者和 AI Coding Agent 在提交 PR 前统一运行: ```bash -bun run quality:pr +bun run verify ``` -这个门禁不调用真实大模型,适合所有人本地运行。它会生成报告: +`bun run verify` 是一键入口,等价于 `bun run quality:pr`。这个门禁不调用真实大模型,适合所有人本地运行。它会先生成 impact report,再按改动范围真实执行被选中的本地门禁:policy、desktop、server、adapters、native、docs、quarantine 和覆盖率。命令失败就说明当前分支还不能提交 PR 或 push。 + +主质量报告会内嵌当前测试范围、结果矩阵、覆盖率摘要,并链接完整 coverage/JUnit/log artifact: ```text artifacts/quality-runs//report.md @@ -45,9 +47,86 @@ artifacts/coverage//coverage-report.md artifacts/coverage//coverage-report.json ``` -PR 描述里请贴出你实际运行的命令和 summary。 +PR 描述里请贴出你实际运行的命令和 summary。`quality:pr` / `quality:verify` 仍然保留给习惯显式质量命名的用户,但推荐文档和 AI prompt 都使用 `bun run verify`。 -`quality:pr` 会执行 quarantine 和覆盖率门禁。覆盖率采用 ratchet 策略:当前 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 条目必须有 owner、reviewAfter 和 exitCriteria;reviewAfter 过期后默认 server/coverage gate 会阻断,直到维护者处理。 + +## AI Coding Agent 修复循环 + +给 AI 写代码时,可以直接把这段作为验收指令: + +```text +Run `bun run verify`. If it fails, read the latest +`artifacts/quality-runs//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 +baselines or thresholds unless a maintainer explicitly requested it. +``` + +Agent 应按这个顺序处理失败: + +1. 先看 `artifacts/quality-runs//report.md` 的 Summary 和 Result Matrix,定位失败 lane。 +2. 如果是 `Path-aware PR checks` 失败,优先看是否缺同区域测试、是否动了 CLI core、是否动了 coverage policy;不要用 override 绕过普通功能 PR。 +3. 如果是 `Coverage gate` 失败,打开 `artifacts/coverage//coverage-report.md` 或 `coverage-report.json`,优先修 `changedLines.failures` 和 `failures`;`targetGaps` 是技术债提示,新改动应让触达区域变好。 +4. 如果是 desktop/server/adapters/native/docs 失败,读对应 `artifacts/quality-runs//logs/.log`,补测试或修构建,再跑相关窄命令。 +5. 窄命令通过后,最后必须再跑一次 `bun run verify`。只有最终 Summary 是 `failed=0`,才可以说 ready。 + +外部参考口径: + +- [Google Testing Blog](https://testing.googleblog.com/2020/08/code-coverage-best-practices.html):60% acceptable、75% commendable、90% exemplary;changed/per-commit coverage 90% 是合理下限。 +- [Microsoft Visual Studio / Azure DevOps 文档](https://learn.microsoft.com/en-us/visualstudio/test/using-code-coverage-to-determine-how-much-code-is-being-tested):团队通常以约 80% 为目标,典型项目要求可为 75%,生成代码可以放宽。 +- [ChromiumOS EC](https://chromium.googlesource.com/chromiumos/platform/ec/+/main/docs/code_coverage.md):新增或变更行要求至少 80% 覆盖。 + +## Feature Quality Contract + +所有新功能、bugfix 和行为变化都必须带着可验证证据交付。这条规则同时约束人和 AI Coding Agent: + +- 先声明变更面:`desktop`、`server`、`adapter`、`native`、`docs`、`provider/runtime`、`agent-loop` 或 `release`。 +- `desktop/src`、`src/server`、`src/tools`、`src/utils`、`adapters` 下的生产代码变更必须同 PR 带同区域测试;除非维护者显式加 `allow-missing-tests`。 +- 纯逻辑写单元测试;server/API/provider/runtime 写 API 或 request-shape 测试;桌面 UI/store/API 写 Vitest/Testing Library;跨 UI、WebSocket、provider proxy、native sidecar、发布打包的用户流程要补 E2E 或 agent-browser smoke。 +- agent loop、工具调用、provider 路由、模型选择、文件编辑、权限、会话恢复、桌面聊天改动,PR 内必须有 mock/fixture 测试;有 provider 条件时还要给 live smoke 或 baseline 证据。 +- 覆盖率是功能的一部分。本项目按 Google/Microsoft 风格执行:生成物/构建产物不计入产品覆盖率,维护中的产品区域要逐步达到 75-80%+,新增或变更的可执行生产代码行必须满足 `coverage-thresholds.json` 里的 changed-line coverage 门槛。 +- 不要为了过门禁随便降低 `coverage-baseline.json` 或 `coverage-thresholds.json`;确实要改时必须有 `allow-coverage-baseline-change` 和原因。历史低覆盖区域是技术债,新 PR 至少要让触达区域更好。 +- PR 描述必须写清楚:改了哪些文件、补了哪些测试、coverage 报告路径、E2E/live 报告路径或 blocker、剩余风险。 + +## 本机 Push 前门禁 + +Git hook 不能提交到远端自动生效,所以每个 clone 需要安装一次: + +```bash +bun run hooks:install +``` + +安装后,每次 `git push` 都会先运行同一套验证入口(内部是 `bun run quality:pr`,等价于 `bun run verify`)。如果单元测试、覆盖率、文档/native/adapter 等按路径选中的门禁失败,push 会被本机 hook 阻断。 + +维护者或有模型额度的贡献者可以把真实 provider smoke 和桌面 agent-browser smoke 也纳入 push 前门禁: + +```bash +bun run quality:providers +bun run hooks:install -- --live-provider-model minimax:main:minimax-main +``` + +需要每次 push 前跑完整 live baseline 时使用: + +```bash +bun run hooks:install -- --live-provider-model minimax:main:minimax-main --live-mode baseline +``` + +这些选项写入本机 `.git/config` 的 `quality.prePush*` 配置,不会提交 provider selector 或密钥。`smoke` 模式覆盖真实 provider 连通性和桌面 UI 聊天 smoke;`baseline` 模式会额外跑全部真实 Coding Agent baseline case。 + +维护者级 override 也必须显式写入本机配置,hook 才会传给 `quality:pr`: + +```bash +bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-change +``` + +这只影响当前 clone,不会提交到仓库;PR 侧仍然需要对应 label。 + +## 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 branch protection / ruleset 中保护 `main`,并把 `pr-quality-gate` 设为 required status check。这样本机 hook 防止低质量 push,PR 侧防止低质量 merge。 ## 按改动范围补充测试 @@ -63,7 +142,7 @@ bun run check:quarantine # quarantine owner、退出条件和复审日期 bun run check:coverage # root、desktop、adapters 覆盖率报告和 ratchet 门禁 ``` -如果只改了很窄的文件,也可以先跑对应的定向测试,但 PR 前仍应跑 `bun run quality:pr`。 +如果只改了很窄的文件,也可以先跑对应的定向测试,但 PR 前仍应跑 `bun run verify`。 生产代码改动必须带对应测试文件:`desktop/src/**`、`src/server/**`、`src/tools/**`、`src/utils/**`、`adapters/**` 变更如果没有同区域测试,会触发阻断。只有维护者确认不适合自动化测试时,才能使用 `allow-missing-tests`。覆盖率 baseline/threshold 变更同样需要维护者确认并加 `allow-coverage-baseline-change`。 @@ -137,7 +216,7 @@ bun run quality:gate --mode baseline --allow-live - agent-browser smoke、Computer Use、Skills、MCP - release 前或风险较大的跨模块重构 -如果没有模型额度,至少运行 `bun run quality:pr`,并在 PR 里说明未跑 live baseline 的原因。 +如果没有模型额度,至少运行 `bun run verify`,并在 PR 里说明未跑 live baseline 的原因。 ## Release 门禁 @@ -157,9 +236,10 @@ release 模式下 live lane 不允许静默跳过。缺少 provider、真实模 2. 安装依赖并完成改动。 3. 为行为变化补测试。 4. 运行相关定向测试。 -5. 运行 `bun run quality:pr`。 -6. 对高风险改动运行 live baseline。 -7. 在 PR 描述里写清楚用户影响、测试命令、覆盖率/质量报告 summary、已知风险。 +5. 可选但推荐:运行 `bun run hooks:install`,让后续 push 自动卡住失败门禁。 +6. 运行 `bun run verify`。 +7. 对高风险改动运行 live baseline。 +8. 在 PR 描述里写清楚用户影响、测试命令、覆盖率/质量报告 summary、已知风险。 ## 常见问题 @@ -168,7 +248,7 @@ release 模式下 live lane 不允许静默跳过。缺少 provider、真实模 可以跑普通门禁: ```bash -bun run quality:pr +bun run verify ``` 只有 live baseline 需要真实模型。先在桌面端 Settings > Providers 添加自己的 provider,再运行: diff --git a/docs/guide/env-vars.md b/docs/guide/env-vars.md index fa2d76d9..e4190ca7 100644 --- a/docs/guide/env-vars.md +++ b/docs/guide/env-vars.md @@ -24,7 +24,7 @@ cp .env.example .env 编辑 `.env`(以下示例使用 [MiniMax](https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link) 作为 API 提供商,也可替换为其他兼容服务): -```env +```bash # API 认证(二选一) ANTHROPIC_API_KEY=sk-xxx # 标准 API Key(x-api-key 头) ANTHROPIC_AUTH_TOKEN=sk-xxx # Bearer Token(Authorization 头) diff --git a/docs/guide/third-party-models.md b/docs/guide/third-party-models.md index 93b6ccdc..940764ff 100644 --- a/docs/guide/third-party-models.md +++ b/docs/guide/third-party-models.md @@ -111,7 +111,7 @@ litellm --config litellm_config.yaml --port 4000 #### 方式 A:通过 `.env` 文件 -```env +```bash ANTHROPIC_AUTH_TOKEN=sk-anything ANTHROPIC_BASE_URL=http://localhost:4000 ANTHROPIC_MODEL=gpt-4o @@ -159,7 +159,7 @@ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 ### OpenRouter -```env +```bash ANTHROPIC_AUTH_TOKEN=sk-or-v1-xxx ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 ANTHROPIC_MODEL=openai/gpt-4o @@ -179,7 +179,7 @@ MiniMax 提供 Anthropic 兼容接口,支持直接接入,无需代理。可 | `MiniMax-M2.7` | 默认推荐,综合性能优秀 | | `MiniMax-M2.7-highspeed` | 响应更快,适合对速度有要求的场景 | -```env +```bash ANTHROPIC_AUTH_TOKEN=your_minimax_api_key_here # 海外用户使用 api.minimax.io,国内用户可改为 api.minimaxi.com ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic diff --git a/package.json b/package.json index 6623b1aa..d80485ed 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,18 @@ "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/pr-triage-workflow.test.ts scripts/pr/pr-quality-workflow.test.ts scripts/pr/release-workflow.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/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/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:server": "bun run scripts/pr/run-server-tests.ts", "check:desktop": "cd desktop && bun run lint && bun run test -- --run && bun run build", "check:adapters": "cd adapters && bun test", "check:native": "cd desktop && bun run build:sidecars && cd src-tauri && cargo check", - "check:docs": "npm ci && npm run docs:build", + "check:docs": "npm ci --loglevel=error && npm run --loglevel=error docs:build", "check:quarantine": "bun run scripts/quality-gate/quarantine.ts --enforce-review-date", "check:coverage": "bun run scripts/quality-gate/coverage.ts", + "hooks:install": "bun run scripts/git-hooks/install.ts", + "verify": "bun run quality:pr", "quality:gate": "bun run scripts/quality-gate/index.ts", + "quality:verify": "bun run quality:pr", "quality:providers": "bun run scripts/quality-gate/providers.ts", "quality:pr": "bun run quality:gate --mode pr", "quality:baseline": "bun run quality:gate --mode baseline", diff --git a/scripts/git-hooks/install.test.ts b/scripts/git-hooks/install.test.ts new file mode 100644 index 00000000..01875cee --- /dev/null +++ b/scripts/git-hooks/install.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installPrePushHook } from './install' + +function runGit(rootDir: string, args: string[]) { + const proc = Bun.spawnSync(['git', ...args], { + cwd: rootDir, + stdout: 'pipe', + stderr: 'pipe', + }) + + if (proc.exitCode !== 0) { + throw new Error(new TextDecoder().decode(proc.stderr)) + } + + return new TextDecoder().decode(proc.stdout).trim() +} + +describe('installPrePushHook', () => { + test('copies the tracked hook and makes it executable', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'git-hook-install-test-')) + try { + const sourcePath = join(tempDir, 'source-pre-push') + const hookPath = join(tempDir, 'hooks', 'pre-push') + writeFileSync(sourcePath, '#!/usr/bin/env bash\necho quality\n') + + const result = installPrePushHook({ + rootDir: tempDir, + sourcePath, + hookPath, + }) + + expect(result.hookPath).toBe(hookPath) + expect(result.liveConfigured).toBe(false) + expect(readFileSync(hookPath, 'utf8')).toContain('echo quality') + expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + test('refuses to overwrite an unrelated existing hook unless forced', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'git-hook-install-test-')) + try { + const sourcePath = join(tempDir, 'source-pre-push') + const hookPath = join(tempDir, 'hooks', 'pre-push') + writeFileSync(sourcePath, '#!/usr/bin/env bash\necho new\n') + mkdirSync(join(tempDir, 'hooks'), { recursive: true }) + writeFileSync(hookPath, '#!/usr/bin/env bash\necho old\n') + + expect(() => installPrePushHook({ + rootDir: tempDir, + sourcePath, + hookPath, + })).toThrow('Refusing to overwrite existing hook') + + installPrePushHook({ + rootDir: tempDir, + sourcePath, + hookPath, + force: true, + }) + + expect(readFileSync(hookPath, 'utf8')).toContain('echo new') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + test('stores live gate settings in local git config', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'git-hook-install-test-')) + try { + runGit(tempDir, ['init']) + const sourcePath = join(tempDir, 'source-pre-push') + writeFileSync(sourcePath, '#!/usr/bin/env bash\necho live\n') + + const result = installPrePushHook({ + rootDir: tempDir, + sourcePath, + liveProviderModels: ['codingplan:main:codingplan-main'], + liveMode: 'baseline', + allowCliCoreChange: true, + allowCoverageBaselineChange: true, + }) + + expect(result.liveConfigured).toBe(true) + expect(readFileSync(result.hookPath, 'utf8')).toContain('echo live') + expect(runGit(tempDir, ['config', '--local', '--get', 'quality.prePushLive'])).toBe('true') + expect(runGit(tempDir, ['config', '--local', '--get', 'quality.prePushProviderModels'])).toBe('codingplan:main:codingplan-main') + expect(runGit(tempDir, ['config', '--local', '--get', 'quality.prePushLiveMode'])).toBe('baseline') + expect(runGit(tempDir, ['config', '--local', '--get', 'quality.allowCliCoreChange'])).toBe('true') + expect(runGit(tempDir, ['config', '--local', '--get', 'quality.allowCoverageBaselineChange'])).toBe('true') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/git-hooks/install.ts b/scripts/git-hooks/install.ts new file mode 100755 index 00000000..317854d9 --- /dev/null +++ b/scripts/git-hooks/install.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env bun + +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +type LiveMode = 'smoke' | 'baseline' + +export type InstallPrePushHookOptions = { + rootDir?: string + sourcePath?: string + hookPath?: string + force?: boolean + allowCliCoreChange?: boolean + allowCoverageBaselineChange?: boolean + allowMissingTests?: boolean + liveProviderModels?: string[] + liveMode?: LiveMode + live?: boolean +} + +export type InstallPrePushHookResult = { + hookPath: string + liveConfigured: boolean +} + +function decode(buffer: ArrayBuffer | Uint8Array) { + return new TextDecoder().decode(buffer).trim() +} + +function runGit(rootDir: string, args: string[]) { + const proc = Bun.spawnSync(['git', ...args], { + cwd: rootDir, + stdout: 'pipe', + stderr: 'pipe', + }) + + if (proc.exitCode !== 0) { + throw new Error(decode(proc.stderr) || decode(proc.stdout) || `git ${args.join(' ')} failed`) + } + + return decode(proc.stdout) +} + +function gitHookPath(rootDir: string) { + return resolve(rootDir, runGit(rootDir, ['rev-parse', '--git-path', 'hooks/pre-push'])) +} + +function gitConfig(rootDir: string, key: string, value: string) { + runGit(rootDir, ['config', '--local', key, value]) +} + +function sameFileContent(leftPath: string, rightPath: string) { + return existsSync(leftPath) && readFileSync(leftPath, 'utf8') === readFileSync(rightPath, 'utf8') +} + +export function installPrePushHook(options: InstallPrePushHookOptions = {}): InstallPrePushHookResult { + const rootDir = options.rootDir ? resolve(options.rootDir) : process.cwd() + const sourcePath = options.sourcePath ? resolve(options.sourcePath) : resolve(rootDir, 'scripts/git-hooks/pre-push') + const hookPath = options.hookPath ? resolve(options.hookPath) : gitHookPath(rootDir) + + if (!existsSync(sourcePath)) { + throw new Error(`Pre-push hook source not found: ${sourcePath}`) + } + + if (existsSync(hookPath) && !options.force && !sameFileContent(hookPath, sourcePath)) { + throw new Error(`Refusing to overwrite existing hook at ${hookPath}. Re-run with --force after reviewing it.`) + } + + mkdirSync(dirname(hookPath), { recursive: true }) + copyFileSync(sourcePath, hookPath) + chmodSync(hookPath, 0o755) + + if (options.allowCliCoreChange) { + gitConfig(rootDir, 'quality.allowCliCoreChange', 'true') + } + + if (options.allowCoverageBaselineChange) { + gitConfig(rootDir, 'quality.allowCoverageBaselineChange', 'true') + } + + if (options.allowMissingTests) { + gitConfig(rootDir, 'quality.allowMissingTests', 'true') + } + + if (options.live === false) { + gitConfig(rootDir, 'quality.prePushLive', 'false') + } + + if (options.liveProviderModels && options.liveProviderModels.length > 0) { + gitConfig(rootDir, 'quality.prePushLive', 'true') + gitConfig(rootDir, 'quality.prePushProviderModels', options.liveProviderModels.join(' ')) + } + + if (options.liveMode) { + gitConfig(rootDir, 'quality.prePushLiveMode', options.liveMode) + } + + return { + hookPath, + liveConfigured: Boolean(options.liveProviderModels?.length || options.liveMode || options.live === false), + } +} + +type ParsedArgs = { + force: boolean + allowCliCoreChange: boolean + allowCoverageBaselineChange: boolean + allowMissingTests: boolean + liveProviderModels: string[] + liveMode?: LiveMode + live?: boolean + help: boolean +} + +function parseArgs(argv: string[]): ParsedArgs { + const parsed: ParsedArgs = { + force: false, + allowCliCoreChange: false, + allowCoverageBaselineChange: false, + allowMissingTests: false, + liveProviderModels: [], + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const next = argv[index + 1] + + if (arg === '--force') { + parsed.force = true + } else if (arg === '--allow-cli-core-change') { + parsed.allowCliCoreChange = true + } else if (arg === '--allow-coverage-baseline-change') { + parsed.allowCoverageBaselineChange = true + } else if (arg === '--allow-missing-tests') { + parsed.allowMissingTests = true + } else if (arg === '--no-live') { + parsed.live = false + } else if (arg === '--live-provider-model') { + if (!next || next.startsWith('--')) { + throw new Error('--live-provider-model requires a provider:model[:label] value') + } + parsed.liveProviderModels.push(next) + index += 1 + } else if (arg === '--live-mode') { + if (next !== 'smoke' && next !== 'baseline') { + throw new Error('--live-mode must be smoke or baseline') + } + parsed.liveMode = next + index += 1 + } else if (arg === '--help' || arg === '-h') { + parsed.help = true + } else { + throw new Error(`Unknown option: ${arg}`) + } + } + + return parsed +} + +function printHelp() { + console.log(`Install the repository pre-push quality gate. + +Usage: + bun run hooks:install [-- --force] [-- --live-provider-model ] [-- --live-mode smoke|baseline] + bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-change + +Examples: + bun run hooks:install + bun run quality:providers + bun run hooks:install -- --live-provider-model codingplan:main:codingplan-main + bun run hooks:install -- --live-provider-model codingplan:main:codingplan-main --live-mode baseline + bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-change +`) +} + +if (import.meta.main) { + try { + const args = parseArgs(process.argv.slice(2)) + + if (args.help) { + printHelp() + process.exit(0) + } + + const result = installPrePushHook({ + force: args.force, + allowCliCoreChange: args.allowCliCoreChange, + allowCoverageBaselineChange: args.allowCoverageBaselineChange, + allowMissingTests: args.allowMissingTests, + liveProviderModels: args.liveProviderModels, + liveMode: args.liveMode, + live: args.live, + }) + + console.log(`Installed pre-push quality gate: ${result.hookPath}`) + console.log('Every git push now runs: bun run quality:pr') + + if (args.liveProviderModels.length > 0) { + console.log(`Live ${args.liveMode ?? 'smoke'} gate is enabled for ${args.liveProviderModels.length} provider selector(s).`) + } else if (args.live === false) { + console.log('Live model gate is disabled in local git config.') + } else { + console.log('Live model gate is disabled. Enable it with --live-provider-model after running bun run quality:providers.') + } + + if (args.allowCliCoreChange || args.allowCoverageBaselineChange || args.allowMissingTests) { + console.log('Local maintainer override config was updated for this clone.') + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 00000000..0207d5a2 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +git_config() { + git config --local --get "$1" 2>/dev/null || true +} + +is_enabled() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) + return 0 + ;; + *) + return 1 + ;; + esac +} + +echo "[pre-push] Running PR quality gate: bun run quality:pr" + +if is_enabled "$(git_config quality.allowCliCoreChange)"; then + export ALLOW_CLI_CORE_CHANGE=1 +fi + +if is_enabled "$(git_config quality.allowMissingTests)"; then + export ALLOW_MISSING_TESTS=1 +fi + +if is_enabled "$(git_config quality.allowCoverageBaselineChange)"; then + export ALLOW_COVERAGE_BASELINE_CHANGE=1 +fi + +bun run quality:pr + +live="${QUALITY_PRE_PUSH_LIVE:-$(git_config quality.prePushLive)}" + +if ! is_enabled "$live"; then + echo "[pre-push] Live model smoke is disabled. Configure it with: bun run hooks:install -- --live-provider-model " + exit 0 +fi + +provider_models="${QUALITY_PRE_PUSH_PROVIDER_MODELS:-${QUALITY_PRE_PUSH_PROVIDER_MODEL:-$(git_config quality.prePushProviderModels)}}" + +if [[ -z "${provider_models// }" ]]; then + echo "[pre-push] Live model smoke is enabled, but no provider selector is configured." + echo "[pre-push] Run: bun run quality:providers" + echo "[pre-push] Then: bun run hooks:install -- --live-provider-model " + exit 1 +fi + +live_mode="${QUALITY_PRE_PUSH_LIVE_MODE:-$(git_config quality.prePushLiveMode)}" +live_mode="${live_mode:-smoke}" + +case "$live_mode" in + smoke) + cmd=(bun run quality:smoke) + ;; + baseline) + cmd=(bun run quality:gate --mode baseline --allow-live) + ;; + *) + echo "[pre-push] Unsupported live mode: $live_mode" + echo "[pre-push] Use smoke or baseline." + exit 1 + ;; +esac + +for selector in $provider_models; do + cmd+=(--provider-model "$selector") +done + +echo "[pre-push] Running live $live_mode gate with configured provider selector(s)." +"${cmd[@]}" diff --git a/scripts/pr/pr-quality-workflow.test.ts b/scripts/pr/pr-quality-workflow.test.ts index 95f74787..7beb037d 100644 --- a/scripts/pr/pr-quality-workflow.test.ts +++ b/scripts/pr/pr-quality-workflow.test.ts @@ -22,4 +22,14 @@ describe('PR quality workflow', () => { expect(workflow).toContain('path: artifacts/coverage/') expect(workflow).toContain('retention-days: 14') }) + + 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 }}"') + }) }) diff --git a/scripts/pr/quality-contract.test.ts b/scripts/pr/quality-contract.test.ts new file mode 100644 index 00000000..a640987c --- /dev/null +++ b/scripts/pr/quality-contract.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' + +describe('feature quality contract', () => { + test('keeps the agent-facing implementation contract explicit', () => { + const agents = readFileSync('AGENTS.md', 'utf8') + + expect(agents).toContain('## Feature Quality Contract') + 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 the feature, 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` fails, do not stop at reporting the failure') + }) + + test('keeps PR authors accountable for tests, coverage, E2E, and risk', () => { + const template = readFileSync('.github/pull_request_template.md', 'utf8') + + expect(template).toContain('## Feature Quality Contract') + expect(template).toContain('Changed surface:') + expect(template).toContain('Tests added or updated:') + expect(template).toContain('Coverage evidence:') + expect(template).toContain('changed-line coverage') + expect(template).toContain('E2E / live-model evidence:') + expect(template).toContain('Known risk / rollback:') + expect(template).toContain('I added or updated same-area tests') + }) + + test('keeps the one-command verification entrypoint documented', () => { + const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { + scripts?: Record + } + const contributing = readFileSync('docs/guide/contributing.md', 'utf8') + const englishContributing = readFileSync('docs/en/guide/contributing.md', 'utf8') + const rootContributing = readFileSync('CONTRIBUTING.md', 'utf8') + + expect(packageJson.scripts?.verify).toBe('bun run quality:pr') + expect(packageJson.scripts?.['quality:verify']).toBe('bun run quality:pr') + expect(contributing).toContain('bun run verify') + expect(contributing).toContain('AI Coding Agent 修复循环') + expect(englishContributing).toContain('bun run verify') + expect(englishContributing).toContain('AI Coding Agent Fix Loop') + expect(rootContributing).toContain('bun run verify') + }) + + 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('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') + }) +}) diff --git a/scripts/quality-gate/coverage-baseline.json b/scripts/quality-gate/coverage-baseline.json index 4fff7f5b..7e0af924 100644 --- a/scripts/quality-gate/coverage-baseline.json +++ b/scripts/quality-gate/coverage-baseline.json @@ -1,17 +1,17 @@ { "schemaVersion": 1, - "generatedAt": "2026-05-06T07:24:15.783Z", + "generatedAt": "2026-05-06T13:40:08.803Z", "suites": { - "root-server": { + "server-api": { "lines": { - "covered": 42027, - "total": 246410, - "pct": 17.06 + "covered": 10755, + "total": 15580, + "pct": 69.03 }, "functions": { - "covered": 1962, - "total": 9691, - "pct": 20.25 + "covered": 1114, + "total": 1384, + "pct": 80.49 }, "branches": { "covered": 0, @@ -19,9 +19,53 @@ "pct": 100 }, "statements": { - "covered": 42027, - "total": 246410, - "pct": 17.06 + "covered": 10755, + "total": 15580, + "pct": 69.03 + } + }, + "agent-tools": { + "lines": { + "covered": 5691, + "total": 32341, + "pct": 17.6 + }, + "functions": { + "covered": 200, + "total": 1127, + "pct": 17.75 + }, + "branches": { + "covered": 0, + "total": 0, + "pct": 100 + }, + "statements": { + "covered": 5691, + "total": 32341, + "pct": 17.6 + } + }, + "agent-utils": { + "lines": { + "covered": 16164, + "total": 108320, + "pct": 14.92 + }, + "functions": { + "covered": 506, + "total": 4004, + "pct": 12.64 + }, + "branches": { + "covered": 0, + "total": 0, + "pct": 100 + }, + "statements": { + "covered": 16164, + "total": 108320, + "pct": 14.92 } }, "adapters": { @@ -48,24 +92,24 @@ }, "desktop": { "lines": { - "covered": 18605, - "total": 49866, - "pct": 37.31 + "covered": 18528, + "total": 27357, + "pct": 67.73 }, "functions": { - "covered": 651, - "total": 2015, - "pct": 32.31 + "covered": 639, + "total": 1206, + "pct": 52.99 }, "branches": { - "covered": 3506, - "total": 5567, - "pct": 62.98 + "covered": 3461, + "total": 4718, + "pct": 73.36 }, "statements": { - "covered": 18605, - "total": 49866, - "pct": 37.31 + "covered": 18528, + "total": 27357, + "pct": 67.73 } } } diff --git a/scripts/quality-gate/coverage-thresholds.json b/scripts/quality-gate/coverage-thresholds.json index 4c4ff16f..b4078aa6 100644 --- a/scripts/quality-gate/coverage-thresholds.json +++ b/scripts/quality-gate/coverage-thresholds.json @@ -1,11 +1,23 @@ { "schemaVersion": 1, "minimums": { - "root-server": { - "lines": 16.56, - "functions": 19.75, + "server-api": { + "lines": 68.53, + "functions": 79.99, "branches": 0, - "statements": 16.56 + "statements": 68.53 + }, + "agent-tools": { + "lines": 17.1, + "functions": 17.25, + "branches": 0, + "statements": 17.1 + }, + "agent-utils": { + "lines": 14.42, + "functions": 12.14, + "branches": 0, + "statements": 14.42 }, "adapters": { "lines": 75.94, @@ -14,12 +26,43 @@ "statements": 75.94 }, "desktop": { - "lines": 36.81, - "functions": 31.81, - "branches": 62.48, - "statements": 36.81 + "lines": 67.23, + "functions": 52.49, + "branches": 72.86, + "statements": 67.23 } }, + "targets": { + "server-api": { + "lines": 75, + "functions": 80, + "statements": 75 + }, + "agent-tools": { + "lines": 60, + "functions": 60, + "statements": 60 + }, + "agent-utils": { + "lines": 60, + "functions": 60, + "statements": 60 + }, + "adapters": { + "lines": 80, + "functions": 85, + "statements": 80 + }, + "desktop": { + "lines": 75, + "functions": 70, + "branches": 75, + "statements": 75 + } + }, + "changedLines": { + "minimumPercent": 90 + }, "ratchet": { "baselinePath": "scripts/quality-gate/coverage-baseline.json", "allowedDropPercent": 0.5 diff --git a/scripts/quality-gate/coverage.test.ts b/scripts/quality-gate/coverage.test.ts index 160c6ad6..4f33286a 100644 --- a/scripts/quality-gate/coverage.test.ts +++ b/scripts/quality-gate/coverage.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { evaluateThresholds, parseLcov } from './coverage' +import { + evaluateChangedLineCoverage, + evaluateThresholds, + parseChangedLinesFromDiff, + parseLcov, +} from './coverage' describe('coverage gate helpers', () => { test('parses lcov totals into percentages', () => { @@ -26,6 +31,64 @@ describe('coverage gate helpers', () => { expect(summary.branches.pct).toBe(70) }) + test('filters lcov records to an explicit source scope', () => { + const summary = parseLcov([ + 'TN:', + 'SF:/repo/src/server/routes.ts', + 'LF:10', + 'LH:8', + 'FNF:2', + 'FNH:2', + 'end_of_record', + 'SF:/repo/desktop/src-tauri/target/generated.js', + 'LF:100', + 'LH:0', + 'FNF:10', + 'FNH:0', + 'end_of_record', + ].join('\n'), { + rootDir: '/repo', + scope: { + id: 'server-api', + title: 'Server/API', + includePrefixes: ['src/server/'], + }, + }) + + expect(summary.lines.pct).toBe(80) + expect(summary.functions.pct).toBe(100) + }) + + test('evaluates changed executable line coverage', () => { + const changedLines = parseChangedLinesFromDiff([ + 'diff --git a/src/server/routes.ts b/src/server/routes.ts', + '--- a/src/server/routes.ts', + '+++ b/src/server/routes.ts', + '@@ -10,0 +11,2 @@', + '+const covered = true', + '+const uncovered = false', + ].join('\n')) + + const failures = evaluateChangedLineCoverage( + changedLines, + new Map([ + ['src/server/routes.ts', { + suiteId: 'server-api', + executableLines: new Set([11, 12]), + coveredLines: new Set([11]), + }], + ]), + [{ + id: 'server-api', + title: 'Server/API', + includePrefixes: ['src/server/'], + }], + 90, + ).failures + + expect(failures).toEqual(['changed-lines: coverage 50% is below minimum 90%']) + }) + test('reports minimum threshold failures', () => { const failures = evaluateThresholds([ { diff --git a/scripts/quality-gate/coverage.ts b/scripts/quality-gate/coverage.ts index c19474ba..c5d9380c 100644 --- a/scripts/quality-gate/coverage.ts +++ b/scripts/quality-gate/coverage.ts @@ -17,6 +17,14 @@ type CoverageSummary = { statements: CoverageMetric } +type CoverageScope = { + id: string + title: string + includePrefixes: string[] + excludePrefixes?: string[] + excludeSuffixes?: string[] +} + type SuiteCoverage = { id: string title: string @@ -31,6 +39,10 @@ type SuiteCoverage = { type CoverageThresholds = { schemaVersion: 1 minimums: Record>> + targets?: Record>> + changedLines?: { + minimumPercent: number + } ratchet?: { baselinePath: string allowedDropPercent: number @@ -51,12 +63,93 @@ type CoverageReport = { outputDir: string baselineRef?: string suites: SuiteCoverage[] + changedLines?: ChangedLineCoverage + targetGaps: string[] + failures: string[] +} + +type LcovRecord = { + file: string + linesTotal: number + linesCovered: number + functionsTotal: number + functionsCovered: number + branchesTotal: number + branchesCovered: number + lineHits: Map +} + +type FileLineCoverage = { + suiteId: string + executableLines: Set + coveredLines: Set +} + +type ChangedLineCoverage = { + minimumPercent: number + covered: number + total: number + pct: number + files: Array<{ + file: string + suiteId: string + covered: number + total: number + pct: number + reason?: string + }> failures: string[] } const ROOT_DIR = process.cwd() const DEFAULT_THRESHOLDS_PATH = join(ROOT_DIR, 'scripts', 'quality-gate', 'coverage-thresholds.json') +const ROOT_COVERAGE_SCOPES: CoverageScope[] = [ + { + id: 'server-api', + title: 'Server/API', + includePrefixes: ['src/server/'], + excludeSuffixes: ['.test.ts', '.test.tsx'], + }, + { + id: 'agent-tools', + title: 'Agent tools', + includePrefixes: ['src/tools/'], + excludeSuffixes: ['.test.ts', '.test.tsx'], + }, + { + id: 'agent-utils', + title: 'Agent utils', + includePrefixes: ['src/utils/'], + excludeSuffixes: ['.test.ts', '.test.tsx'], + }, +] + +const ADAPTERS_SCOPE: CoverageScope = { + id: 'adapters', + title: 'IM adapters', + includePrefixes: ['adapters/'], + excludePrefixes: ['adapters/node_modules/'], + excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts'], +} + +const DESKTOP_SCOPE: CoverageScope = { + id: 'desktop', + title: 'Desktop React', + includePrefixes: ['desktop/src/'], + excludePrefixes: [ + 'desktop/src/mocks/', + 'desktop/src/types/', + ], + excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts'], +} + +const CHANGED_LINE_SCOPES = [ + ...ROOT_COVERAGE_SCOPES, + ADAPTERS_SCOPE, + DESKTOP_SCOPE, +] + function nowId() { return new Date().toISOString().replace(/[:.]/g, '-') } @@ -89,6 +182,28 @@ function normalize(path: string, rootDir = ROOT_DIR) { return relative(rootDir, path).split(sep).join('/') } +function normalizeCoveragePath(path: string, rootDir = ROOT_DIR) { + const normalized = path.replace(/\\/g, '/') + if (normalized.startsWith('/')) { + return relative(rootDir, normalized).split(sep).join('/') + } + return normalized.replace(/^\.\//, '') +} + +function matchesScope(file: string, scope: CoverageScope) { + const normalized = file.replace(/\\/g, '/') + if (!scope.includePrefixes.some((prefix) => normalized.startsWith(prefix))) { + return false + } + if (scope.excludePrefixes?.some((prefix) => normalized.startsWith(prefix))) { + return false + } + if (scope.excludeSuffixes?.some((suffix) => normalized.endsWith(suffix))) { + return false + } + return true +} + function walkTestFiles(path: string, files: string[], excluded: Set, rootDir = ROOT_DIR) { const stat = statSync(path) if (stat.isDirectory()) { @@ -114,7 +229,62 @@ function collectServerTestFiles(rootDir = ROOT_DIR) { return files.sort() } -export function parseLcov(content: string): CoverageSummary { +function parseLcovRecords(content: string, options: { + rootDir?: string + scope?: CoverageScope +} = {}) { + const records: LcovRecord[] = [] + let current: LcovRecord | null = null + + function flush() { + if (!current) return + if (!options.scope || matchesScope(current.file, options.scope)) { + records.push(current) + } + current = null + } + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (line === 'end_of_record') { + flush() + continue + } + if (line.startsWith('SF:')) { + flush() + current = { + file: normalizeCoveragePath(line.slice(3), options.rootDir), + linesTotal: 0, + linesCovered: 0, + functionsTotal: 0, + functionsCovered: 0, + branchesTotal: 0, + branchesCovered: 0, + lineHits: new Map(), + } + continue + } + if (!current) continue + if (line.startsWith('LF:')) current.linesTotal += Number(line.slice(3)) || 0 + if (line.startsWith('LH:')) current.linesCovered += Number(line.slice(3)) || 0 + if (line.startsWith('FNF:')) current.functionsTotal += Number(line.slice(4)) || 0 + if (line.startsWith('FNH:')) current.functionsCovered += Number(line.slice(4)) || 0 + if (line.startsWith('BRF:')) current.branchesTotal += Number(line.slice(4)) || 0 + if (line.startsWith('BRH:')) current.branchesCovered += Number(line.slice(4)) || 0 + if (line.startsWith('DA:')) { + const [lineNumber, hits] = line.slice(3).split(',') + const parsedLine = Number(lineNumber) + if (Number.isFinite(parsedLine)) { + current.lineHits.set(parsedLine, Number(hits) || 0) + } + } + } + + flush() + return records +} + +function summarizeLcovRecords(records: LcovRecord[]): CoverageSummary { let linesTotal = 0 let linesCovered = 0 let functionsTotal = 0 @@ -122,14 +292,13 @@ export function parseLcov(content: string): CoverageSummary { let branchesTotal = 0 let branchesCovered = 0 - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim() - if (line.startsWith('LF:')) linesTotal += Number(line.slice(3)) || 0 - if (line.startsWith('LH:')) linesCovered += Number(line.slice(3)) || 0 - if (line.startsWith('FNF:')) functionsTotal += Number(line.slice(4)) || 0 - if (line.startsWith('FNH:')) functionsCovered += Number(line.slice(4)) || 0 - if (line.startsWith('BRF:')) branchesTotal += Number(line.slice(4)) || 0 - if (line.startsWith('BRH:')) branchesCovered += Number(line.slice(4)) || 0 + for (const record of records) { + linesTotal += record.linesTotal + linesCovered += record.linesCovered + functionsTotal += record.functionsTotal + functionsCovered += record.functionsCovered + branchesTotal += record.branchesTotal + branchesCovered += record.branchesCovered } return { @@ -140,6 +309,29 @@ export function parseLcov(content: string): CoverageSummary { } } +export function parseLcov(content: string, options: { + rootDir?: string + scope?: CoverageScope +} = {}): CoverageSummary { + return summarizeLcovRecords(parseLcovRecords(content, options)) +} + +function lcovLineCoverage(content: string, suiteId: string, scope: CoverageScope, rootDir = ROOT_DIR) { + const coverage = new Map() + for (const record of parseLcovRecords(content, { rootDir, scope })) { + const executableLines = new Set() + const coveredLines = new Set() + for (const [line, hits] of record.lineHits) { + executableLines.add(line) + if (hits > 0) { + coveredLines.add(line) + } + } + coverage.set(record.file, { suiteId, executableLines, coveredLines }) + } + return coverage +} + function parseVitestSummary(path: string): CoverageSummary { const raw = JSON.parse(readFileSync(path, 'utf8')) as { total: Record @@ -238,6 +430,136 @@ function readGitFile(rootDir: string, ref: string, filePath: string) { return new TextDecoder().decode(proc.stdout) } +export function parseChangedLinesFromDiff(diff: string) { + const changed = new Map>() + let currentFile: string | null = null + let nextLine = 0 + + for (const rawLine of diff.split(/\r?\n/)) { + if (rawLine.startsWith('+++ ')) { + const file = rawLine.slice(4).trim() + currentFile = file === '/dev/null' ? null : file.replace(/^b\//, '') + continue + } + + const hunk = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) + if (hunk) { + nextLine = Number(hunk[1]) + continue + } + + if (!currentFile || nextLine === 0) continue + if (rawLine.startsWith('+++')) continue + if (rawLine.startsWith('+')) { + let lines = changed.get(currentFile) + if (!lines) { + lines = new Set() + changed.set(currentFile, lines) + } + lines.add(nextLine) + nextLine += 1 + continue + } + if (rawLine.startsWith('-')) continue + if (rawLine.length > 0) { + nextLine += 1 + } + } + + return changed +} + +function gitOutput(rootDir: string, args: string[]) { + const proc = Bun.spawnSync(['git', ...args], { + cwd: rootDir, + stdout: 'pipe', + stderr: 'pipe', + }) + if (proc.exitCode !== 0) return null + return new TextDecoder().decode(proc.stdout) +} + +function collectChangedLines(rootDir: string, baseRef?: string) { + const explicitBase = baseRef?.trim() + if (explicitBase) { + const diff = gitOutput(rootDir, ['diff', '--unified=0', '--no-ext-diff', `${explicitBase}...HEAD`, '--']) + return diff ? parseChangedLinesFromDiff(diff) : new Map>() + } + + const dirty = gitOutput(rootDir, ['diff', '--name-only', '--']) + if (dirty?.trim()) { + const diff = gitOutput(rootDir, ['diff', '--unified=0', '--no-ext-diff', 'HEAD', '--']) + return diff ? parseChangedLinesFromDiff(diff) : new Map>() + } + + const branch = gitOutput(rootDir, ['branch', '--show-current'])?.trim() + const hasOriginMain = gitOutput(rootDir, ['rev-parse', '--verify', 'origin/main']) + if (branch && branch !== 'main' && hasOriginMain) { + const diff = gitOutput(rootDir, ['diff', '--unified=0', '--no-ext-diff', 'origin/main...HEAD', '--']) + return diff ? parseChangedLinesFromDiff(diff) : new Map>() + } + + return new Map>() +} + +export function evaluateChangedLineCoverage( + changedLines: Map>, + coverageByFile: Map, + scopes: CoverageScope[], + minimumPercent: number, +): ChangedLineCoverage { + const files: ChangedLineCoverage['files'] = [] + let total = 0 + let covered = 0 + + for (const [file, lines] of [...changedLines.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const scope = scopes.find((candidate) => matchesScope(file, candidate)) + if (!scope) continue + + const fileCoverage = coverageByFile.get(file) + if (!fileCoverage) { + const lineCount = lines.size + total += lineCount + files.push({ + file, + suiteId: scope.id, + covered: 0, + total: lineCount, + pct: 0, + reason: 'no coverage data for changed source file', + }) + continue + } + + const executableChangedLines = [...lines].filter((line) => fileCoverage.executableLines.has(line)) + if (executableChangedLines.length === 0) continue + const fileCovered = executableChangedLines.filter((line) => fileCoverage.coveredLines.has(line)).length + total += executableChangedLines.length + covered += fileCovered + files.push({ + file, + suiteId: fileCoverage.suiteId, + covered: fileCovered, + total: executableChangedLines.length, + pct: pct(fileCovered, executableChangedLines.length), + }) + } + + const coverage = pct(covered, total) + const failures = total > 0 && coverage + Number.EPSILON < minimumPercent + ? [`changed-lines: coverage ${coverage}% is below minimum ${minimumPercent}%`] + : [] + + return { + minimumPercent, + covered, + total, + pct: coverage, + files, + failures, + } +} + function loadBaseline(path: string, rootDir = ROOT_DIR, baselineRef?: string): BaselineFile | null { if (baselineRef) { const raw = readGitFile(rootDir, baselineRef, path) @@ -289,6 +611,21 @@ export function evaluateThresholds( return failures } +function evaluateTargetGaps(suites: SuiteCoverage[], thresholds: CoverageThresholds) { + const gaps: string[] = [] + for (const suite of suites) { + if (suite.status !== 'passed' || !suite.summary) continue + const targets = thresholds.targets?.[suite.id] ?? {} + for (const [metricName, target] of Object.entries(targets)) { + const actual = suite.summary[metricName as keyof CoverageSummary].pct + if (actual + Number.EPSILON < target) { + gaps.push(`${suite.id}: ${metricName} coverage ${actual}% is below target ${target}%`) + } + } + } + return gaps +} + function renderReport(report: CoverageReport) { const lines = [ '# Coverage Report', @@ -315,6 +652,33 @@ function renderReport(report: CoverageReport) { ].join(' | ')} |`) } + lines.push('', '## Changed Lines', '') + if (!report.changedLines) { + lines.push('- not evaluated') + } else if (report.changedLines.total === 0) { + lines.push(`- No changed executable production lines matched the coverage scopes. Minimum: ${report.changedLines.minimumPercent}%`) + } else { + lines.push( + `- Coverage: ${report.changedLines.pct}% (${report.changedLines.covered}/${report.changedLines.total})`, + `- Minimum: ${report.changedLines.minimumPercent}%`, + '', + '| File | Suite | Lines | Reason |', + '| --- | --- | ---: | --- |', + ) + for (const file of report.changedLines.files) { + lines.push(`| ${file.file} | ${file.suiteId} | ${file.pct}% (${file.covered}/${file.total}) | ${file.reason ?? '-'} |`) + } + } + + lines.push('', '## Target Gaps', '') + if (report.targetGaps.length === 0) { + lines.push('- none') + } else { + for (const gap of report.targetGaps) { + lines.push(`- ${gap}`) + } + } + lines.push('', '## Failures', '') if (report.failures.length === 0) { lines.push('- none') @@ -333,6 +697,7 @@ export async function runCoverageGate(options: { runId?: string thresholdsPath?: string baselineRef?: string + changedBaseRef?: string } = {}) { const rootDir = options.rootDir ?? ROOT_DIR const runId = options.runId ?? nowId() @@ -343,26 +708,55 @@ export async function runCoverageGate(options: { const serverFiles = collectServerTestFiles(rootDir) const suites: SuiteCoverage[] = [] + const coverageByFile = new Map() - suites.push(await runSuite( - 'root-server', - 'Root server/tools/utils', - ['bun', 'test', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'root-server'), ...serverFiles], - rootDir, - join(outputDir, 'root-server'), - () => parseLcov(readFileSync(join(outputDir, 'root-server', 'lcov.info'), 'utf8')), - )) + const rootCommand = ['bun', 'test', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'root-server'), ...serverFiles] + const rootLogPath = join(outputDir, 'root-server', 'coverage.log') + mkdirSync(join(outputDir, 'root-server'), { recursive: true }) + const rootResult = await runCommand(rootCommand, rootDir, rootLogPath) + const rootLcovPath = join(outputDir, 'root-server', 'lcov.info') + const rootLcov = rootResult.exitCode === 0 && existsSync(rootLcovPath) + ? readFileSync(rootLcovPath, 'utf8') + : '' + for (const scope of ROOT_COVERAGE_SCOPES) { + const summary = rootResult.exitCode === 0 + ? parseLcov(rootLcov, { rootDir, scope }) + : undefined + suites.push({ + id: scope.id, + title: scope.title, + status: rootResult.exitCode === 0 ? 'passed' : 'failed', + command: rootCommand, + durationMs: rootResult.durationMs, + logPath: rootLogPath, + ...(summary ? { summary } : {}), + ...(rootResult.exitCode !== 0 ? { error: `coverage command exited with ${rootResult.exitCode}` } : {}), + }) + if (rootResult.exitCode === 0) { + for (const [file, coverage] of lcovLineCoverage(rootLcov, scope.id, scope, rootDir)) { + coverageByFile.set(file, coverage) + } + } + } - suites.push(await runSuite( + const adapters = await runSuite( 'adapters', 'IM adapters', ['bun', 'test', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'adapters')], join(rootDir, 'adapters'), join(outputDir, 'adapters'), () => parseLcov(readFileSync(join(outputDir, 'adapters', 'lcov.info'), 'utf8')), - )) + ) + suites.push(adapters) + const adaptersLcovPath = join(outputDir, 'adapters', 'lcov.info') + if (adapters.status === 'passed' && existsSync(adaptersLcovPath)) { + const adaptersLcov = readFileSync(adaptersLcovPath, 'utf8') + for (const [file, coverage] of lcovLineCoverage(adaptersLcov, 'adapters', ADAPTERS_SCOPE, rootDir)) { + coverageByFile.set(file, coverage) + } + } - suites.push(await runSuite( + const desktop = await runSuite( 'desktop', 'Desktop React', [ @@ -380,10 +774,31 @@ export async function runCoverageGate(options: { join(rootDir, 'desktop'), join(outputDir, 'desktop'), () => parseVitestSummary(join(outputDir, 'desktop', 'coverage-summary.json')), - )) + ) + suites.push(desktop) + const desktopLcovPath = join(outputDir, 'desktop', 'lcov.info') + if (desktop.status === 'passed' && existsSync(desktopLcovPath)) { + const desktopLcov = readFileSync(desktopLcovPath, 'utf8') + for (const [file, coverage] of lcovLineCoverage(desktopLcov, 'desktop', DESKTOP_SCOPE, rootDir)) { + coverageByFile.set(file, coverage) + } + } const thresholds = loadThresholds(options.thresholdsPath ?? join(rootDir, 'scripts', 'quality-gate', 'coverage-thresholds.json')) const failures = evaluateThresholds(suites, thresholds, rootDir, baselineRef) + const changedLineMinimum = thresholds.changedLines?.minimumPercent + const changedLines = typeof changedLineMinimum === 'number' + ? evaluateChangedLineCoverage( + collectChangedLines(rootDir, options.changedBaseRef ?? process.env.COVERAGE_BASE_REF), + coverageByFile, + CHANGED_LINE_SCOPES, + changedLineMinimum, + ) + : undefined + if (changedLines) { + failures.push(...changedLines.failures) + } + const targetGaps = evaluateTargetGaps(suites, thresholds) const report: CoverageReport = { schemaVersion: 1, runId, @@ -392,6 +807,8 @@ export async function runCoverageGate(options: { outputDir, ...(baselineRef ? { baselineRef } : {}), suites, + ...(changedLines ? { changedLines } : {}), + targetGaps, failures, } @@ -407,6 +824,7 @@ if (import.meta.main) { runId: typeof args.get('--run-id') === 'string' ? String(args.get('--run-id')) : undefined, thresholdsPath: typeof args.get('--thresholds') === 'string' ? String(args.get('--thresholds')) : undefined, baselineRef: typeof args.get('--baseline-ref') === 'string' ? String(args.get('--baseline-ref')) : undefined, + changedBaseRef: typeof args.get('--changed-base') === 'string' ? String(args.get('--changed-base')) : undefined, }) console.log(`Coverage report: ${report.outputDir}/coverage-report.md`) console.log(`Summary: passed=${report.suites.filter((suite) => suite.status === 'passed').length} failed=${report.failures.length}`) diff --git a/scripts/quality-gate/desktop-smoke/execute.ts b/scripts/quality-gate/desktop-smoke/execute.ts index e03f7f8c..e3deda8e 100644 --- a/scripts/quality-gate/desktop-smoke/execute.ts +++ b/scripts/quality-gate/desktop-smoke/execute.ts @@ -247,7 +247,14 @@ export async function executeDesktopSmoke( void pipeToFile(server.stdout, serverLogPath) void pipeToFile(server.stderr, serverLogPath) - const vite = Bun.spawn(['bun', 'run', 'dev', '--', '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], { + const viteExecutable = join( + rootDir, + 'desktop', + 'node_modules', + '.bin', + process.platform === 'win32' ? 'vite.cmd' : 'vite', + ) + const vite = Bun.spawn([viteExecutable, '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], { cwd: join(rootDir, 'desktop'), env: { ...process.env, diff --git a/scripts/quality-gate/modes.ts b/scripts/quality-gate/modes.ts index 9c08cb4f..b7eb9c67 100644 --- a/scripts/quality-gate/modes.ts +++ b/scripts/quality-gate/modes.ts @@ -10,6 +10,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:impact'], requiredForModes: ['pr', 'baseline', 'release'], + category: 'scope', }, { id: 'pr-checks', @@ -18,6 +19,67 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:pr'], requiredForModes: ['pr', 'release'], + category: 'governance', + }, + { + id: 'policy-checks', + title: 'Policy checks', + description: 'Run policy, workflow, hook, quarantine, and gate unit tests when any PR quality policy applies.', + kind: 'command', + command: ['bun', 'run', 'check:policy'], + impactRequiredCheck: 'bun run check:policy', + requiredForModes: ['pr', 'release'], + category: 'governance', + }, + { + id: 'desktop-checks', + title: 'Desktop checks', + description: 'Run desktop lint, Vitest, and production build when desktop paths changed.', + kind: 'command', + command: ['bun', 'run', 'check:desktop'], + impactRequiredCheck: 'bun run check:desktop', + requiredForModes: ['pr'], + category: 'unit', + }, + { + id: 'server-checks', + title: 'Server checks', + description: 'Run server, provider, runtime, MCP, OAuth, WebSocket, and API tests when server paths changed.', + kind: 'command', + command: ['bun', 'run', 'check:server'], + impactRequiredCheck: 'bun run check:server', + requiredForModes: ['pr'], + category: 'unit', + }, + { + id: 'adapter-checks', + title: 'Adapter checks', + description: 'Run adapter tests when IM adapter paths changed.', + kind: 'command', + command: ['bun', 'run', 'check:adapters'], + impactRequiredCheck: 'bun run check:adapters', + requiredForModes: ['pr'], + category: 'unit', + }, + { + id: 'native-checks', + title: 'Native desktop checks', + description: 'Build sidecars and run the Tauri native compile check when native or packaging paths changed.', + kind: 'command', + command: ['bun', 'run', 'check:native'], + impactRequiredCheck: 'bun run check:native', + requiredForModes: ['pr', 'release'], + category: 'native', + }, + { + id: 'docs-checks', + title: 'Docs checks', + description: 'Run docs install and VitePress build when docs paths changed.', + kind: 'command', + command: ['bun', 'run', 'check:docs'], + impactRequiredCheck: 'bun run check:docs', + requiredForModes: ['pr'], + category: 'docs', }, { id: 'quarantine', @@ -26,6 +88,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:quarantine'], requiredForModes: ['pr', 'baseline', 'release'], + category: 'governance', }, { id: 'coverage', @@ -34,6 +97,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:coverage'], requiredForModes: ['pr', 'baseline', 'release'], + category: 'coverage', }, { id: 'baseline-catalog', @@ -42,14 +106,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'test', 'scripts/quality-gate/baseline/cases.test.ts'], requiredForModes: ['baseline', 'release'], - }, - { - id: 'native-checks', - title: 'Native desktop checks', - description: 'Build sidecars and run the Tauri native compile check.', - kind: 'command', - command: ['bun', 'run', 'check:native'], - requiredForModes: ['release'], + category: 'unit', }, ] @@ -68,6 +125,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar baselineCaseId: testCase.id, baselineTarget: target, requiredForModes: ['baseline', 'release'], + category: 'integration', live: true, }) } @@ -82,6 +140,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'provider-smoke', baselineTarget: target, requiredForModes: ['baseline', 'release'], + category: 'smoke', live: true, }) } @@ -95,6 +154,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'desktop-smoke', baselineTarget: target, requiredForModes: ['baseline', 'release'], + category: 'smoke', live: true, }) } diff --git a/scripts/quality-gate/reporter.ts b/scripts/quality-gate/reporter.ts index e895ebd9..31d3c10e 100644 --- a/scripts/quality-gate/reporter.ts +++ b/scripts/quality-gate/reporter.ts @@ -1,6 +1,17 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { QualityGateReport } from './types' +import type { LaneCategory, QualityGateReport } from './types' + +const categoryLabels: Record = { + scope: 'Test scope', + governance: 'Governance', + unit: 'Unit/local', + coverage: 'Coverage', + integration: 'Integration', + smoke: 'Smoke/live', + native: 'Native', + docs: 'Docs', +} export function writeReport(report: QualityGateReport, outputDir: string) { mkdirSync(outputDir, { recursive: true }) @@ -9,6 +20,31 @@ export function writeReport(report: QualityGateReport, outputDir: string) { writeFileSync(join(outputDir, 'junit.xml'), renderJUnitReport(report)) } +function escapeMarkdownTable(value: string | number | boolean | undefined) { + return String(value ?? '').replace(/\|/g, '\\|').replace(/\n/g, '
') +} + +function formatDuration(ms: number) { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +function formatMetric(metric: { pct: number; covered: number; total: number } | undefined) { + return metric ? `${metric.pct}% (${metric.covered}/${metric.total})` : '-' +} + +function renderSummaryList(lines: string[], title: string, items: string[]) { + lines.push(`### ${title}`, '') + if (items.length === 0) { + lines.push('- none', '') + return + } + for (const item of items) { + lines.push(`- ${item}`) + } + lines.push('') +} + export function renderMarkdownReport(report: QualityGateReport) { const lines = [ `# Quality Gate Report`, @@ -28,16 +64,79 @@ export function renderMarkdownReport(report: QualityGateReport) { `- Failed: ${report.summary.failed}`, `- Skipped: ${report.summary.skipped}`, '', - `## Lanes`, + `## Test Scope`, '', ] + if (report.impact) { + lines.push(`- Changed files: ${report.impact.changedFiles ?? 'unknown'}`) + lines.push(`- Areas: ${report.impact.areas.length ? report.impact.areas.join(', ') : 'none'}`) + lines.push(`- Labels: ${report.impact.labels.length ? report.impact.labels.join(', ') : 'none'}`) + lines.push(`- Blocked by policy: ${report.impact.blocked === undefined ? 'unknown' : report.impact.blocked ? 'yes' : 'no'}`) + lines.push('') + renderSummaryList(lines, 'Required Local Checks', report.impact.requiredChecks) + renderSummaryList(lines, 'Test Coverage Signals', report.impact.testCoverageSignals) + renderSummaryList(lines, 'Risk Notes', report.impact.riskNotes) + } else { + lines.push('- Impact summary unavailable; inspect the impact-report lane log.', '') + } + + lines.push('## Result Matrix', '') + lines.push('| Category | Lane | Status | Live | Duration | Evidence |') + lines.push('| --- | --- | --- | --- | ---: | --- |') + for (const result of report.results) { + const evidence = result.artifactDir ? result.artifactDir : result.logPath ?? '' + lines.push(`| ${[ + escapeMarkdownTable(result.category ? categoryLabels[result.category] : 'Other'), + escapeMarkdownTable(result.title), + escapeMarkdownTable(result.status), + escapeMarkdownTable(result.live ? 'yes' : 'no'), + escapeMarkdownTable(formatDuration(result.durationMs)), + escapeMarkdownTable(evidence), + ].join(' | ')} |`) + } + lines.push('') + + lines.push('## Coverage', '') + if (report.coverage) { + lines.push(`- Report: ${report.coverage.reportPath}`, '') + lines.push('| Suite | Status | Lines | Functions | Branches | Statements |') + lines.push('| --- | --- | ---: | ---: | ---: | ---: |') + for (const suite of report.coverage.suites) { + lines.push(`| ${[ + escapeMarkdownTable(suite.title), + escapeMarkdownTable(suite.status), + escapeMarkdownTable(formatMetric(suite.lines)), + escapeMarkdownTable(formatMetric(suite.functions)), + escapeMarkdownTable(formatMetric(suite.branches)), + escapeMarkdownTable(formatMetric(suite.statements)), + ].join(' | ')} |`) + } + lines.push('') + renderSummaryList(lines, 'Coverage Failures', report.coverage.failures) + } else { + lines.push('- Coverage summary unavailable; inspect the coverage lane log.', '') + } + + lines.push('## Artifacts', '') + for (const artifact of report.artifacts) { + lines.push(`- ${artifact.title}: ${artifact.path}`) + } + lines.push('', `## Lanes`, '') + for (const result of report.results) { lines.push(`### ${result.title}`) lines.push('') lines.push(`- ID: ${result.id}`) + if (result.category) { + lines.push(`- Category: ${categoryLabels[result.category]}`) + } + lines.push(`- Live: ${result.live ? 'yes' : 'no'}`) + if (result.description) { + lines.push(`- Description: ${result.description}`) + } lines.push(`- Status: ${result.status}`) - lines.push(`- Duration: ${result.durationMs}ms`) + lines.push(`- Duration: ${formatDuration(result.durationMs)}`) if (result.command) { lines.push(`- Command: \`${result.command.join(' ')}\``) } diff --git a/scripts/quality-gate/runner.test.ts b/scripts/quality-gate/runner.test.ts index 6d854707..91f4e83c 100644 --- a/scripts/quality-gate/runner.test.ts +++ b/scripts/quality-gate/runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { lanesForMode } from './modes' @@ -12,6 +12,12 @@ describe('quality gate modes', () => { const lanes = lanesForMode('pr').map((lane) => lane.id) expect(lanes).toContain('impact-report') expect(lanes).toContain('pr-checks') + expect(lanes).toContain('policy-checks') + expect(lanes).toContain('desktop-checks') + expect(lanes).toContain('server-checks') + expect(lanes).toContain('adapter-checks') + expect(lanes).toContain('native-checks') + expect(lanes).toContain('docs-checks') expect(lanes).toContain('quarantine') expect(lanes).toContain('coverage') expect(lanes.some((lane) => lane.startsWith('baseline:'))).toBe(false) @@ -32,6 +38,7 @@ describe('quality gate modes', () => { test('release mode composes PR, baseline, and native lanes', () => { const lanes = lanesForMode('release').map((lane) => lane.id) expect(lanes).toContain('pr-checks') + expect(lanes).toContain('policy-checks') expect(lanes).toContain('quarantine') expect(lanes).toContain('coverage') expect(lanes).toContain('baseline:failing-unit:current-runtime') @@ -74,8 +81,11 @@ describe('runQualityGate', () => { expect(report.mode).toBe('baseline') expect(report.summary.failed).toBe(0) expect(report.summary.skipped).toBeGreaterThan(0) + expect(report.results.find((result) => result.id === 'coverage')?.category).toBe('coverage') + expect(report.results.find((result) => result.id.startsWith('baseline:'))?.live).toBe(true) expect(readFileSync(join(outputDir, 'report.json'), 'utf8')).toContain('"mode": "baseline"') expect(readFileSync(join(outputDir, 'report.md'), 'utf8')).toContain('# Quality Gate Report') + expect(readFileSync(join(outputDir, 'report.md'), 'utf8')).toContain('## Result Matrix') expect(readFileSync(join(outputDir, 'junit.xml'), 'utf8')).toContain(' { } }) + test('skips PR local check lanes when the impact report does not require them', async () => { + const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-')) + const lanes: LaneDefinition[] = [ + { + id: 'impact-report', + title: 'Impact report', + description: 'Writes selected local checks', + kind: 'command', + command: ['bash', '-lc', [ + 'printf "%s\\n"', + '"# PR impact report"', + '""', + '"Changed files: 1"', + '"Areas: server"', + '"Labels: none"', + '"Blocked: no"', + '""', + '"## Required local checks"', + '"- bun run check:server"', + ].join(' ')], + requiredForModes: ['pr'], + }, + { + id: 'desktop-checks', + title: 'Desktop checks', + description: 'Should be skipped', + kind: 'command', + command: ['bash', '-lc', 'exit 7'], + impactRequiredCheck: 'bun run check:desktop', + requiredForModes: ['pr'], + }, + ] + + try { + const { report } = await runQualityGateLanes({ + mode: 'pr', + dryRun: false, + allowLive: false, + baselineTargets: [], + rootDir: process.cwd(), + artifactsDir, + runId: 'impact-selected-skip-test', + }, lanes) + + expect(report.results.map((result) => result.status)).toEqual(['passed', 'skipped']) + expect(report.results[1].skipReason).toContain('not required by impact report') + } finally { + rmSync(artifactsDir, { recursive: true, force: true }) + } + }) + + test('runs PR local check lanes selected by the impact report', async () => { + const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-')) + const lanes: LaneDefinition[] = [ + { + id: 'impact-report', + title: 'Impact report', + description: 'Writes selected local checks', + kind: 'command', + command: ['bash', '-lc', [ + 'printf "%s\\n"', + '"# PR impact report"', + '""', + '"Changed files: 1"', + '"Areas: desktop"', + '"Labels: none"', + '"Blocked: no"', + '""', + '"## Required local checks"', + '"- bun run check:desktop"', + ].join(' ')], + requiredForModes: ['pr'], + }, + { + id: 'desktop-checks', + title: 'Desktop checks', + description: 'Should run', + kind: 'command', + command: ['bash', '-lc', 'exit 0'], + impactRequiredCheck: 'bun run check:desktop', + requiredForModes: ['pr'], + }, + ] + + try { + const { report } = await runQualityGateLanes({ + mode: 'pr', + dryRun: false, + allowLive: false, + baselineTargets: [], + rootDir: process.cwd(), + artifactsDir, + runId: 'impact-selected-run-test', + }, lanes) + + expect(report.results.map((result) => result.status)).toEqual(['passed', 'passed']) + } finally { + rmSync(artifactsDir, { recursive: true, force: true }) + } + }) + + test('hydrates impact and coverage summaries from generated command artifacts', async () => { + const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-')) + const coverageDir = join(artifactsDir, 'coverage-output') + mkdirSync(coverageDir, { recursive: true }) + writeFileSync(join(coverageDir, 'coverage-report.md'), '# Coverage Report\n') + writeFileSync(join(coverageDir, 'coverage-report.json'), JSON.stringify({ + suites: [ + { + id: 'desktop', + title: 'Desktop React', + status: 'passed', + summary: { + lines: { pct: 88, covered: 88, total: 100 }, + functions: { pct: 80, covered: 8, total: 10 }, + branches: { pct: 75, covered: 15, total: 20 }, + statements: { pct: 88, covered: 88, total: 100 }, + }, + }, + ], + failures: [], + }) + '\n') + + const lanes: LaneDefinition[] = [ + { + id: 'impact-report', + title: 'Impact report', + description: 'Summarize changed areas', + kind: 'command', + command: ['bun', 'run', 'check:impact'], + requiredForModes: ['pr'], + category: 'scope', + }, + { + id: 'coverage', + title: 'Coverage gate', + description: 'Run coverage', + kind: 'command', + command: ['bun', 'run', 'check:coverage'], + requiredForModes: ['pr'], + category: 'coverage', + }, + ] + + try { + const { report } = await runQualityGateLanes({ + mode: 'pr', + dryRun: false, + allowLive: false, + baselineTargets: [], + rootDir: process.cwd(), + artifactsDir, + runId: 'artifact-summary-test', + }, lanes, async (lane, options) => { + const logPath = join(options.runOutputDir!, 'logs', `${lane.id}.log`) + mkdirSync(join(options.runOutputDir!, 'logs'), { recursive: true }) + if (lane.id === 'impact-report') { + writeFileSync(logPath, [ + '# PR impact report', + '', + 'Changed files: 2', + 'Areas: desktop, server', + 'Labels: none', + 'Blocked: no', + '', + '## Required local checks', + '- `bun run check:desktop`', + '', + '## Test coverage signals', + '- No obvious missing-test signal from changed paths.', + '', + '## Risk notes', + '- Desktop state/API layer changed.', + ].join('\n')) + } else { + writeFileSync(logPath, `Coverage report: ${join(coverageDir, 'coverage-report.md')}\nSummary: passed=1 failed=0\n`) + } + return { + id: lane.id, + title: lane.title, + status: 'passed', + command: lane.command, + durationMs: 1, + exitCode: 0, + logPath, + } + }) + + expect(report.impact?.changedFiles).toBe(2) + expect(report.impact?.requiredChecks).toEqual(['`bun run check:desktop`']) + expect(report.coverage?.suites[0].lines?.pct).toBe(88) + expect(report.artifacts.map((artifact) => artifact.path)).toContain(join(coverageDir, 'coverage-report.md')) + } finally { + rmSync(artifactsDir, { recursive: true, force: true }) + } + }) + test('release mode treats skipped live lanes as failures', async () => { const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-')) const lanes: LaneDefinition[] = [ @@ -247,27 +454,96 @@ describe('renderMarkdownReport', () => { { id: 'impact-report', title: 'Impact report', - status: 'skipped', - command: ['bun', 'run', 'check:impact'], - durationMs: 1, - skipReason: 'dry run', - logPath: '/tmp/impact-report.log', - }, + description: 'Summarize changed areas.', + category: 'scope', + live: false, + status: 'skipped', + command: ['bun', 'run', 'check:impact'], + durationMs: 1, + skipReason: 'dry run', + logPath: '/tmp/impact-report.log', + }, ], summary: { passed: 0, failed: 0, skipped: 1, }, + artifacts: [], } const markdown = renderMarkdownReport(report) expect(markdown).toContain('Skipped: 1') + expect(markdown).toContain('Test scope') expect(markdown).toContain('`bun run check:impact`') expect(markdown).toContain('dry run') expect(markdown).toContain('/tmp/impact-report.log') }) + test('renders impact and coverage summaries in one markdown report', () => { + const report: QualityGateReport = { + schemaVersion: 1, + runId: 'example', + mode: 'pr', + dryRun: false, + allowLive: false, + startedAt: '2026-05-02T00:00:00.000Z', + finishedAt: '2026-05-02T00:00:03.000Z', + rootDir: process.cwd(), + git: { + sha: 'abc123', + dirty: false, + }, + results: [ + { + id: 'coverage', + title: 'Coverage gate', + category: 'coverage', + live: false, + status: 'passed', + command: ['bun', 'run', 'check:coverage'], + durationMs: 2500, + logPath: '/tmp/coverage.log', + }, + ], + impact: { + changedFiles: 3, + areas: ['desktop', 'server'], + labels: ['allow-cli-core-change'], + blocked: false, + requiredChecks: ['`bun run check:desktop`', '`bun run check:coverage`'], + testCoverageSignals: ['No obvious missing-test signal from changed paths.'], + riskNotes: ['Session runtime changed.'], + }, + coverage: { + reportPath: '/tmp/coverage-report.md', + failures: [], + suites: [ + { + id: 'desktop', + title: 'Desktop React', + status: 'passed', + lines: { pct: 82, covered: 82, total: 100 }, + functions: { pct: 80, covered: 8, total: 10 }, + branches: { pct: 75, covered: 15, total: 20 }, + statements: { pct: 82, covered: 82, total: 100 }, + }, + ], + }, + artifacts: [{ title: 'Coverage report markdown', path: '/tmp/coverage-report.md' }], + summary: { + passed: 1, + failed: 0, + skipped: 0, + }, + } + + const markdown = renderMarkdownReport(report) + expect(markdown).toContain('Changed files: 3') + expect(markdown).toContain('| Desktop React | passed | 82% (82/100)') + expect(markdown).toContain('Coverage report markdown: /tmp/coverage-report.md') + }) + test('renders JUnit report for CI test-summary consumers', () => { const report: QualityGateReport = { schemaVersion: 1, @@ -286,6 +562,8 @@ describe('renderMarkdownReport', () => { { id: 'provider-smoke:example', title: 'Provider smoke', + category: 'smoke', + live: true, status: 'failed', durationMs: 500, error: 'API ', @@ -297,6 +575,7 @@ describe('renderMarkdownReport', () => { failed: 1, skipped: 0, }, + artifacts: [], } const junit = renderJUnitReport(report) diff --git a/scripts/quality-gate/runner.ts b/scripts/quality-gate/runner.ts index ebacf191..e921e8c5 100644 --- a/scripts/quality-gate/runner.ts +++ b/scripts/quality-gate/runner.ts @@ -1,4 +1,4 @@ -import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { baselineCases } from './baseline/cases' import { executeBaselineCase } from './baseline/execute' @@ -6,7 +6,16 @@ import { executeDesktopSmoke } from './desktop-smoke/execute' import { lanesForMode } from './modes' import { executeProviderSmoke } from './provider-smoke/execute' import { writeReport } from './reporter' -import type { LaneDefinition, LaneResult, QualityGateOptions, QualityGateReport } from './types' +import type { + CoverageSuiteSummary, + ImpactSummary, + LaneCategory, + LaneDefinition, + LaneResult, + QualityGateOptions, + QualityGateReport, + ReportArtifact, +} from './types' type LaneExecutor = (lane: LaneDefinition, options: QualityGateOptions) => Promise @@ -107,6 +116,42 @@ async function runCommandLane(lane: LaneDefinition, options: QualityGateOptions) mkdirSync(dirname(logPath), { recursive: true }) writeFileSync(logPath, `$ ${command.join(' ')}\n`) + + if (options.mode === 'pr' && lane.impactRequiredCheck) { + const requiredChecks = readImpactRequiredChecks(options) + if (!requiredChecks) { + const error = `Impact report unavailable before ${lane.impactRequiredCheck}` + appendFileSync(logPath, `[quality-gate] failed: ${error}\n`) + return { + id: lane.id, + title: lane.title, + status: 'failed', + command, + durationMs: Date.now() - started, + error, + logPath, + } + } + + const requiredCheck = normalizeImpactCheck(lane.impactRequiredCheck) + if (!requiredChecks.includes(requiredCheck)) { + const skipReason = `${requiredCheck} not required by impact report` + appendFileSync(logPath, `[quality-gate] skipped: ${skipReason}\n`) + return { + id: lane.id, + title: lane.title, + status: 'skipped', + command, + durationMs: Date.now() - started, + skipReason, + logPath, + } + } + } + + const streamLogs = process.env.QUALITY_GATE_STREAM_LOGS === '1' + const writeStdout = streamLogs ? (chunk: Buffer) => process.stdout.write(chunk) : () => {} + const writeStderr = streamLogs ? (chunk: Buffer) => process.stderr.write(chunk) : () => {} const proc = Bun.spawn(command, { cwd: options.rootDir, stdout: 'pipe', @@ -114,8 +159,8 @@ async function runCommandLane(lane: LaneDefinition, options: QualityGateOptions) }) const [exitCode] = await Promise.all([ proc.exited, - pipeToLog(proc.stdout, logPath, (chunk) => process.stdout.write(chunk)), - pipeToLog(proc.stderr, logPath, (chunk) => process.stderr.write(chunk)), + pipeToLog(proc.stdout, logPath, writeStdout), + pipeToLog(proc.stderr, logPath, writeStderr), ]) return { @@ -224,6 +269,146 @@ function summarize(results: LaneResult[]) { } } +function defaultCategoryForLane(lane: LaneDefinition): LaneCategory { + if (lane.category) return lane.category + if (lane.id === 'impact-report') return 'scope' + if (lane.id === 'coverage') return 'coverage' + if (lane.id === 'native-checks') return 'native' + if (lane.kind === 'baseline-case') return 'integration' + if (lane.kind === 'provider-smoke' || lane.kind === 'desktop-smoke') return 'smoke' + if (lane.id.includes('test') || lane.id.includes('checks')) return 'unit' + return 'governance' +} + +function withLaneMetadata(lane: LaneDefinition, result: LaneResult): LaneResult { + return { + ...result, + description: lane.description, + category: defaultCategoryForLane(lane), + live: Boolean(lane.live), + } +} + +function readText(path: string | undefined) { + if (!path || !existsSync(path)) return null + return readFileSync(path, 'utf8') +} + +function readSection(lines: string[], heading: string) { + const items: string[] = [] + let active = false + + for (const line of lines) { + if (line.startsWith('## ')) { + active = line.trim() === `## ${heading}` + continue + } + + if (!active) continue + if (line.startsWith('- ')) { + items.push(line.slice(2).trim()) + } + } + + return items +} + +function normalizeImpactCheck(value: string) { + return value.replace(/`/g, '').replace(/\s+/g, ' ').trim() +} + +function impactReportLogPath(options: QualityGateOptions) { + const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current') + return join(artifactRoot, 'logs', 'impact-report.log') +} + +function readImpactRequiredChecks(options: QualityGateOptions) { + const log = readText(impactReportLogPath(options)) + if (!log) return null + return readSection(log.split(/\r?\n/), 'Required local checks').map(normalizeImpactCheck) +} + +function splitSummaryList(value: string | undefined) { + if (!value || value === 'none') return [] + return value.split(',').map((item) => item.trim()).filter(Boolean) +} + +function parseImpactSummary(results: LaneResult[]): ImpactSummary | undefined { + const impact = results.find((result) => result.id === 'impact-report') + const log = readText(impact?.logPath) + if (!log) return undefined + + const lines = log.split(/\r?\n/) + const findValue = (label: string) => { + const prefix = `${label}:` + return lines.find((line) => line.startsWith(prefix))?.slice(prefix.length).trim() + } + + const changedFiles = Number(findValue('Changed files')) + + return { + ...(Number.isFinite(changedFiles) ? { changedFiles } : {}), + areas: splitSummaryList(findValue('Areas')), + labels: splitSummaryList(findValue('Labels')), + blocked: findValue('Blocked') === 'yes' ? true : findValue('Blocked') === 'no' ? false : undefined, + requiredChecks: readSection(lines, 'Required local checks'), + testCoverageSignals: readSection(lines, 'Test coverage signals'), + riskNotes: readSection(lines, 'Risk notes'), + } +} + +function coverageReportPathFromLog(results: LaneResult[]) { + const coverage = results.find((result) => result.id === 'coverage') + const log = readText(coverage?.logPath) + if (!log) return null + return log.match(/Coverage report:\s*(.+coverage-report\.md)/)?.[1]?.trim() ?? null +} + +function parseCoverageSummary(results: LaneResult[]) { + const reportPath = coverageReportPathFromLog(results) + if (!reportPath) return undefined + + const jsonPath = reportPath.replace(/coverage-report\.md$/, 'coverage-report.json') + if (!existsSync(jsonPath)) return undefined + + const parsed = JSON.parse(readFileSync(jsonPath, 'utf8')) as { + suites?: Array + }> + failures?: string[] + } + + return { + reportPath, + suites: (parsed.suites ?? []).map((suite) => ({ + id: suite.id, + title: suite.title, + status: suite.status, + lines: suite.lines ?? suite.summary?.lines, + functions: suite.functions ?? suite.summary?.functions, + branches: suite.branches ?? suite.summary?.branches, + statements: suite.statements ?? suite.summary?.statements, + })), + failures: parsed.failures ?? [], + } +} + +function collectReportArtifacts(outputDir: string, results: LaneResult[]): ReportArtifact[] { + const artifacts: ReportArtifact[] = [ + { title: 'Quality report markdown', path: join(outputDir, 'report.md') }, + { title: 'Quality report JSON', path: join(outputDir, 'report.json') }, + { title: 'Quality report JUnit', path: join(outputDir, 'junit.xml') }, + ] + + const coveragePath = coverageReportPathFromLog(results) + if (coveragePath) { + artifacts.push({ title: 'Coverage report markdown', path: coveragePath }) + artifacts.push({ title: 'Coverage report JSON', path: coveragePath.replace(/coverage-report\.md$/, 'coverage-report.json') }) + } + + return artifacts +} + function enforceReleaseLiveLanes( options: QualityGateOptions, lanes: LaneDefinition[], @@ -267,7 +452,7 @@ export async function runQualityGateLanes( const rawResults: LaneResult[] = [] for (const lane of selectedLanes) { const result = await executeLane(lane, runOptions) - rawResults.push(result) + rawResults.push(withLaneMetadata(lane, result)) } const results = enforceReleaseLiveLanes(options, selectedLanes, rawResults) @@ -282,6 +467,9 @@ export async function runQualityGateLanes( rootDir: options.rootDir, git: await gitInfo(options.rootDir), results, + impact: parseImpactSummary(results), + coverage: parseCoverageSummary(results), + artifacts: collectReportArtifacts(outputDir, results), summary: summarize(results), } diff --git a/scripts/quality-gate/types.ts b/scripts/quality-gate/types.ts index 59a98851..5225169c 100644 --- a/scripts/quality-gate/types.ts +++ b/scripts/quality-gate/types.ts @@ -2,15 +2,27 @@ export type QualityGateMode = 'pr' | 'baseline' | 'release' export type LaneKind = 'command' | 'baseline-case' | 'desktop-smoke' | 'provider-smoke' +export type LaneCategory = + | 'scope' + | 'governance' + | 'unit' + | 'coverage' + | 'integration' + | 'smoke' + | 'native' + | 'docs' + export type LaneDefinition = { id: string title: string description: string kind: LaneKind command?: string[] + impactRequiredCheck?: string baselineCaseId?: string baselineTarget?: BaselineTarget requiredForModes: QualityGateMode[] + category?: LaneCategory live?: boolean } @@ -43,6 +55,9 @@ export type LaneStatus = 'passed' | 'failed' | 'skipped' export type LaneResult = { id: string title: string + description?: string + category?: LaneCategory + live?: boolean status: LaneStatus command?: string[] durationMs: number @@ -53,6 +68,37 @@ export type LaneResult = { logPath?: string } +export type ImpactSummary = { + changedFiles?: number + areas: string[] + labels: string[] + blocked?: boolean + requiredChecks: string[] + testCoverageSignals: string[] + riskNotes: string[] +} + +export type CoverageMetricSummary = { + pct: number + covered: number + total: number +} + +export type CoverageSuiteSummary = { + id: string + title: string + status: string + lines?: CoverageMetricSummary + functions?: CoverageMetricSummary + branches?: CoverageMetricSummary + statements?: CoverageMetricSummary +} + +export type ReportArtifact = { + title: string + path: string +} + export type QualityGateOptions = { mode: QualityGateMode dryRun: boolean @@ -80,6 +126,13 @@ export type QualityGateReport = { dirty: boolean } results: LaneResult[] + impact?: ImpactSummary + coverage?: { + reportPath: string + suites: CoverageSuiteSummary[] + failures: string[] + } + artifacts: ReportArtifact[] summary: { passed: number failed: number diff --git a/src/tools/BashTool/bashPermissions.test.ts b/src/tools/BashTool/bashPermissions.test.ts new file mode 100644 index 00000000..5bb9d7b8 --- /dev/null +++ b/src/tools/BashTool/bashPermissions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { + BINARY_HIJACK_VARS, + getFirstWordPrefix, + getSimpleCommandPrefix, + matchWildcardPattern, + stripAllLeadingEnvVars, + stripSafeWrappers, + stripWrappersFromArgv, +} from './bashPermissions' + +describe('bash permission command normalization', () => { + test('extracts stable prefixes only through safe env vars', () => { + expect(getSimpleCommandPrefix('NODE_ENV=production npm run build')).toBe('npm run') + expect(getSimpleCommandPrefix('MY_VAR=value npm run build')).toBeNull() + expect(getSimpleCommandPrefix('git commit -m "fix"')).toBe('git commit') + expect(getSimpleCommandPrefix('chmod 755 file')).toBeNull() + }) + + test('uses first-word fallback without suggesting broad shell wrappers', () => { + expect(getFirstWordPrefix('python3 script.py 2>&1 | tail -20')).toBe('python3') + expect(getFirstWordPrefix('bash -c "rm -rf /tmp/x"')).toBeNull() + expect(getFirstWordPrefix('./script.sh')).toBeNull() + }) + + test('strips only safe wrappers and leading safe env assignments', () => { + expect(stripSafeWrappers('RUST_LOG=debug timeout -k 5s --signal TERM 10s nohup nice -n 5 -- git status')).toBe('git status') + expect(stripSafeWrappers('timeout -k$(id) 10s ls')).toBe('timeout -k$(id) 10s ls') + expect(stripSafeWrappers('timeout 10s FOO=bar npm test')).toBe('FOO=bar npm test') + }) + + test('normalizes wrapper argv with the same safety boundary', () => { + expect(stripWrappersFromArgv(['timeout', '--signal', 'TERM', '10s', 'nohup', 'git', 'status'])).toEqual(['git', 'status']) + expect(stripWrappersFromArgv(['timeout', '--signal', '$(id)', '10s', 'ls'])).toEqual(['timeout', '--signal', '$(id)', '10s', 'ls']) + expect(stripWrappersFromArgv(['nice', '-n', '5', '--', 'git', 'status'])).toEqual(['git', 'status']) + }) + + test('strips arbitrary leading env vars for deny matching but keeps binary hijack vars', () => { + expect(stripAllLeadingEnvVars('FOO=bar TOKEN="safe value" claude --help')).toBe('claude --help') + expect(stripAllLeadingEnvVars('PATH=/tmp/bin claude --help', BINARY_HIJACK_VARS)).toBe('PATH=/tmp/bin claude --help') + }) + + test('matches wildcard permission patterns consistently', () => { + expect(matchWildcardPattern('git *', 'git status --short')).toBe(true) + expect(matchWildcardPattern('git commit', 'git status')).toBe(false) + }) +}) diff --git a/src/utils/bash/bashParser.test.ts b/src/utils/bash/bashParser.test.ts new file mode 100644 index 00000000..c7e3ff07 --- /dev/null +++ b/src/utils/bash/bashParser.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test' +import { ensureParserInitialized, getParserModule, type TsNode } from './bashParser' + +function parse(command: string) { + const parser = getParserModule() + expect(parser).not.toBeNull() + return parser!.parse(command, Infinity) +} + +function collectTypes(node: TsNode | null, types = new Set()) { + if (!node) return types + types.add(node.type) + for (const child of node.children) { + collectTypes(child, types) + } + return types +} + +describe('pure TypeScript bash parser', () => { + test('parses command chains with assignments, redirects, and pipelines', async () => { + await ensureParserInitialized() + + const root = parse('NODE_ENV=test npm run build > out.log 2>&1 | tee report.log') + const types = collectTypes(root) + + expect(root?.type).toBe('program') + expect(types.has('variable_assignment')).toBe(true) + expect(types.has('command')).toBe(true) + expect(types.has('pipeline')).toBe(true) + expect(types.has('redirected_statement')).toBe(true) + expect(root?.text).toContain('npm run build') + }) + + test('parses control flow and command substitutions', () => { + const root = parse([ + 'for file in "$@"; do', + ' if [[ -f "$file" ]]; then', + ' echo "$(basename "$file")"', + ' fi', + 'done', + ].join('\n')) + const types = collectTypes(root) + + expect(types.has('for_statement')).toBe(true) + expect(types.has('if_statement')).toBe(true) + expect(types.has('command_substitution')).toBe(true) + expect(types.has('test_command')).toBe(true) + }) + + test('preserves UTF-8 byte offsets for non-ASCII command text', () => { + const root = parse('echo "你好" && printf "%s\\n" done') + + expect(root).not.toBeNull() + expect(root!.endIndex).toBe(new TextEncoder().encode(root!.text).length) + expect(root!.text).toContain('你好') + }) + + test('parses heredocs, case statements, and functions', () => { + const root = parse([ + 'deploy() {', + ' case "$1" in', + ' prod) cat <