mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
feat: make quality gates observable and enforceable
The repository now has a measurable PR quality path instead of a loose set of manual checks. Coverage, quarantine governance, provider smoke, desktop smoke, and workflow wiring all produce durable reports that contributors and maintainers can inspect without reconstructing terminal output. This also fixes the desktop smoke current-runtime path so browser-driven smoke runs use the desktop default active provider instead of forcing the official current model, and records that runtime decision as an artifact. Constraint: Default PR gates must remain non-live and contributor-safe while live model checks stay explicit. Constraint: Release packaging is still GitHub Actions based, so release preflight must run before the build matrix. Rejected: Make live provider or desktop smoke mandatory on every PR | secrets, quotas, and model availability are maintainer-controlled. Rejected: Let PRs lower coverage baselines in the same change | base-branch ratchet comparison must remain authoritative. Confidence: high Scope-risk: moderate Directive: Do not relax coverage or quarantine policy without a maintainer approval label and a fresh quality report. Tested: ALLOW_CLI_CORE_CHANGE=1 ALLOW_COVERAGE_BASELINE_CHANGE=1 bun run quality:gate --mode pr Tested: bun run quality:gate --mode baseline --allow-live --only provider-smoke:* --provider-model nvidia-custom:main:nvidia-custom-main --artifacts-dir /tmp/quality-gate-live-smoke Tested: bun run quality:gate --mode baseline --allow-live --only desktop-smoke:* --provider-model current:current:current-runtime --artifacts-dir /tmp/quality-gate-desktop-smoke-fixed Tested: git diff --check Not-tested: Full live release mode with multiple providers in hosted CI; provider credentials and quota remain maintainer-controlled.
This commit is contained in:
parent
d1f3b1f91b
commit
9719726cd2
7
.github/pull_request_template.md
vendored
7
.github/pull_request_template.md
vendored
@ -4,10 +4,15 @@
|
||||
## 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 attached or summarized the quality report path, JUnit/log artifact path, and pass/fail/skip counts.
|
||||
|
||||
## Risk
|
||||
|
||||
- [ ] This PR does not touch CLI core paths, or it has maintainer approval for `allow-cli-core-change`.
|
||||
- [ ] User-facing behavior changes include tests or an explanation of why tests are not practical.
|
||||
- [ ] Production code changes include matching tests, or have maintainer approval for `allow-missing-tests`.
|
||||
- [ ] Coverage baseline/threshold changes have maintainer approval for `allow-coverage-baseline-change`.
|
||||
- [ ] Quarantined tests still have owners, exit criteria, and unexpired review windows.
|
||||
- [ ] Provider/runtime changes were covered by mock contract tests, and live smoke was run or explicitly deferred.
|
||||
|
||||
@dosubot review this PR for changed-area risk, missing tests, docs impact, desktop startup risk, and CLI core impact.
|
||||
|
||||
53
.github/workflows/pr-quality.yml
vendored
53
.github/workflows/pr-quality.yml
vendored
@ -25,9 +25,12 @@ jobs:
|
||||
adapter_checks: ${{ steps.policy.outputs.adapter_checks }}
|
||||
desktop_native_checks: ${{ steps.policy.outputs.desktop_native_checks }}
|
||||
docs_checks: ${{ steps.policy.outputs.docs_checks }}
|
||||
coverage_checks: ${{ steps.policy.outputs.coverage_checks }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@ -58,6 +61,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@ -179,3 +184,51 @@ jobs:
|
||||
|
||||
- name: Run docs checks
|
||||
run: npm ci && npm run docs:build
|
||||
|
||||
coverage-checks:
|
||||
name: coverage-checks
|
||||
needs: change-policy
|
||||
if: needs.change-policy.outputs.coverage_checks == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
- name: Run coverage checks
|
||||
env:
|
||||
COVERAGE_BASE_REF: origin/${{ github.base_ref }}
|
||||
run: bun run check:coverage
|
||||
|
||||
- name: Summarize coverage report
|
||||
if: always()
|
||||
run: |
|
||||
latest_report="$(find artifacts/coverage -name coverage-report.md -print | sort | tail -n 1)"
|
||||
if [ -n "$latest_report" ]; then
|
||||
cat "$latest_report" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: artifacts/coverage/
|
||||
retention-days: 14
|
||||
|
||||
67
.github/workflows/pr-triage.yml
vendored
67
.github/workflows/pr-triage.yml
vendored
@ -35,6 +35,8 @@ jobs:
|
||||
'area:cli-core': 'b60205',
|
||||
'needs-maintainer-approval': 'b60205',
|
||||
'allow-cli-core-change': 'c2e0c6',
|
||||
'allow-missing-tests': 'c2e0c6',
|
||||
'allow-coverage-baseline-change': 'c2e0c6',
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
@ -107,6 +109,32 @@ jobs:
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/')
|
||||
)
|
||||
const missingTestSignals = []
|
||||
if (desktopProduct.length > 0 && !hasTest('desktop/src/')) {
|
||||
missingTestSignals.push('Desktop product files changed without a desktop test file in the PR.')
|
||||
}
|
||||
if (serverProduct.length > 0 && !hasTest('src/server/')) {
|
||||
missingTestSignals.push('Server product files changed without a server test file in the PR.')
|
||||
}
|
||||
if (adapterProduct.length > 0 && !hasTest('adapters/')) {
|
||||
missingTestSignals.push('Adapter product files changed without an adapter test file in the PR.')
|
||||
}
|
||||
if (agentRuntimeProduct.length > 0 && !filenames.some((file) =>
|
||||
(file.startsWith('src/tools/') || file.startsWith('src/utils/')) &&
|
||||
(/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
|
||||
)) {
|
||||
missingTestSignals.push('Agent/runtime product files changed without a tools/utils test file in the PR.')
|
||||
}
|
||||
if (missingTestSignals.length > 0 && !currentLabels.has('allow-missing-tests')) {
|
||||
areas.add('needs-maintainer-approval')
|
||||
}
|
||||
const coveragePolicyFiles = filenames.filter((file) =>
|
||||
file === 'scripts/quality-gate/coverage-baseline.json' ||
|
||||
file === 'scripts/quality-gate/coverage-thresholds.json'
|
||||
)
|
||||
if (coveragePolicyFiles.length > 0 && !currentLabels.has('allow-coverage-baseline-change')) {
|
||||
areas.add('needs-maintainer-approval')
|
||||
}
|
||||
|
||||
const requiredChecks = ['change-policy']
|
||||
if (areas.has('area:desktop') || areas.has('area:server')) requiredChecks.push('desktop-checks')
|
||||
@ -121,17 +149,20 @@ jobs:
|
||||
)
|
||||
) requiredChecks.push('desktop-native-checks')
|
||||
if (areas.has('area:docs')) requiredChecks.push('docs-checks')
|
||||
if (
|
||||
filenames.some((file) =>
|
||||
file.startsWith('desktop/src/') ||
|
||||
file.startsWith('src/server/') ||
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/') ||
|
||||
file.startsWith('adapters/') ||
|
||||
file.startsWith('scripts/quality-gate/') ||
|
||||
['package.json', 'desktop/package.json', 'desktop/bun.lock'].includes(file)
|
||||
)
|
||||
) requiredChecks.push('coverage-checks')
|
||||
|
||||
const testSignals = []
|
||||
if (desktopProduct.length > 0 && !hasTest('desktop/src/')) {
|
||||
testSignals.push('Desktop product files changed without a desktop test file in the PR.')
|
||||
}
|
||||
if (serverProduct.length > 0 && !hasTest('src/server/')) {
|
||||
testSignals.push('Server product files changed without a server test file in the PR.')
|
||||
}
|
||||
if (adapterProduct.length > 0 && !hasTest('adapters/')) {
|
||||
testSignals.push('Adapter product files changed without an adapter test file in the PR.')
|
||||
}
|
||||
testSignals.push(...missingTestSignals.map((signal) => `BLOCKING unless \`allow-missing-tests\` is applied: ${signal}`))
|
||||
if (agentRuntimeProduct.length > 0) {
|
||||
testSignals.push('Agent/model runtime path changed: use mock/request-shape tests in PR and maintainer live-model smoke before release.')
|
||||
}
|
||||
@ -183,7 +214,7 @@ jobs:
|
||||
}
|
||||
|
||||
for (const label of Object.keys(managedLabels)) {
|
||||
if (label === 'allow-cli-core-change') continue
|
||||
if (label === 'allow-cli-core-change' || label === 'allow-missing-tests' || label === 'allow-coverage-baseline-change') continue
|
||||
if (!areas.has(label) && currentLabels.has(label)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
@ -203,9 +234,18 @@ jobs:
|
||||
const approvalText = cliCoreFiles.length > 0 && !currentLabels.has('allow-cli-core-change')
|
||||
? 'Blocked by policy until a maintainer applies `allow-cli-core-change` and approves the PR.'
|
||||
: 'No CLI-core policy block detected.'
|
||||
const missingTestText = missingTestSignals.length > 0 && !currentLabels.has('allow-missing-tests')
|
||||
? 'Blocked by policy until a maintainer applies `allow-missing-tests` or matching tests are added.'
|
||||
: 'No missing-test policy block detected.'
|
||||
const coveragePolicyText = coveragePolicyFiles.length > 0 && !currentLabels.has('allow-coverage-baseline-change')
|
||||
? 'Blocked by policy until a maintainer applies `allow-coverage-baseline-change` after reviewing the baseline or threshold change.'
|
||||
: 'No coverage-baseline policy block detected.'
|
||||
const cliFilesText = cliCoreFiles.length
|
||||
? cliCoreFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
|
||||
: '- none'
|
||||
const coveragePolicyFilesText = coveragePolicyFiles.length
|
||||
? coveragePolicyFiles.slice(0, 20).map((file) => `- \`${file}\``).join('\n')
|
||||
: '- none'
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
@ -215,9 +255,16 @@ jobs:
|
||||
'',
|
||||
`**CLI core policy:** ${approvalText}`,
|
||||
'',
|
||||
`**Missing-test policy:** ${missingTestText}`,
|
||||
'',
|
||||
`**Coverage baseline policy:** ${coveragePolicyText}`,
|
||||
'',
|
||||
'**CLI core files:**',
|
||||
cliFilesText,
|
||||
'',
|
||||
'**Coverage policy files:**',
|
||||
coveragePolicyFilesText,
|
||||
'',
|
||||
'**Expected checks:**',
|
||||
requiredChecks.map((check) => `- \`${check}\``).join('\n'),
|
||||
'',
|
||||
|
||||
66
.github/workflows/release-desktop.yml
vendored
66
.github/workflows/release-desktop.yml
vendored
@ -19,7 +19,73 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality-preflight:
|
||||
name: Quality preflight
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential curl wget file \
|
||||
libxdo-dev libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev patchelf \
|
||||
libfuse2
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
- name: Run release quality preflight
|
||||
run: bun run quality:gate --mode pr --artifacts-dir artifacts/quality-runs
|
||||
|
||||
- name: Summarize quality report
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
latest_report="$(find artifacts/quality-runs -name report.md -print | sort | tail -n 1)"
|
||||
if [ -n "$latest_report" ]; then
|
||||
cat "$latest_report" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload quality report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-quality-gate
|
||||
path: artifacts/quality-runs/
|
||||
retention-days: 14
|
||||
|
||||
build:
|
||||
needs: quality-preflight
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,6 +25,7 @@ e2e-*.png
|
||||
|
||||
# Quality gate reports
|
||||
artifacts/quality-runs/
|
||||
artifacts/coverage/
|
||||
|
||||
# Desktop build output
|
||||
desktop/dist/
|
||||
|
||||
17
AGENTS.md
17
AGENTS.md
@ -14,13 +14,17 @@ Install root dependencies with `bun install`, then install desktop dependencies
|
||||
- `cd desktop && bun run test`: run Vitest suites.
|
||||
- `cd desktop && bun run lint`: run TypeScript no-emit checks.
|
||||
- `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 a report under `artifacts/quality-runs/`.
|
||||
- `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>`: run live Coding Agent baseline cases, including desktop agent-browser smoke.
|
||||
- `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>`: run the release gate with live baseline coverage.
|
||||
- `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.
|
||||
- `bun run check:coverage`: run root, desktop, and adapter coverage suites with ratchet enforcement.
|
||||
- `bun run quality:smoke --provider-model <provider:model[:label]>`: run only provider live/proxy smoke and desktop agent-browser smoke.
|
||||
- `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>`: run live Coding Agent baseline cases, provider smoke, and desktop agent-browser smoke.
|
||||
- `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>`: run the release gate with live baseline, provider smoke, desktop smoke, and coverage.
|
||||
|
||||
## Desktop Release Workflow
|
||||
- Desktop releases are built remotely by GitHub Actions, not by uploading local build artifacts.
|
||||
- The release workflow is `.github/workflows/release-desktop.yml`; it triggers automatically on `push` of tags matching `v*.*.*`.
|
||||
- Release workflow builds wait on a non-live `quality:gate --mode pr` preflight and upload `release-quality-gate`; run the live release gate locally or in a maintainer-controlled environment when provider credentials are available.
|
||||
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename aligned with the version/tag exactly.
|
||||
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates version files, refreshes `desktop/src-tauri/Cargo.lock`, requires the matching `release-notes/vX.Y.Z.md`, commits it, and creates the annotated tag.
|
||||
- The normal release push is `git push origin main --tags`. If the tag, app version, or release-notes filename do not match, the workflow is designed to fail fast instead of publishing the wrong release.
|
||||
@ -34,7 +38,7 @@ Install root dependencies with `bun install`, then install desktop dependencies
|
||||
Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match the existing code. Prefer `PascalCase` for React components, `camelCase` for functions, hooks, and stores, and descriptive file names like `teamWatcher.ts` or `AgentTranscript.tsx`. Keep shared UI in `desktop/src/components/`, API clients in `desktop/src/api/`, and avoid adding new dependencies unless the existing utilities cannot cover the change.
|
||||
|
||||
## 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__/`. No coverage gate is configured, so add regression tests for any behavior you change and run the relevant suites before opening a PR.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@ -45,11 +49,12 @@ Future Coding Agents should run the right local gate themselves before claiming
|
||||
- Use `bun run check:native` for `desktop/src-tauri`, sidecars, native packaging, release, or platform startup behavior changes.
|
||||
- Use `bun run check:adapters` for `adapters/`; on a fresh checkout run `cd adapters && bun install` first if dependencies are missing.
|
||||
- Use `bun run check:docs` for docs, VitePress, README, or docs workflow changes.
|
||||
- Use `bun run check:quarantine` and `bun run check:coverage` for code changes; production changes under `desktop/src`, `src/server`, `src/tools`, `src/utils`, or `adapters` must include same-area tests unless a maintainer explicitly approves `allow-missing-tests`. Coverage baseline or threshold changes require `allow-coverage-baseline-change`.
|
||||
- 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 <provider:model[:label]>`.
|
||||
- For release readiness, run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` with at least one real provider/model selector. Prefer multiple providers when quota is available.
|
||||
- For release readiness, run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` 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.
|
||||
- Quality reports are written to `artifacts/quality-runs/<timestamp>/`. Summarize the final report path and the pass/fail counts in handoffs and PR descriptions.
|
||||
- Quality reports are written to `artifacts/quality-runs/<timestamp>/` as `report.md`, `report.json`, `junit.xml`, and `logs/*.log`; coverage reports are written to `artifacts/coverage/<timestamp>/`. 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.
|
||||
|
||||
|
||||
@ -14,6 +14,19 @@ bun install
|
||||
bun run quality:pr
|
||||
```
|
||||
|
||||
这个门禁现在会同时生成质量报告和覆盖率报告:
|
||||
|
||||
```text
|
||||
artifacts/quality-runs/<timestamp>/report.md
|
||||
artifacts/quality-runs/<timestamp>/report.json
|
||||
artifacts/quality-runs/<timestamp>/junit.xml
|
||||
artifacts/quality-runs/<timestamp>/logs/*.log
|
||||
artifacts/coverage/<timestamp>/coverage-report.md
|
||||
```
|
||||
|
||||
覆盖率 baseline/threshold 变更需要维护者加 `allow-coverage-baseline-change`。CI 会优先用 base branch 的 baseline 做 ratchet 对比,避免 PR 自己降低 baseline 后绕过门禁。
|
||||
被 quarantine 的测试必须保留 owner、reviewAfter 和 exitCriteria;过期后 `check:quarantine`、`check:server`、`check:coverage` 都会阻断。
|
||||
|
||||
如果你在全新 clone 中运行 adapter 或 native 相关检查,还需要安装 adapter 依赖:
|
||||
|
||||
```bash
|
||||
@ -28,4 +41,10 @@ bun run quality:providers
|
||||
bun run quality:gate --mode baseline --allow-live --provider-model <selector>:main
|
||||
```
|
||||
|
||||
质量报告会写入 `artifacts/quality-runs/<timestamp>/`。
|
||||
只想跑真实 provider/desktop smoke 时,可以使用:
|
||||
|
||||
```bash
|
||||
bun run quality:smoke --provider-model <selector>:main
|
||||
```
|
||||
|
||||
发版前使用 `quality:gate --mode release --allow-live`,live lane 不允许静默跳过;如果缺 provider、额度或外部账号,要在报告里明确写 blocker。
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
@ -50,6 +51,8 @@
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.4.4.tgz", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
|
||||
@ -76,6 +79,8 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||
|
||||
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "https://registry.npmmirror.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="],
|
||||
@ -184,6 +189,10 @@
|
||||
|
||||
"@iconify/utils": ["@iconify/utils@3.1.0", "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.0.tgz", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.6.tgz", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@ -200,6 +209,8 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.127.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.127.0.tgz", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
|
||||
@ -478,6 +489,8 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@3.2.4", "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.4.tgz", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||
@ -510,10 +523,16 @@
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "https://registry.npmmirror.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
@ -564,6 +583,8 @@
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@7.1.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
@ -678,6 +699,8 @@
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
@ -712,6 +735,8 @@
|
||||
|
||||
"find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@ -724,12 +749,16 @@
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"glob": ["glob@10.5.0", "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hachure-fill": ["hachure-fill@0.5.2", "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
@ -746,6 +775,8 @@
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
@ -778,6 +809,18 @@
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||
|
||||
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
|
||||
|
||||
"istanbul-reports": ["istanbul-reports@3.2.0", "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
@ -842,6 +885,10 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.3.5", "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz", { "bin": "bin/marked.js" }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@ -914,6 +961,10 @@
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"mlly": ["mlly@1.8.2", "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@ -932,6 +983,8 @@
|
||||
|
||||
"p-try": ["p-try@2.2.0", "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
@ -946,8 +999,12 @@
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"path-type": ["path-type@4.0.0", "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
@ -1024,12 +1081,20 @@
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shiki": ["shiki@4.0.2", "https://registry.npmmirror.com/shiki/-/shiki-4.0.2.tgz", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"source-map": ["source-map@0.5.7", "https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@ -1042,10 +1107,14 @@
|
||||
|
||||
"string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.1.0", "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||
@ -1056,6 +1125,8 @@
|
||||
|
||||
"stylis": ["stylis@4.3.6", "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
@ -1064,6 +1135,8 @@
|
||||
|
||||
"tapable": ["tapable@2.3.2", "https://registry.npmmirror.com/tapable/-/tapable-2.3.2.tgz", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"test-exclude": ["test-exclude@7.0.2", "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
@ -1140,12 +1213,16 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@14.2.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
|
||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-module": ["which-module@2.0.1", "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
@ -1170,10 +1247,18 @@
|
||||
|
||||
"@emotion/cache/stylis": ["stylis@4.2.0", "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"cosmiconfig/yaml": ["yaml@1.10.3", "https://registry.npmmirror.com/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
"cssstyle/rrweb-cssom": ["rrweb-cssom@0.8.0", "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
@ -1186,6 +1271,8 @@
|
||||
|
||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
@ -1204,14 +1291,26 @@
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
|
||||
|
||||
"vite-node/vite/postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"vite-node/vite/tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"vitest/vite/postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@ -39,10 +39,16 @@ This gate does not call real models, so every contributor can run it locally. It
|
||||
```text
|
||||
artifacts/quality-runs/<timestamp>/report.md
|
||||
artifacts/quality-runs/<timestamp>/report.json
|
||||
artifacts/quality-runs/<timestamp>/junit.xml
|
||||
artifacts/quality-runs/<timestamp>/logs/*.log
|
||||
artifacts/coverage/<timestamp>/coverage-report.md
|
||||
artifacts/coverage/<timestamp>/coverage-report.json
|
||||
```
|
||||
|
||||
Include the commands you ran and the report summary in your PR description.
|
||||
|
||||
`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.
|
||||
|
||||
## Area-Specific Checks
|
||||
|
||||
Run the checks that match the files you changed:
|
||||
@ -53,13 +59,17 @@ bun run check:desktop # Desktop lint, Vitest, and production build
|
||||
bun run check:adapters # IM adapter tests
|
||||
bun run check:native # Desktop sidecars and Tauri native checks
|
||||
bun run check:docs # Docs build, using npm ci + docs:build
|
||||
bun run check:quarantine # Quarantine owners, exit criteria, and review windows
|
||||
bun run check: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.
|
||||
|
||||
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`.
|
||||
|
||||
## Live Model Baseline
|
||||
|
||||
`quality:baseline` runs real Coding Agent tasks: it starts the local server, creates isolated fixtures, asks a model through chat to fix code, runs tests, and saves transcripts, diffs, verification logs, and a report.
|
||||
`quality:baseline` runs real Coding Agent tasks: it starts the local server, creates isolated fixtures, asks a model through chat to fix code, runs tests, and saves transcripts, diffs, verification logs, and a report. It also runs provider live smoke: saved or active OpenAI-compatible providers validate connectivity, proxy conversion, and streaming proxy behavior; env-only provider smoke validates upstream connectivity and the transform pipeline.
|
||||
|
||||
The default baseline command does not call real models:
|
||||
|
||||
@ -91,6 +101,12 @@ Copy one of the listed values:
|
||||
bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:minimax-main
|
||||
```
|
||||
|
||||
To run only provider smoke plus desktop agent-browser smoke, use:
|
||||
|
||||
```bash
|
||||
bun run quality:smoke --provider-model minimax:main:minimax-main
|
||||
```
|
||||
|
||||
You can run multiple models in one pass:
|
||||
|
||||
```bash
|
||||
@ -101,6 +117,16 @@ bun run quality:gate --mode baseline --allow-live \
|
||||
|
||||
Provider selectors come from the providers saved in your local Desktop Settings > Providers page. Contributors do not need the maintainer's provider UUIDs or vendor accounts. They can add their own provider locally, run `bun run quality:providers`, and choose their own model.
|
||||
|
||||
If you do not have a saved provider, you can run one unsaved provider smoke with environment variables:
|
||||
|
||||
```bash
|
||||
QUALITY_GATE_PROVIDER_BASE_URL=https://example.com \
|
||||
QUALITY_GATE_PROVIDER_API_KEY=... \
|
||||
QUALITY_GATE_PROVIDER_MODEL=model-id \
|
||||
QUALITY_GATE_PROVIDER_API_FORMAT=openai_chat \
|
||||
bun run quality:gate --mode baseline --allow-live
|
||||
```
|
||||
|
||||
## When To Run The Baseline
|
||||
|
||||
Run the live baseline for changes touching:
|
||||
@ -121,7 +147,9 @@ Before a release, run release mode:
|
||||
bun run quality:gate --mode release --allow-live --provider-model <selector>:main
|
||||
```
|
||||
|
||||
Release mode composes PR checks, baseline catalog validation, live baseline cases, desktop smoke, and native checks. Reports are written to `artifacts/quality-runs/<timestamp>/`.
|
||||
Release mode composes PR checks, baseline catalog validation, live baseline cases, provider smoke, desktop smoke, and native checks. Reports are written to `artifacts/quality-runs/<timestamp>/`. The hosted release workflow now runs `quality:gate --mode pr` as a non-live preflight before the packaging matrix and uploads a `release-quality-gate` artifact; maintainers still need to run the live release gate explicitly with an available provider.
|
||||
|
||||
In release mode, live lanes are not allowed to be silently skipped. Missing providers, model quota, or external account access will fail the gate and must be recorded as a release blocker.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
@ -131,7 +159,7 @@ Release mode composes PR checks, baseline catalog validation, live baseline case
|
||||
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, report summary, and known risks.
|
||||
7. In the PR description, include user impact, verification commands, coverage/quality report summary, and known risks.
|
||||
|
||||
## FAQ
|
||||
|
||||
|
||||
@ -39,10 +39,16 @@ bun run quality:pr
|
||||
```text
|
||||
artifacts/quality-runs/<timestamp>/report.md
|
||||
artifacts/quality-runs/<timestamp>/report.json
|
||||
artifacts/quality-runs/<timestamp>/junit.xml
|
||||
artifacts/quality-runs/<timestamp>/logs/*.log
|
||||
artifacts/coverage/<timestamp>/coverage-report.md
|
||||
artifacts/coverage/<timestamp>/coverage-report.json
|
||||
```
|
||||
|
||||
PR 描述里请贴出你实际运行的命令和 summary。
|
||||
|
||||
`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 会阻断,直到维护者处理。
|
||||
|
||||
## 按改动范围补充测试
|
||||
|
||||
根据你改动的区域补充运行:
|
||||
@ -53,13 +59,17 @@ bun run check:desktop # 桌面端 lint、Vitest、生产构建
|
||||
bun run check:adapters # IM adapter 测试
|
||||
bun run check:native # 桌面 sidecar 与 Tauri native 检查
|
||||
bun run check:docs # 文档构建,使用 npm ci + docs:build
|
||||
bun run check:quarantine # quarantine owner、退出条件和复审日期
|
||||
bun run check:coverage # root、desktop、adapters 覆盖率报告和 ratchet 门禁
|
||||
```
|
||||
|
||||
如果只改了很窄的文件,也可以先跑对应的定向测试,但 PR 前仍应跑 `bun run quality:pr`。
|
||||
|
||||
生产代码改动必须带对应测试文件:`desktop/src/**`、`src/server/**`、`src/tools/**`、`src/utils/**`、`adapters/**` 变更如果没有同区域测试,会触发阻断。只有维护者确认不适合自动化测试时,才能使用 `allow-missing-tests`。覆盖率 baseline/threshold 变更同样需要维护者确认并加 `allow-coverage-baseline-change`。
|
||||
|
||||
## 真实模型 Baseline
|
||||
|
||||
`quality:baseline` 用来跑真实 Coding Agent 任务:启动本地服务端、创建隔离 fixture、让模型通过聊天修代码、跑测试,并保存 transcript、diff、verification log 和报告。
|
||||
`quality:baseline` 用来跑真实 Coding Agent 任务:启动本地服务端、创建隔离 fixture、让模型通过聊天修代码、跑测试,并保存 transcript、diff、verification log 和报告。它还会对 provider 进行 live smoke:已保存或当前激活的 OpenAI-compatible provider 会验证连通性、proxy 转换和流式 proxy 结果;env-only provider smoke 只验证上游连通性和转换管线。
|
||||
|
||||
默认命令不会调用真实模型:
|
||||
|
||||
@ -91,6 +101,12 @@ Saved providers:
|
||||
bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:minimax-main
|
||||
```
|
||||
|
||||
如果只需要跑 provider smoke 和桌面 agent-browser smoke,而不跑全部 baseline case,可以使用:
|
||||
|
||||
```bash
|
||||
bun run quality:smoke --provider-model minimax:main:minimax-main
|
||||
```
|
||||
|
||||
可以一次跑多个模型:
|
||||
|
||||
```bash
|
||||
@ -101,6 +117,16 @@ bun run quality:gate --mode baseline --allow-live \
|
||||
|
||||
`provider` selector 来自桌面端「Settings > Providers」里保存的本机配置。别人 clone 代码后不需要知道你的 provider UUID,也不需要使用你的供应商;他们可以在自己的桌面端添加 provider 后运行 `bun run quality:providers` 选择自己的模型。
|
||||
|
||||
如果没有保存 provider,也可以用环境变量跑一条 unsaved provider smoke:
|
||||
|
||||
```bash
|
||||
QUALITY_GATE_PROVIDER_BASE_URL=https://example.com \
|
||||
QUALITY_GATE_PROVIDER_API_KEY=... \
|
||||
QUALITY_GATE_PROVIDER_MODEL=model-id \
|
||||
QUALITY_GATE_PROVIDER_API_FORMAT=openai_chat \
|
||||
bun run quality:gate --mode baseline --allow-live
|
||||
```
|
||||
|
||||
## 什么时候必须跑 Baseline
|
||||
|
||||
以下改动建议跑 live baseline:
|
||||
@ -121,7 +147,9 @@ bun run quality:gate --mode baseline --allow-live \
|
||||
bun run quality:gate --mode release --allow-live --provider-model <selector>:main
|
||||
```
|
||||
|
||||
release 模式会组合 PR checks、baseline catalog、live baseline、desktop smoke 和 native checks。发版报告同样写入 `artifacts/quality-runs/<timestamp>/`。
|
||||
release 模式会组合 PR checks、baseline catalog、live baseline、desktop smoke 和 native checks。发版报告同样写入 `artifacts/quality-runs/<timestamp>/`。线上 release workflow 在打包矩阵前会先跑 `quality:gate --mode pr` 作为非 live 预检,并上传 `release-quality-gate` artifact;真实 live release gate 仍需要维护者用可用 provider 显式运行。
|
||||
|
||||
release 模式下 live lane 不允许静默跳过。缺少 provider、真实模型额度或外部账号时,门禁会失败,并要求在发版记录里明确 blocker。
|
||||
|
||||
## PR 提交流程
|
||||
|
||||
@ -131,7 +159,7 @@ release 模式会组合 PR checks、baseline catalog、live baseline、desktop s
|
||||
4. 运行相关定向测试。
|
||||
5. 运行 `bun run quality:pr`。
|
||||
6. 对高风险改动运行 live baseline。
|
||||
7. 在 PR 描述里写清楚用户影响、测试命令、报告 summary、已知风险。
|
||||
7. 在 PR 描述里写清楚用户影响、测试命令、覆盖率/质量报告 summary、已知风险。
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
@ -11,17 +11,20 @@
|
||||
"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",
|
||||
"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: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:quarantine": "bun run scripts/quality-gate/quarantine.ts --enforce-review-date",
|
||||
"check:coverage": "bun run scripts/quality-gate/coverage.ts",
|
||||
"quality:gate": "bun run scripts/quality-gate/index.ts",
|
||||
"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",
|
||||
"quality:release": "bun run quality:gate --mode release",
|
||||
"quality:smoke": "bun run quality:gate --mode baseline --allow-live --only 'provider-smoke:*' --only 'desktop-smoke:*'",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
|
||||
@ -19,7 +19,7 @@ describe('evaluateChangePolicy', () => {
|
||||
test('allows CLI core changes with a maintainer override label', () => {
|
||||
const result = evaluateChangePolicy(
|
||||
['src/tools/WebSearchTool/backend.ts'],
|
||||
['allow-cli-core-change'],
|
||||
['allow-cli-core-change', 'allow-missing-tests'],
|
||||
)
|
||||
|
||||
expect(result.blocked).toBe(false)
|
||||
@ -36,6 +36,7 @@ describe('evaluateChangePolicy', () => {
|
||||
expect(result.blocked).toBe(false)
|
||||
expect(result.areas).toEqual(['docs'])
|
||||
expect(result.checks.docs).toBe(true)
|
||||
expect(result.checks.coverage).toBe(false)
|
||||
expect(result.checks.desktop).toBe(false)
|
||||
expect(result.checks.desktopNative).toBe(false)
|
||||
})
|
||||
@ -50,6 +51,9 @@ describe('evaluateChangePolicy', () => {
|
||||
expect(result.checks.desktop).toBe(true)
|
||||
expect(result.checks.server).toBe(true)
|
||||
expect(result.checks.desktopNative).toBe(true)
|
||||
expect(result.checks.coverage).toBe(true)
|
||||
expect(result.missingTestSignals).toContain('Desktop product files changed without a desktop test file in the PR.')
|
||||
expect(result.missingTestSignals).toContain('Server product files changed without a server test file in the PR.')
|
||||
})
|
||||
|
||||
test('routes adapter changes to adapter and native checks', () => {
|
||||
@ -58,5 +62,55 @@ describe('evaluateChangePolicy', () => {
|
||||
expect(result.areas).toEqual(['adapters'])
|
||||
expect(result.checks.adapters).toBe(true)
|
||||
expect(result.checks.desktopNative).toBe(true)
|
||||
expect(result.checks.coverage).toBe(true)
|
||||
expect(result.blocked).toBe(true)
|
||||
expect(result.missingTestSignals).toEqual(['Adapter product files changed without an adapter test file in the PR.'])
|
||||
})
|
||||
|
||||
test('allows production changes when matching tests are included', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'desktop/src/pages/Settings.tsx',
|
||||
'desktop/src/pages/Settings.test.tsx',
|
||||
])
|
||||
|
||||
expect(result.blocked).toBe(false)
|
||||
expect(result.missingTestSignals).toEqual([])
|
||||
})
|
||||
|
||||
test('blocks coverage baseline and threshold changes without maintainer override', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'scripts/quality-gate/coverage-baseline.json',
|
||||
'scripts/quality-gate/coverage-thresholds.json',
|
||||
])
|
||||
|
||||
expect(result.blocked).toBe(true)
|
||||
expect(result.coveragePolicyFiles).toEqual([
|
||||
'scripts/quality-gate/coverage-baseline.json',
|
||||
'scripts/quality-gate/coverage-thresholds.json',
|
||||
])
|
||||
expect(result.blockingReasons).toContain('Coverage baseline or threshold changes require the allow-coverage-baseline-change label and maintainer approval.')
|
||||
})
|
||||
|
||||
test('allows coverage baseline changes with maintainer override', () => {
|
||||
const result = evaluateChangePolicy(
|
||||
['scripts/quality-gate/coverage-baseline.json'],
|
||||
['allow-coverage-baseline-change'],
|
||||
)
|
||||
|
||||
expect(result.blocked).toBe(false)
|
||||
})
|
||||
|
||||
test('normalizes relative and windows-style paths before classification', () => {
|
||||
const result = evaluateChangePolicy([
|
||||
'./desktop\\src\\pages\\Settings.tsx',
|
||||
'./desktop\\src\\pages\\Settings.test.tsx',
|
||||
'./scripts\\quality-gate\\coverage.ts',
|
||||
])
|
||||
|
||||
expect(result.files).toContain('desktop/src/pages/Settings.tsx')
|
||||
expect(result.files).toContain('scripts/quality-gate/coverage.ts')
|
||||
expect(result.areas).toContain('desktop')
|
||||
expect(result.checks.coverage).toBe(true)
|
||||
expect(result.blocked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,17 +17,23 @@ export type ChangePolicyResult = {
|
||||
areaLabels: string[]
|
||||
blocked: boolean
|
||||
blockingReason: string | null
|
||||
blockingReasons: string[]
|
||||
cliCoreFiles: string[]
|
||||
coveragePolicyFiles: string[]
|
||||
missingTestSignals: string[]
|
||||
checks: {
|
||||
desktop: boolean
|
||||
server: boolean
|
||||
adapters: boolean
|
||||
desktopNative: boolean
|
||||
docs: boolean
|
||||
coverage: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOW_CLI_CORE_LABEL = 'allow-cli-core-change'
|
||||
const ALLOW_MISSING_TESTS_LABEL = 'allow-missing-tests'
|
||||
const ALLOW_COVERAGE_BASELINE_LABEL = 'allow-coverage-baseline-change'
|
||||
|
||||
const areaLabels: Record<ChangeArea, string> = {
|
||||
desktop: 'area:desktop',
|
||||
@ -82,6 +88,11 @@ const releaseExactPaths = new Set([
|
||||
'desktop/src-tauri/Cargo.lock',
|
||||
])
|
||||
|
||||
const coveragePolicyExactPaths = new Set([
|
||||
'scripts/quality-gate/coverage-baseline.json',
|
||||
'scripts/quality-gate/coverage-thresholds.json',
|
||||
])
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.trim().replace(/\\/g, '/').replace(/^\.\//, '')
|
||||
}
|
||||
@ -128,6 +139,48 @@ function areasForPath(path: string): ChangeArea[] {
|
||||
return [...areas]
|
||||
}
|
||||
|
||||
function hasMatchingTest(files: string[], predicate: (file: string) => boolean) {
|
||||
return files.some((file) => (
|
||||
predicate(file) &&
|
||||
(/\.test\.[cm]?[jt]sx?$/.test(file) || file.includes('/__tests__/'))
|
||||
))
|
||||
}
|
||||
|
||||
function changedProductionFiles(files: string[], predicate: (file: string) => boolean) {
|
||||
return files.filter((file) => (
|
||||
predicate(file) &&
|
||||
!/\.test\.[cm]?[jt]sx?$/.test(file) &&
|
||||
!file.includes('/__tests__/') &&
|
||||
!file.includes('/fixtures/')
|
||||
))
|
||||
}
|
||||
|
||||
function missingTestSignals(files: string[]) {
|
||||
const signals: string[] = []
|
||||
const desktopProd = changedProductionFiles(files, (file) => file.startsWith('desktop/src/'))
|
||||
const serverProd = changedProductionFiles(files, (file) => file.startsWith('src/server/'))
|
||||
const adapterProd = changedProductionFiles(files, (file) => file.startsWith('adapters/'))
|
||||
const agentRuntimeProd = changedProductionFiles(files, (file) => (
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/')
|
||||
))
|
||||
|
||||
if (desktopProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('desktop/src/'))) {
|
||||
signals.push('Desktop product files changed without a desktop test file in the PR.')
|
||||
}
|
||||
if (serverProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('src/server/'))) {
|
||||
signals.push('Server product files changed without a server test file in the PR.')
|
||||
}
|
||||
if (adapterProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('adapters/'))) {
|
||||
signals.push('Adapter product files changed without an adapter test file in the PR.')
|
||||
}
|
||||
if (agentRuntimeProd.length > 0 && !hasMatchingTest(files, (file) => file.startsWith('src/tools/') || file.startsWith('src/utils/'))) {
|
||||
signals.push('Agent/runtime product files changed without a tools/utils test file in the PR.')
|
||||
}
|
||||
|
||||
return signals
|
||||
}
|
||||
|
||||
export function evaluateChangePolicy(
|
||||
inputFiles: string[],
|
||||
inputLabels: string[] = [],
|
||||
@ -145,7 +198,22 @@ export function evaluateChangePolicy(
|
||||
const cliCoreFiles = files.filter(isCliCorePath)
|
||||
const hasCliCoreChange = cliCoreFiles.length > 0
|
||||
const hasCliCoreOverride = labels.includes(ALLOW_CLI_CORE_LABEL)
|
||||
const blocked = hasCliCoreChange && !hasCliCoreOverride
|
||||
const coveragePolicyFiles = files.filter((file) => coveragePolicyExactPaths.has(file))
|
||||
const hasCoveragePolicyOverride = labels.includes(ALLOW_COVERAGE_BASELINE_LABEL)
|
||||
const missingTests = missingTestSignals(files)
|
||||
const hasMissingTestsOverride = labels.includes(ALLOW_MISSING_TESTS_LABEL)
|
||||
const blockingReasons: string[] = []
|
||||
|
||||
if (hasCliCoreChange && !hasCliCoreOverride) {
|
||||
blockingReasons.push(`CLI core changes require the ${ALLOW_CLI_CORE_LABEL} label and maintainer approval.`)
|
||||
}
|
||||
if (missingTests.length > 0 && !hasMissingTestsOverride) {
|
||||
blockingReasons.push(`Production code changes require matching tests or the ${ALLOW_MISSING_TESTS_LABEL} maintainer override.`)
|
||||
}
|
||||
if (coveragePolicyFiles.length > 0 && !hasCoveragePolicyOverride) {
|
||||
blockingReasons.push(`Coverage baseline or threshold changes require the ${ALLOW_COVERAGE_BASELINE_LABEL} label and maintainer approval.`)
|
||||
}
|
||||
const blocked = blockingReasons.length > 0
|
||||
|
||||
const touchesDesktopNative = files.some((file) => (
|
||||
file.startsWith('desktop/') ||
|
||||
@ -159,6 +227,17 @@ export function evaluateChangePolicy(
|
||||
file.startsWith('release-notes/') ||
|
||||
docsExactPaths.has(file)
|
||||
))
|
||||
const touchesCoverage = files.some((file) => (
|
||||
file.startsWith('desktop/src/') ||
|
||||
file.startsWith('src/server/') ||
|
||||
file.startsWith('src/tools/') ||
|
||||
file.startsWith('src/utils/') ||
|
||||
file.startsWith('adapters/') ||
|
||||
file.startsWith('scripts/quality-gate/') ||
|
||||
file === 'package.json' ||
|
||||
file === 'desktop/package.json' ||
|
||||
file === 'desktop/bun.lock'
|
||||
))
|
||||
|
||||
const orderedAreas = [...areas].sort()
|
||||
|
||||
@ -168,16 +247,18 @@ export function evaluateChangePolicy(
|
||||
areas: orderedAreas,
|
||||
areaLabels: orderedAreas.map((area) => areaLabels[area]),
|
||||
blocked,
|
||||
blockingReason: blocked
|
||||
? `CLI core changes require the ${ALLOW_CLI_CORE_LABEL} label and maintainer approval.`
|
||||
: null,
|
||||
blockingReason: blockingReasons[0] ?? null,
|
||||
blockingReasons,
|
||||
cliCoreFiles,
|
||||
coveragePolicyFiles,
|
||||
missingTestSignals: missingTests,
|
||||
checks: {
|
||||
desktop: areas.has('desktop') || areas.has('server'),
|
||||
server: areas.has('server') || files.some((file) => file.startsWith('src/tools/') || file.startsWith('src/utils/')),
|
||||
adapters: areas.has('adapters'),
|
||||
desktopNative: touchesDesktopNative,
|
||||
docs: touchesDocs,
|
||||
coverage: touchesCoverage,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -219,7 +300,7 @@ function formatSummary(result: ChangePolicyResult) {
|
||||
'PR change policy',
|
||||
` Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`,
|
||||
` Labels: ${result.labels.length ? result.labels.join(', ') : 'none'}`,
|
||||
` Checks: desktop=${result.checks.desktop}, server=${result.checks.server}, adapters=${result.checks.adapters}, desktopNative=${result.checks.desktopNative}, docs=${result.checks.docs}`,
|
||||
` Checks: desktop=${result.checks.desktop}, server=${result.checks.server}, adapters=${result.checks.adapters}, desktopNative=${result.checks.desktopNative}, docs=${result.checks.docs}, coverage=${result.checks.coverage}`,
|
||||
]
|
||||
|
||||
if (result.cliCoreFiles.length > 0) {
|
||||
@ -229,8 +310,25 @@ function formatSummary(result: ChangePolicyResult) {
|
||||
}
|
||||
}
|
||||
|
||||
if (result.blockingReason) {
|
||||
lines.push(` Blocked: ${result.blockingReason}`)
|
||||
if (result.coveragePolicyFiles.length > 0) {
|
||||
lines.push(' Coverage policy files:')
|
||||
for (const file of result.coveragePolicyFiles) {
|
||||
lines.push(` - ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.missingTestSignals.length > 0) {
|
||||
lines.push(' Missing test signals:')
|
||||
for (const signal of result.missingTestSignals) {
|
||||
lines.push(` - ${signal}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.blockingReasons.length > 0) {
|
||||
lines.push(' Blocked:')
|
||||
for (const reason of result.blockingReasons) {
|
||||
lines.push(` - ${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
@ -251,6 +349,7 @@ function writeGithubOutputs(result: ChangePolicyResult) {
|
||||
adapter_checks: String(result.checks.adapters),
|
||||
desktop_native_checks: String(result.checks.desktopNative),
|
||||
docs_checks: String(result.checks.docs),
|
||||
coverage_checks: String(result.checks.coverage),
|
||||
}
|
||||
|
||||
appendFileSync(
|
||||
|
||||
@ -76,6 +76,12 @@ const labels = process.env.PR_LABELS?.split(',').map((label) => label.trim()).fi
|
||||
if (process.env.ALLOW_CLI_CORE_CHANGE === '1' && !labels.includes('allow-cli-core-change')) {
|
||||
labels.push('allow-cli-core-change')
|
||||
}
|
||||
if (process.env.ALLOW_MISSING_TESTS === '1' && !labels.includes('allow-missing-tests')) {
|
||||
labels.push('allow-missing-tests')
|
||||
}
|
||||
if (process.env.ALLOW_COVERAGE_BASELINE_CHANGE === '1' && !labels.includes('allow-coverage-baseline-change')) {
|
||||
labels.push('allow-coverage-baseline-change')
|
||||
}
|
||||
|
||||
const result = evaluateChangePolicy(files, labels)
|
||||
|
||||
@ -84,8 +90,11 @@ console.log(` Files: ${files.length}`)
|
||||
console.log(` Areas: ${result.areas.length ? result.areas.join(', ') : 'none'}`)
|
||||
|
||||
if (result.blockingReason) {
|
||||
console.error(`\nBlocked: ${result.blockingReason}`)
|
||||
console.error('Set ALLOW_CLI_CORE_CHANGE=1 only after maintainer approval.')
|
||||
console.error('\nBlocked:')
|
||||
for (const reason of result.blockingReasons) {
|
||||
console.error(`- ${reason}`)
|
||||
}
|
||||
console.error('Use ALLOW_CLI_CORE_CHANGE=1, ALLOW_MISSING_TESTS=1, or ALLOW_COVERAGE_BASELINE_CHANGE=1 only after maintainer approval.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
@ -88,6 +88,9 @@ function commandList(result: ReturnType<typeof evaluateChangePolicy>) {
|
||||
if (result.checks.docs) {
|
||||
commands.push('bun run check:docs')
|
||||
}
|
||||
if (result.checks.coverage) {
|
||||
commands.push('bun run check:coverage')
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
@ -169,11 +172,18 @@ const labels = [
|
||||
if (process.env.ALLOW_CLI_CORE_CHANGE === '1') {
|
||||
labels.push('allow-cli-core-change')
|
||||
}
|
||||
if (process.env.ALLOW_MISSING_TESTS === '1') {
|
||||
labels.push('allow-missing-tests')
|
||||
}
|
||||
if (process.env.ALLOW_COVERAGE_BASELINE_CHANGE === '1') {
|
||||
labels.push('allow-coverage-baseline-change')
|
||||
}
|
||||
|
||||
const files = await changedFiles()
|
||||
const result = evaluateChangePolicy(files, labels)
|
||||
const commands = commandList(result)
|
||||
const warnings = coverageWarnings(result.files)
|
||||
const warnings = [...coverageWarnings(result.files)]
|
||||
const blockingTestSignals = result.missingTestSignals
|
||||
const notes = riskNotes(result.files)
|
||||
|
||||
console.log('# PR impact report')
|
||||
@ -184,7 +194,10 @@ console.log(`Labels: ${result.labels.length ? result.labels.join(', ') : 'none'}
|
||||
console.log(`Blocked: ${result.blocked ? 'yes' : 'no'}`)
|
||||
|
||||
if (result.blockingReason) {
|
||||
console.log(`Blocking reason: ${result.blockingReason}`)
|
||||
console.log('Blocking reasons:')
|
||||
for (const reason of result.blockingReasons) {
|
||||
console.log(`- ${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('')
|
||||
@ -195,7 +208,12 @@ for (const command of commands) {
|
||||
|
||||
console.log('')
|
||||
console.log('## Test coverage signals')
|
||||
if (warnings.length === 0) {
|
||||
if (blockingTestSignals.length > 0) {
|
||||
for (const signal of blockingTestSignals) {
|
||||
console.log(`- BLOCKING: ${signal}`)
|
||||
}
|
||||
}
|
||||
if (warnings.length === 0 && blockingTestSignals.length === 0) {
|
||||
console.log('- No obvious missing-test signal from changed paths.')
|
||||
} else {
|
||||
for (const warning of warnings) {
|
||||
|
||||
25
scripts/pr/pr-quality-workflow.test.ts
Normal file
25
scripts/pr/pr-quality-workflow.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
describe('PR quality workflow', () => {
|
||||
test('routes policy outputs into conditional check jobs', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.desktop_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.server_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.adapter_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.desktop_native_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.docs_checks == 'true'")
|
||||
expect(workflow).toContain("if: needs.change-policy.outputs.coverage_checks == 'true'")
|
||||
})
|
||||
|
||||
test('keeps coverage artifacts observable in CI', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain('COVERAGE_BASE_REF: origin/${{ github.base_ref }}')
|
||||
expect(workflow).toContain('cat "$latest_report" >> "$GITHUB_STEP_SUMMARY"')
|
||||
expect(workflow).toContain('uses: actions/upload-artifact@v4')
|
||||
expect(workflow).toContain('path: artifacts/coverage/')
|
||||
expect(workflow).toContain('retention-days: 14')
|
||||
})
|
||||
})
|
||||
@ -25,4 +25,15 @@ describe('PR triage workflow comment', () => {
|
||||
expect(lastEntry).toBe(`'${DOSU_PROMPT}',`)
|
||||
expect(workflow).not.toContain(`\`${DOSU_PROMPT}\``)
|
||||
})
|
||||
|
||||
test('surfaces missing-test, coverage-check, and coverage-baseline policy branches', () => {
|
||||
const workflow = readFileSync('.github/workflows/pr-triage.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain("'allow-missing-tests': 'c2e0c6'")
|
||||
expect(workflow).toContain("'allow-coverage-baseline-change': 'c2e0c6'")
|
||||
expect(workflow).toContain("requiredChecks.push('coverage-checks')")
|
||||
expect(workflow).toContain('Coverage baseline policy')
|
||||
expect(workflow).toContain('coveragePolicyFiles')
|
||||
expect(workflow).toContain('BLOCKING unless \\`allow-missing-tests\\` is applied')
|
||||
})
|
||||
})
|
||||
|
||||
16
scripts/pr/release-workflow.test.ts
Normal file
16
scripts/pr/release-workflow.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
describe('release desktop workflow', () => {
|
||||
test('runs a quality preflight before packaging matrix builds', () => {
|
||||
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
|
||||
|
||||
expect(workflow).toContain('quality-preflight:')
|
||||
expect(workflow).toContain('run: bun run quality:gate --mode pr --artifacts-dir artifacts/quality-runs')
|
||||
expect(workflow).toContain('cat "$latest_report" >> "$GITHUB_STEP_SUMMARY"')
|
||||
expect(workflow).toContain('name: release-quality-gate')
|
||||
expect(workflow).toContain('path: artifacts/quality-runs/')
|
||||
expect(workflow).toContain('retention-days: 14')
|
||||
expect(workflow).toContain('needs: quality-preflight')
|
||||
})
|
||||
})
|
||||
@ -2,11 +2,11 @@
|
||||
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { quarantinedPathSet } from '../quality-gate/quarantine'
|
||||
import { loadQuarantineManifest, quarantinedPathSet } from '../quality-gate/quarantine'
|
||||
|
||||
const root = process.cwd()
|
||||
const roots = ['src/server', 'src/tools', 'src/utils']
|
||||
const excludedFiles = quarantinedPathSet()
|
||||
const excludedFiles = quarantinedPathSet(loadQuarantineManifest(undefined, { enforceReviewDate: true }))
|
||||
|
||||
function normalize(path: string) {
|
||||
return relative(root, path).split(sep).join('/')
|
||||
|
||||
72
scripts/quality-gate/coverage-baseline.json
Normal file
72
scripts/quality-gate/coverage-baseline.json
Normal file
@ -0,0 +1,72 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-05-06T07:24:15.783Z",
|
||||
"suites": {
|
||||
"root-server": {
|
||||
"lines": {
|
||||
"covered": 42027,
|
||||
"total": 246410,
|
||||
"pct": 17.06
|
||||
},
|
||||
"functions": {
|
||||
"covered": 1962,
|
||||
"total": 9691,
|
||||
"pct": 20.25
|
||||
},
|
||||
"branches": {
|
||||
"covered": 0,
|
||||
"total": 0,
|
||||
"pct": 100
|
||||
},
|
||||
"statements": {
|
||||
"covered": 42027,
|
||||
"total": 246410,
|
||||
"pct": 17.06
|
||||
}
|
||||
},
|
||||
"adapters": {
|
||||
"lines": {
|
||||
"covered": 2151,
|
||||
"total": 2814,
|
||||
"pct": 76.44
|
||||
},
|
||||
"functions": {
|
||||
"covered": 270,
|
||||
"total": 322,
|
||||
"pct": 83.85
|
||||
},
|
||||
"branches": {
|
||||
"covered": 0,
|
||||
"total": 0,
|
||||
"pct": 100
|
||||
},
|
||||
"statements": {
|
||||
"covered": 2151,
|
||||
"total": 2814,
|
||||
"pct": 76.44
|
||||
}
|
||||
},
|
||||
"desktop": {
|
||||
"lines": {
|
||||
"covered": 18605,
|
||||
"total": 49866,
|
||||
"pct": 37.31
|
||||
},
|
||||
"functions": {
|
||||
"covered": 651,
|
||||
"total": 2015,
|
||||
"pct": 32.31
|
||||
},
|
||||
"branches": {
|
||||
"covered": 3506,
|
||||
"total": 5567,
|
||||
"pct": 62.98
|
||||
},
|
||||
"statements": {
|
||||
"covered": 18605,
|
||||
"total": 49866,
|
||||
"pct": 37.31
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
scripts/quality-gate/coverage-thresholds.json
Normal file
27
scripts/quality-gate/coverage-thresholds.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"minimums": {
|
||||
"root-server": {
|
||||
"lines": 16.56,
|
||||
"functions": 19.75,
|
||||
"branches": 0,
|
||||
"statements": 16.56
|
||||
},
|
||||
"adapters": {
|
||||
"lines": 75.94,
|
||||
"functions": 83.35,
|
||||
"branches": 0,
|
||||
"statements": 75.94
|
||||
},
|
||||
"desktop": {
|
||||
"lines": 36.81,
|
||||
"functions": 31.81,
|
||||
"branches": 62.48,
|
||||
"statements": 36.81
|
||||
}
|
||||
},
|
||||
"ratchet": {
|
||||
"baselinePath": "scripts/quality-gate/coverage-baseline.json",
|
||||
"allowedDropPercent": 0.5
|
||||
}
|
||||
}
|
||||
107
scripts/quality-gate/coverage.test.ts
Normal file
107
scripts/quality-gate/coverage.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { evaluateThresholds, parseLcov } from './coverage'
|
||||
|
||||
describe('coverage gate helpers', () => {
|
||||
test('parses lcov totals into percentages', () => {
|
||||
const summary = parseLcov([
|
||||
'TN:',
|
||||
'SF:src/a.ts',
|
||||
'FNF:4',
|
||||
'FNH:3',
|
||||
'BRF:10',
|
||||
'BRH:7',
|
||||
'LF:20',
|
||||
'LH:18',
|
||||
'end_of_record',
|
||||
'SF:src/b.ts',
|
||||
'FNF:1',
|
||||
'FNH:1',
|
||||
'LF:10',
|
||||
'LH:5',
|
||||
'end_of_record',
|
||||
].join('\n'))
|
||||
|
||||
expect(summary.lines.pct).toBe(76.67)
|
||||
expect(summary.functions.pct).toBe(80)
|
||||
expect(summary.branches.pct).toBe(70)
|
||||
})
|
||||
|
||||
test('reports minimum threshold failures', () => {
|
||||
const failures = evaluateThresholds([
|
||||
{
|
||||
id: 'root-server',
|
||||
title: 'Root',
|
||||
status: 'passed',
|
||||
command: ['bun', 'test'],
|
||||
durationMs: 1,
|
||||
logPath: 'coverage.log',
|
||||
summary: {
|
||||
lines: { total: 100, covered: 79, pct: 79 },
|
||||
functions: { total: 10, covered: 9, pct: 90 },
|
||||
branches: { total: 10, covered: 8, pct: 80 },
|
||||
statements: { total: 100, covered: 79, pct: 79 },
|
||||
},
|
||||
},
|
||||
], {
|
||||
schemaVersion: 1,
|
||||
minimums: {
|
||||
'root-server': {
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(failures).toEqual(['root-server: lines coverage 79% is below minimum 80%'])
|
||||
})
|
||||
|
||||
test('treats failed suite execution as a coverage failure', () => {
|
||||
const failures = evaluateThresholds([
|
||||
{
|
||||
id: 'desktop',
|
||||
title: 'Desktop',
|
||||
status: 'failed',
|
||||
command: ['bun', 'run', 'test'],
|
||||
durationMs: 1,
|
||||
logPath: 'coverage.log',
|
||||
error: 'coverage command exited with 1',
|
||||
},
|
||||
], {
|
||||
schemaVersion: 1,
|
||||
minimums: {},
|
||||
})
|
||||
|
||||
expect(failures).toEqual(['desktop: coverage command exited with 1'])
|
||||
})
|
||||
|
||||
test('does not require every suite to exist in the ratchet baseline', () => {
|
||||
const failures = evaluateThresholds([
|
||||
{
|
||||
id: 'new-suite',
|
||||
title: 'New suite',
|
||||
status: 'passed',
|
||||
command: ['bun', 'test'],
|
||||
durationMs: 1,
|
||||
logPath: 'coverage.log',
|
||||
summary: {
|
||||
lines: { total: 100, covered: 90, pct: 90 },
|
||||
functions: { total: 10, covered: 9, pct: 90 },
|
||||
branches: { total: 10, covered: 8, pct: 80 },
|
||||
statements: { total: 100, covered: 90, pct: 90 },
|
||||
},
|
||||
},
|
||||
], {
|
||||
schemaVersion: 1,
|
||||
minimums: {
|
||||
'new-suite': {
|
||||
branches: 85,
|
||||
},
|
||||
},
|
||||
ratchet: {
|
||||
baselinePath: 'scripts/quality-gate/coverage-baseline.json',
|
||||
allowedDropPercent: 0,
|
||||
},
|
||||
})
|
||||
|
||||
expect(failures).toEqual(['new-suite: branches coverage 80% is below minimum 85%'])
|
||||
})
|
||||
})
|
||||
416
scripts/quality-gate/coverage.ts
Normal file
416
scripts/quality-gate/coverage.ts
Normal file
@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, relative, sep } from 'node:path'
|
||||
import { loadQuarantineManifest, quarantinedPathSet } from './quarantine'
|
||||
|
||||
type CoverageMetric = {
|
||||
total: number
|
||||
covered: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
type CoverageSummary = {
|
||||
lines: CoverageMetric
|
||||
functions: CoverageMetric
|
||||
branches: CoverageMetric
|
||||
statements: CoverageMetric
|
||||
}
|
||||
|
||||
type SuiteCoverage = {
|
||||
id: string
|
||||
title: string
|
||||
status: 'passed' | 'failed'
|
||||
command: string[]
|
||||
durationMs: number
|
||||
summary?: CoverageSummary
|
||||
logPath: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type CoverageThresholds = {
|
||||
schemaVersion: 1
|
||||
minimums: Record<string, Partial<Record<keyof CoverageSummary, number>>>
|
||||
ratchet?: {
|
||||
baselinePath: string
|
||||
allowedDropPercent: number
|
||||
}
|
||||
}
|
||||
|
||||
type BaselineFile = {
|
||||
schemaVersion: 1
|
||||
generatedAt?: string
|
||||
suites: Record<string, CoverageSummary>
|
||||
}
|
||||
|
||||
type CoverageReport = {
|
||||
schemaVersion: 1
|
||||
runId: string
|
||||
startedAt: string
|
||||
finishedAt: string
|
||||
outputDir: string
|
||||
baselineRef?: string
|
||||
suites: SuiteCoverage[]
|
||||
failures: string[]
|
||||
}
|
||||
|
||||
const ROOT_DIR = process.cwd()
|
||||
const DEFAULT_THRESHOLDS_PATH = join(ROOT_DIR, 'scripts', 'quality-gate', 'coverage-thresholds.json')
|
||||
|
||||
function nowId() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-')
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = new Map<string, string | boolean>()
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (!arg.startsWith('--')) continue
|
||||
const next = argv[index + 1]
|
||||
if (next && !next.startsWith('--')) {
|
||||
args.set(arg, next)
|
||||
index += 1
|
||||
} else {
|
||||
args.set(arg, true)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function pct(covered: number, total: number) {
|
||||
return total === 0 ? 100 : Number(((covered / total) * 100).toFixed(2))
|
||||
}
|
||||
|
||||
function metric(covered: number, total: number): CoverageMetric {
|
||||
return { covered, total, pct: pct(covered, total) }
|
||||
}
|
||||
|
||||
function normalize(path: string, rootDir = ROOT_DIR) {
|
||||
return relative(rootDir, path).split(sep).join('/')
|
||||
}
|
||||
|
||||
function walkTestFiles(path: string, files: string[], excluded: Set<string>, rootDir = ROOT_DIR) {
|
||||
const stat = statSync(path)
|
||||
if (stat.isDirectory()) {
|
||||
for (const entry of readdirSync(path)) {
|
||||
walkTestFiles(join(path, entry), files, excluded, rootDir)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!stat.isFile()) return
|
||||
|
||||
const normalized = normalize(path, rootDir)
|
||||
if (normalized.endsWith('.test.ts') && !excluded.has(normalized)) {
|
||||
files.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function collectServerTestFiles(rootDir = ROOT_DIR) {
|
||||
const excluded = quarantinedPathSet(loadQuarantineManifest(undefined, { enforceReviewDate: true }))
|
||||
const files: string[] = []
|
||||
for (const root of ['src/server', 'src/tools', 'src/utils']) {
|
||||
walkTestFiles(join(rootDir, root), files, excluded, rootDir)
|
||||
}
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
export function parseLcov(content: string): CoverageSummary {
|
||||
let linesTotal = 0
|
||||
let linesCovered = 0
|
||||
let functionsTotal = 0
|
||||
let functionsCovered = 0
|
||||
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
|
||||
}
|
||||
|
||||
return {
|
||||
lines: metric(linesCovered, linesTotal),
|
||||
functions: metric(functionsCovered, functionsTotal),
|
||||
branches: metric(branchesCovered, branchesTotal),
|
||||
statements: metric(linesCovered, linesTotal),
|
||||
}
|
||||
}
|
||||
|
||||
function parseVitestSummary(path: string): CoverageSummary {
|
||||
const raw = JSON.parse(readFileSync(path, 'utf8')) as {
|
||||
total: Record<string, { total: number; covered: number; pct: number }>
|
||||
}
|
||||
const total = raw.total
|
||||
return {
|
||||
lines: metric(total.lines.covered, total.lines.total),
|
||||
functions: metric(total.functions.covered, total.functions.total),
|
||||
branches: metric(total.branches.covered, total.branches.total),
|
||||
statements: metric(total.statements.covered, total.statements.total),
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(command: string[], cwd: string, logPath: string) {
|
||||
const started = Date.now()
|
||||
const proc = Bun.spawn(command, {
|
||||
cwd,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
mkdirSync(dirname(logPath), { recursive: true })
|
||||
writeFileSync(logPath, `$ ${command.join(' ')}\n${stdout}${stderr}`)
|
||||
return { exitCode, durationMs: Date.now() - started }
|
||||
}
|
||||
|
||||
async function runSuite(
|
||||
id: string,
|
||||
title: string,
|
||||
command: string[],
|
||||
cwd: string,
|
||||
suiteDir: string,
|
||||
readSummary: () => CoverageSummary,
|
||||
): Promise<SuiteCoverage> {
|
||||
mkdirSync(suiteDir, { recursive: true })
|
||||
const logPath = join(suiteDir, 'coverage.log')
|
||||
const result = await runCommand(command, cwd, logPath)
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
status: 'failed',
|
||||
command,
|
||||
durationMs: result.durationMs,
|
||||
logPath,
|
||||
error: `coverage command exited with ${result.exitCode}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
status: 'passed',
|
||||
command,
|
||||
durationMs: result.durationMs,
|
||||
logPath,
|
||||
summary: readSummary(),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
status: 'failed',
|
||||
command,
|
||||
durationMs: result.durationMs,
|
||||
logPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadThresholds(path = DEFAULT_THRESHOLDS_PATH): CoverageThresholds {
|
||||
if (!existsSync(path)) {
|
||||
return { schemaVersion: 1, minimums: {} }
|
||||
}
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as CoverageThresholds
|
||||
}
|
||||
|
||||
function readGitFile(rootDir: string, ref: string, filePath: string) {
|
||||
const gitPath = filePath.startsWith('/')
|
||||
? relative(rootDir, filePath).split(sep).join('/')
|
||||
: filePath.replace(/\\/g, '/').replace(/^\.\//, '')
|
||||
const proc = Bun.spawnSync(['git', 'show', `${ref}:${gitPath}`], {
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
if (proc.exitCode !== 0) {
|
||||
return null
|
||||
}
|
||||
return new TextDecoder().decode(proc.stdout)
|
||||
}
|
||||
|
||||
function loadBaseline(path: string, rootDir = ROOT_DIR, baselineRef?: string): BaselineFile | null {
|
||||
if (baselineRef) {
|
||||
const raw = readGitFile(rootDir, baselineRef, path)
|
||||
return raw ? JSON.parse(raw) as BaselineFile : null
|
||||
}
|
||||
|
||||
const resolved = path.startsWith('/') ? path : join(rootDir, path)
|
||||
if (!existsSync(resolved)) return null
|
||||
return JSON.parse(readFileSync(resolved, 'utf8')) as BaselineFile
|
||||
}
|
||||
|
||||
export function evaluateThresholds(
|
||||
suites: SuiteCoverage[],
|
||||
thresholds: CoverageThresholds,
|
||||
rootDir = ROOT_DIR,
|
||||
baselineRef?: string,
|
||||
) {
|
||||
const failures: string[] = []
|
||||
const baseline = thresholds.ratchet?.baselinePath
|
||||
? loadBaseline(thresholds.ratchet.baselinePath, rootDir, baselineRef)
|
||||
: null
|
||||
const allowedDrop = thresholds.ratchet?.allowedDropPercent ?? 0
|
||||
|
||||
for (const suite of suites) {
|
||||
if (suite.status !== 'passed' || !suite.summary) {
|
||||
failures.push(`${suite.id}: ${suite.error ?? 'coverage suite failed'}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const minimums = thresholds.minimums[suite.id] ?? {}
|
||||
for (const [metricName, minimum] of Object.entries(minimums)) {
|
||||
const actual = suite.summary[metricName as keyof CoverageSummary].pct
|
||||
if (actual + Number.EPSILON < minimum) {
|
||||
failures.push(`${suite.id}: ${metricName} coverage ${actual}% is below minimum ${minimum}%`)
|
||||
}
|
||||
}
|
||||
|
||||
const baselineSummary = baseline?.suites[suite.id]
|
||||
if (!baselineSummary) continue
|
||||
for (const metricName of ['lines', 'functions', 'branches', 'statements'] as const) {
|
||||
const actual = suite.summary[metricName].pct
|
||||
const expected = baselineSummary[metricName].pct - allowedDrop
|
||||
if (actual + Number.EPSILON < expected) {
|
||||
failures.push(`${suite.id}: ${metricName} coverage ${actual}% dropped below baseline ${baselineSummary[metricName].pct}%`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
|
||||
function renderReport(report: CoverageReport) {
|
||||
const lines = [
|
||||
'# Coverage Report',
|
||||
'',
|
||||
`- Run: ${report.runId}`,
|
||||
`- Started: ${report.startedAt}`,
|
||||
`- Finished: ${report.finishedAt}`,
|
||||
`- Output: ${report.outputDir}`,
|
||||
...(report.baselineRef ? [`- Baseline ref: ${report.baselineRef}`] : []),
|
||||
'',
|
||||
'| Suite | Status | Lines | Functions | Branches | Statements |',
|
||||
'| --- | --- | ---: | ---: | ---: | ---: |',
|
||||
]
|
||||
|
||||
for (const suite of report.suites) {
|
||||
const summary = suite.summary
|
||||
lines.push(`| ${[
|
||||
suite.title,
|
||||
suite.status,
|
||||
summary ? `${summary.lines.pct}%` : '-',
|
||||
summary ? `${summary.functions.pct}%` : '-',
|
||||
summary ? `${summary.branches.pct}%` : '-',
|
||||
summary ? `${summary.statements.pct}%` : '-',
|
||||
].join(' | ')} |`)
|
||||
}
|
||||
|
||||
lines.push('', '## Failures', '')
|
||||
if (report.failures.length === 0) {
|
||||
lines.push('- none')
|
||||
} else {
|
||||
for (const failure of report.failures) {
|
||||
lines.push(`- ${failure}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
export async function runCoverageGate(options: {
|
||||
rootDir?: string
|
||||
artifactsDir?: string
|
||||
runId?: string
|
||||
thresholdsPath?: string
|
||||
baselineRef?: string
|
||||
} = {}) {
|
||||
const rootDir = options.rootDir ?? ROOT_DIR
|
||||
const runId = options.runId ?? nowId()
|
||||
const outputDir = join(options.artifactsDir ?? join(rootDir, 'artifacts', 'coverage'), runId)
|
||||
const startedAt = new Date().toISOString()
|
||||
const baselineRef = options.baselineRef ?? process.env.COVERAGE_BASE_REF
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
const serverFiles = collectServerTestFiles(rootDir)
|
||||
const suites: SuiteCoverage[] = []
|
||||
|
||||
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')),
|
||||
))
|
||||
|
||||
suites.push(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(await runSuite(
|
||||
'desktop',
|
||||
'Desktop React',
|
||||
[
|
||||
'bun',
|
||||
'run',
|
||||
'test',
|
||||
'--',
|
||||
'--run',
|
||||
'--coverage',
|
||||
'--coverage.reporter=json-summary',
|
||||
'--coverage.reporter=lcov',
|
||||
`--coverage.reportsDirectory=${join(outputDir, 'desktop')}`,
|
||||
'--testTimeout=20000',
|
||||
],
|
||||
join(rootDir, 'desktop'),
|
||||
join(outputDir, 'desktop'),
|
||||
() => parseVitestSummary(join(outputDir, 'desktop', 'coverage-summary.json')),
|
||||
))
|
||||
|
||||
const thresholds = loadThresholds(options.thresholdsPath ?? join(rootDir, 'scripts', 'quality-gate', 'coverage-thresholds.json'))
|
||||
const failures = evaluateThresholds(suites, thresholds, rootDir, baselineRef)
|
||||
const report: CoverageReport = {
|
||||
schemaVersion: 1,
|
||||
runId,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
outputDir,
|
||||
...(baselineRef ? { baselineRef } : {}),
|
||||
suites,
|
||||
failures,
|
||||
}
|
||||
|
||||
writeFileSync(join(outputDir, 'coverage-report.json'), JSON.stringify(report, null, 2) + '\n')
|
||||
writeFileSync(join(outputDir, 'coverage-report.md'), renderReport(report))
|
||||
return report
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const report = await runCoverageGate({
|
||||
artifactsDir: typeof args.get('--artifacts-dir') === 'string' ? String(args.get('--artifacts-dir')) : undefined,
|
||||
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,
|
||||
})
|
||||
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}`)
|
||||
if (report.failures.length > 0) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
32
scripts/quality-gate/desktop-smoke/execute.test.ts
Normal file
32
scripts/quality-gate/desktop-smoke/execute.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { resolveDesktopSmokeRuntimeSelection } from './execute'
|
||||
|
||||
describe('desktop smoke runtime selection', () => {
|
||||
test('lets current-runtime use the desktop default active provider', () => {
|
||||
expect(resolveDesktopSmokeRuntimeSelection({
|
||||
providerId: null,
|
||||
modelId: 'current',
|
||||
label: 'current-runtime',
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps explicit official and saved provider selections scoped to the session', () => {
|
||||
expect(resolveDesktopSmokeRuntimeSelection({
|
||||
providerId: null,
|
||||
modelId: 'claude-sonnet-4-6',
|
||||
label: 'official-sonnet',
|
||||
})).toEqual({
|
||||
providerId: null,
|
||||
modelId: 'claude-sonnet-4-6',
|
||||
})
|
||||
|
||||
expect(resolveDesktopSmokeRuntimeSelection({
|
||||
providerId: 'provider-a',
|
||||
modelId: 'model-a',
|
||||
label: 'provider-a-main',
|
||||
})).toEqual({
|
||||
providerId: 'provider-a',
|
||||
modelId: 'model-a',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -13,6 +13,17 @@ const PROMPT = [
|
||||
'When the tests pass, briefly say done.',
|
||||
].join(' ')
|
||||
|
||||
export function resolveDesktopSmokeRuntimeSelection(target: BaselineTarget | undefined) {
|
||||
if (!target) return null
|
||||
if (!target.providerId && target.modelId === 'current' && target.label === 'current-runtime') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
providerId: target.providerId ?? null,
|
||||
modelId: target.modelId,
|
||||
}
|
||||
}
|
||||
|
||||
async function getPort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
@ -262,10 +273,10 @@ export async function executeDesktopSmoke(
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir: projectDir }),
|
||||
})
|
||||
const runtimeSelection = {
|
||||
providerId: target?.providerId ?? null,
|
||||
modelId: target?.modelId ?? 'current',
|
||||
}
|
||||
const runtimeSelection = resolveDesktopSmokeRuntimeSelection(target)
|
||||
writeFileSync(join(artifactDir, 'runtime-selection.json'), JSON.stringify(runtimeSelection
|
||||
? { source: 'explicit-target', ...runtimeSelection }
|
||||
: { source: 'default-runtime' }, null, 2) + '\n')
|
||||
|
||||
await runLoggedCommand(['agent-browser', 'open', appUrl], {
|
||||
cwd: rootDir,
|
||||
@ -273,18 +284,21 @@ export async function executeDesktopSmoke(
|
||||
logPath: browserLogPath,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
const browserSetup = [
|
||||
`localStorage.setItem('cc-haha-open-tabs', ${JSON.stringify(JSON.stringify({
|
||||
openTabs: [{ sessionId: session.sessionId, title: 'Desktop Smoke', type: 'session' }],
|
||||
activeTabId: session.sessionId,
|
||||
}))})`,
|
||||
runtimeSelection
|
||||
? `localStorage.setItem('cc-haha-session-runtime', ${JSON.stringify(JSON.stringify({
|
||||
[session.sessionId]: runtimeSelection,
|
||||
}))})`
|
||||
: `localStorage.removeItem('cc-haha-session-runtime')`,
|
||||
]
|
||||
await runLoggedCommand([
|
||||
'agent-browser',
|
||||
'eval',
|
||||
[
|
||||
`localStorage.setItem('cc-haha-open-tabs', ${JSON.stringify(JSON.stringify({
|
||||
openTabs: [{ sessionId: session.sessionId, title: 'Desktop Smoke', type: 'session' }],
|
||||
activeTabId: session.sessionId,
|
||||
}))})`,
|
||||
`localStorage.setItem('cc-haha-session-runtime', ${JSON.stringify(JSON.stringify({
|
||||
[session.sessionId]: runtimeSelection,
|
||||
}))})`,
|
||||
].join(';'),
|
||||
browserSetup.join(';'),
|
||||
], {
|
||||
cwd: rootDir,
|
||||
env: browserEnv,
|
||||
|
||||
@ -37,7 +37,7 @@ function readMode(value: string | boolean | undefined): QualityGateMode {
|
||||
if (value === 'pr' || value === 'baseline' || value === 'release') {
|
||||
return value
|
||||
}
|
||||
throw new Error('Usage: bun run quality:gate --mode <pr|baseline|release> [--dry-run] [--allow-live] [--provider-model provider:model[:label]]')
|
||||
throw new Error('Usage: bun run quality:gate --mode <pr|baseline|release> [--dry-run] [--allow-live] [--provider-model provider:model[:label]] [--only lane-id|prefix*] [--skip lane-id|prefix*]')
|
||||
}
|
||||
|
||||
function readBaselineTargets(args: Map<string, Array<string | boolean>>): BaselineTarget[] {
|
||||
@ -47,6 +47,14 @@ function readBaselineTargets(args: Map<string, Array<string | boolean>>): Baseli
|
||||
return parseBaselineTargetValues(values)
|
||||
}
|
||||
|
||||
function readStringValues(args: Map<string, Array<string | boolean>>, name: string) {
|
||||
return (args.get(name) ?? [])
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (hasFlag(args, '--list-providers')) {
|
||||
@ -69,6 +77,8 @@ const { report, outputDir } = await runQualityGate({
|
||||
baselineTargets,
|
||||
rootDir: process.cwd(),
|
||||
artifactsDir,
|
||||
onlyLaneSelectors: readStringValues(args, '--only'),
|
||||
skipLaneSelectors: readStringValues(args, '--skip'),
|
||||
})
|
||||
|
||||
console.log(`Quality gate report: ${outputDir}/report.md`)
|
||||
|
||||
@ -19,6 +19,22 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar
|
||||
command: ['bun', 'run', 'check:pr'],
|
||||
requiredForModes: ['pr', 'release'],
|
||||
},
|
||||
{
|
||||
id: 'quarantine',
|
||||
title: 'Quarantine governance',
|
||||
description: 'Validate quarantined tests still have owners, exit criteria, and active review windows.',
|
||||
kind: 'command',
|
||||
command: ['bun', 'run', 'check:quarantine'],
|
||||
requiredForModes: ['pr', 'baseline', 'release'],
|
||||
},
|
||||
{
|
||||
id: 'coverage',
|
||||
title: 'Coverage gate',
|
||||
description: 'Run unit/component coverage suites and enforce the ratcheted coverage baseline.',
|
||||
kind: 'command',
|
||||
command: ['bun', 'run', 'check:coverage'],
|
||||
requiredForModes: ['pr', 'baseline', 'release'],
|
||||
},
|
||||
{
|
||||
id: 'baseline-catalog',
|
||||
title: 'Baseline case catalog validation',
|
||||
@ -57,6 +73,19 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const targetSlug = target.label.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
lanes.push({
|
||||
id: `provider-smoke:${targetSlug}`,
|
||||
title: `Provider live/proxy smoke (${target.label})`,
|
||||
description: 'Validate live provider connectivity. Saved or active OpenAI-compatible providers also exercise the local non-stream and streaming proxy endpoints; env-only targets validate upstream connectivity and transform pipeline.',
|
||||
kind: 'provider-smoke',
|
||||
baselineTarget: target,
|
||||
requiredForModes: ['baseline', 'release'],
|
||||
live: true,
|
||||
})
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const targetSlug = target.label.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
lanes.push({
|
||||
|
||||
53
scripts/quality-gate/provider-smoke/execute.test.ts
Normal file
53
scripts/quality-gate/provider-smoke/execute.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { executeProviderSmoke } from './execute'
|
||||
|
||||
const ENV_KEYS = [
|
||||
'QUALITY_GATE_PROVIDER_BASE_URL',
|
||||
'QUALITY_GATE_PROVIDER_API_KEY',
|
||||
'QUALITY_GATE_PROVIDER_MODEL',
|
||||
'QUALITY_GATE_PROVIDER_API_FORMAT',
|
||||
'QUALITY_GATE_PROVIDER_AUTH_STRATEGY',
|
||||
] as const
|
||||
|
||||
const originalEnv = new Map<string, string | undefined>(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
)
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const original = originalEnv.get(key)
|
||||
if (original === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = original
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('provider smoke execution', () => {
|
||||
test('skips unsaved provider smoke with an actionable missing-env reason', async () => {
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key]
|
||||
}
|
||||
|
||||
const artifactRoot = mkdtempSync(join(tmpdir(), 'provider-smoke-test-'))
|
||||
try {
|
||||
const result = await executeProviderSmoke(
|
||||
process.cwd(),
|
||||
join(artifactRoot, 'case'),
|
||||
'provider-smoke:test',
|
||||
'Provider smoke',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.status).toBe('skipped')
|
||||
expect(result.skipReason).toContain('QUALITY_GATE_PROVIDER_BASE_URL')
|
||||
expect(existsSync(join(artifactRoot, 'case'))).toBe(true)
|
||||
} finally {
|
||||
rmSync(artifactRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
235
scripts/quality-gate/provider-smoke/execute.ts
Normal file
235
scripts/quality-gate/provider-smoke/execute.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import { ProviderService } from '../../../src/server/services/providerService'
|
||||
import type { BaselineTarget, LaneResult } from '../types'
|
||||
|
||||
type SavedProvider = Awaited<ReturnType<ProviderService['getProvider']>>
|
||||
|
||||
function getPort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHttp(url: string, timeoutMs: number) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) return
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
await Bun.sleep(500)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${url}`)
|
||||
}
|
||||
|
||||
function anthropicProbeBody(model: string, stream: boolean) {
|
||||
return {
|
||||
model,
|
||||
max_tokens: 32,
|
||||
stream,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Say "ok" and nothing else.',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function runProxyProbe(
|
||||
rootDir: string,
|
||||
artifactDir: string,
|
||||
provider: SavedProvider,
|
||||
modelId: string,
|
||||
) {
|
||||
const port = await getPort()
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const serverLogPath = join(artifactDir, 'proxy-server.log')
|
||||
ProviderService.setServerPort(port)
|
||||
|
||||
const server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)], {
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
SERVER_PORT: String(port),
|
||||
},
|
||||
})
|
||||
|
||||
const pipe = async (stream: ReadableStream<Uint8Array> | null) => {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
appendFileSync(serverLogPath, Buffer.from(value))
|
||||
}
|
||||
}
|
||||
void pipe(server.stdout)
|
||||
void pipe(server.stderr)
|
||||
|
||||
try {
|
||||
await waitForHttp(`${baseUrl}/health`, 60_000)
|
||||
const proxyPath = `${baseUrl}/proxy/providers/${encodeURIComponent(provider.id)}/v1/messages`
|
||||
|
||||
const nonStream = await fetch(proxyPath, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(anthropicProbeBody(modelId, false)),
|
||||
})
|
||||
const nonStreamText = await nonStream.text()
|
||||
writeFileSync(join(artifactDir, 'proxy-non-stream.json'), nonStreamText)
|
||||
if (!nonStream.ok) {
|
||||
throw new Error(`non-stream proxy probe failed with HTTP ${nonStream.status}: ${nonStreamText.slice(0, 300)}`)
|
||||
}
|
||||
const parsed = JSON.parse(nonStreamText) as { type?: string; content?: unknown[] }
|
||||
if (parsed.type !== 'message' || !Array.isArray(parsed.content)) {
|
||||
throw new Error('non-stream proxy probe did not return Anthropic message shape')
|
||||
}
|
||||
|
||||
const stream = await fetch(proxyPath, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(anthropicProbeBody(modelId, true)),
|
||||
})
|
||||
const streamText = await stream.text()
|
||||
writeFileSync(join(artifactDir, 'proxy-stream.sse'), streamText)
|
||||
if (!stream.ok) {
|
||||
throw new Error(`stream proxy probe failed with HTTP ${stream.status}: ${streamText.slice(0, 300)}`)
|
||||
}
|
||||
if (!streamText.includes('message_start') && !streamText.includes('content_block')) {
|
||||
throw new Error('stream proxy probe did not return Anthropic SSE events')
|
||||
}
|
||||
} finally {
|
||||
server.kill()
|
||||
await server.exited.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function runSavedProviderSmoke(
|
||||
rootDir: string,
|
||||
artifactDir: string,
|
||||
target: BaselineTarget,
|
||||
) {
|
||||
const service = new ProviderService()
|
||||
if (!target.providerId) {
|
||||
const { providers, activeId } = await service.listProviders()
|
||||
const activeProvider = providers.find((provider) => provider.id === activeId)
|
||||
if (!activeProvider) {
|
||||
return runEnvProviderSmoke(artifactDir)
|
||||
}
|
||||
|
||||
writeFileSync(join(artifactDir, 'resolved-target.json'), JSON.stringify({
|
||||
source: 'active-provider',
|
||||
providerId: activeProvider.id,
|
||||
providerName: activeProvider.name,
|
||||
requestedModel: target.modelId,
|
||||
}, null, 2) + '\n')
|
||||
return runSavedProviderSmoke(rootDir, artifactDir, {
|
||||
...target,
|
||||
providerId: activeProvider.id,
|
||||
})
|
||||
}
|
||||
|
||||
const provider = await service.getProvider(target.providerId)
|
||||
const modelId = target.modelId === 'current' ? provider.models.main : target.modelId
|
||||
const result = await service.testProvider(target.providerId, { modelId })
|
||||
writeFileSync(join(artifactDir, 'provider-test.json'), JSON.stringify(result, null, 2) + '\n')
|
||||
|
||||
if (!result.connectivity.success) {
|
||||
throw new Error(`provider connectivity failed: ${result.connectivity.error ?? 'unknown error'}`)
|
||||
}
|
||||
if (result.proxy?.success === false) {
|
||||
throw new Error(`provider proxy transform failed: ${result.proxy.error ?? 'unknown error'}`)
|
||||
}
|
||||
|
||||
const apiFormat = provider.apiFormat ?? 'anthropic'
|
||||
if (apiFormat === 'openai_chat' || apiFormat === 'openai_responses') {
|
||||
await runProxyProbe(rootDir, artifactDir, provider, modelId)
|
||||
}
|
||||
|
||||
return { skipped: false as const }
|
||||
}
|
||||
|
||||
async function runEnvProviderSmoke(artifactDir: string) {
|
||||
const baseUrl = process.env.QUALITY_GATE_PROVIDER_BASE_URL
|
||||
const apiKey = process.env.QUALITY_GATE_PROVIDER_API_KEY
|
||||
const modelId = process.env.QUALITY_GATE_PROVIDER_MODEL
|
||||
if (!baseUrl || !apiKey || !modelId) {
|
||||
return { skipped: true as const, reason: 'set QUALITY_GATE_PROVIDER_BASE_URL, QUALITY_GATE_PROVIDER_API_KEY, and QUALITY_GATE_PROVIDER_MODEL or pass a saved provider target' }
|
||||
}
|
||||
|
||||
const service = new ProviderService()
|
||||
const result = await service.testProviderConfig({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
modelId,
|
||||
apiFormat: (process.env.QUALITY_GATE_PROVIDER_API_FORMAT as 'anthropic' | 'openai_chat' | 'openai_responses' | undefined) ?? 'openai_chat',
|
||||
authStrategy: (process.env.QUALITY_GATE_PROVIDER_AUTH_STRATEGY as 'api_key' | 'auth_token' | 'auth_token_empty_api_key' | 'dual_same_token' | 'dual_dummy' | undefined) ?? 'api_key',
|
||||
})
|
||||
writeFileSync(join(artifactDir, 'provider-test.json'), JSON.stringify(result, null, 2) + '\n')
|
||||
if (!result.connectivity.success) {
|
||||
throw new Error(`provider connectivity failed: ${result.connectivity.error ?? 'unknown error'}`)
|
||||
}
|
||||
if (result.proxy?.success === false) {
|
||||
throw new Error(`provider proxy transform failed: ${result.proxy.error ?? 'unknown error'}`)
|
||||
}
|
||||
|
||||
return { skipped: false as const }
|
||||
}
|
||||
|
||||
export async function executeProviderSmoke(
|
||||
rootDir: string,
|
||||
artifactDir: string,
|
||||
resultId: string,
|
||||
resultTitle: string,
|
||||
target: BaselineTarget | undefined,
|
||||
): Promise<LaneResult> {
|
||||
const started = Date.now()
|
||||
mkdirSync(artifactDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const result = target
|
||||
? await runSavedProviderSmoke(rootDir, artifactDir, target)
|
||||
: await runEnvProviderSmoke(artifactDir)
|
||||
|
||||
if (result.skipped) {
|
||||
return {
|
||||
id: resultId,
|
||||
title: resultTitle,
|
||||
status: 'skipped',
|
||||
durationMs: Date.now() - started,
|
||||
skipReason: result.reason,
|
||||
artifactDir,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: resultId,
|
||||
title: resultTitle,
|
||||
status: 'passed',
|
||||
durationMs: Date.now() - started,
|
||||
artifactDir,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
id: resultId,
|
||||
title: resultTitle,
|
||||
status: 'failed',
|
||||
durationMs: Date.now() - started,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
artifactDir,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,35 +5,40 @@
|
||||
"path": "src/server/__tests__/cron-scheduler.test.ts",
|
||||
"reason": "Known unstable scheduler timing assertions; keep out of default server gate until fixed.",
|
||||
"owner": "maintainers",
|
||||
"reviewAfter": "2026-06-01"
|
||||
"reviewAfter": "2026-06-01",
|
||||
"exitCriteria": "Replace timing-sensitive assertions with deterministic scheduler controls and prove the test passes in check:server for five consecutive local/CI runs."
|
||||
},
|
||||
{
|
||||
"id": "server:providers-real",
|
||||
"path": "src/server/__tests__/providers-real.test.ts",
|
||||
"reason": "Live provider coverage belongs in maintainer baseline or release mode, not the default PR gate.",
|
||||
"owner": "maintainers",
|
||||
"reviewAfter": "2026-06-01"
|
||||
"reviewAfter": "2026-06-01",
|
||||
"exitCriteria": "Move live behavior into provider-smoke lanes and keep only non-live provider contract tests in check:server."
|
||||
},
|
||||
{
|
||||
"id": "server:tasks",
|
||||
"path": "src/server/__tests__/tasks.test.ts",
|
||||
"reason": "Known default-gate instability; keep quarantined until task timing is hardened.",
|
||||
"owner": "maintainers",
|
||||
"reviewAfter": "2026-06-01"
|
||||
"reviewAfter": "2026-06-01",
|
||||
"exitCriteria": "Harden task timing with fake timers or event-based waits and prove the suite passes in check:server for five consecutive local/CI runs."
|
||||
},
|
||||
{
|
||||
"id": "server:e2e:business-flow",
|
||||
"path": "src/server/__tests__/e2e/business-flow.test.ts",
|
||||
"reason": "Broad e2e flow is not deterministic enough for default PR checks.",
|
||||
"owner": "maintainers",
|
||||
"reviewAfter": "2026-06-01"
|
||||
"reviewAfter": "2026-06-01",
|
||||
"exitCriteria": "Split into deterministic contract tests or move to an explicit smoke lane with bounded fixtures, logs, and timeout policy."
|
||||
},
|
||||
{
|
||||
"id": "server:e2e:full-flow",
|
||||
"path": "src/server/__tests__/e2e/full-flow.test.ts",
|
||||
"reason": "Broad e2e flow is not deterministic enough for default PR checks.",
|
||||
"owner": "maintainers",
|
||||
"reviewAfter": "2026-06-01"
|
||||
"reviewAfter": "2026-06-01",
|
||||
"exitCriteria": "Split into deterministic contract tests or move to an explicit smoke lane with bounded fixtures, logs, and timeout policy."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { loadQuarantineManifest, quarantinedPathSet, validateQuarantineManifest } from './quarantine'
|
||||
import { expiredQuarantineEntries, loadQuarantineManifest, quarantinedPathSet, renderQuarantineSummary, validateQuarantineManifest } from './quarantine'
|
||||
|
||||
describe('quarantine manifest', () => {
|
||||
test('loads the default manifest', () => {
|
||||
@ -7,6 +7,10 @@ describe('quarantine manifest', () => {
|
||||
expect(manifest.quarantined.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('default manifest review dates are still active', () => {
|
||||
validateQuarantineManifest(loadQuarantineManifest(), { enforceReviewDate: true })
|
||||
})
|
||||
|
||||
test('exposes quarantined paths as a set', () => {
|
||||
const paths = quarantinedPathSet()
|
||||
expect(paths.has('src/server/__tests__/providers-real.test.ts')).toBe(true)
|
||||
@ -21,6 +25,7 @@ describe('quarantine manifest', () => {
|
||||
reason: 'test',
|
||||
owner: 'maintainers',
|
||||
reviewAfter: '2026-06-01',
|
||||
exitCriteria: 'make deterministic',
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
@ -28,8 +33,78 @@ describe('quarantine manifest', () => {
|
||||
reason: 'test',
|
||||
owner: 'maintainers',
|
||||
reviewAfter: '2026-06-01',
|
||||
exitCriteria: 'make deterministic',
|
||||
},
|
||||
],
|
||||
})).toThrow('duplicate quarantine id')
|
||||
})
|
||||
|
||||
test('requires exit criteria and valid review dates', () => {
|
||||
expect(() => validateQuarantineManifest({
|
||||
quarantined: [
|
||||
{
|
||||
id: 'missing-exit',
|
||||
path: 'a.test.ts',
|
||||
reason: 'test',
|
||||
owner: 'maintainers',
|
||||
reviewAfter: '2026-06-01',
|
||||
exitCriteria: '',
|
||||
},
|
||||
],
|
||||
})).toThrow('invalid quarantine entry')
|
||||
|
||||
expect(() => validateQuarantineManifest({
|
||||
quarantined: [
|
||||
{
|
||||
id: 'bad-date',
|
||||
path: 'a.test.ts',
|
||||
reason: 'test',
|
||||
owner: 'maintainers',
|
||||
reviewAfter: 'soon',
|
||||
exitCriteria: 'make deterministic',
|
||||
},
|
||||
],
|
||||
})).toThrow('invalid quarantine reviewAfter date')
|
||||
})
|
||||
|
||||
test('detects expired review dates when enforcement is requested', () => {
|
||||
const manifest = {
|
||||
quarantined: [
|
||||
{
|
||||
id: 'expired',
|
||||
path: 'a.test.ts',
|
||||
reason: 'test',
|
||||
owner: 'maintainers',
|
||||
reviewAfter: '2026-01-01',
|
||||
exitCriteria: 'make deterministic',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(expiredQuarantineEntries(manifest, new Date('2026-05-06T00:00:00.000Z')).map((entry) => entry.id)).toEqual(['expired'])
|
||||
expect(() => validateQuarantineManifest(manifest, {
|
||||
enforceReviewDate: true,
|
||||
asOf: new Date('2026-05-06T00:00:00.000Z'),
|
||||
})).toThrow('expired quarantine entries require review')
|
||||
})
|
||||
|
||||
test('renders a compact review summary', () => {
|
||||
const manifest = {
|
||||
quarantined: [
|
||||
{
|
||||
id: 'expired',
|
||||
path: 'a.test.ts',
|
||||
reason: 'test',
|
||||
owner: 'maintainers',
|
||||
reviewAfter: '2026-01-01',
|
||||
exitCriteria: 'make deterministic',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const summary = renderQuarantineSummary(manifest, new Date('2026-05-06T00:00:00.000Z'))
|
||||
expect(summary).toContain('Entries: 1')
|
||||
expect(summary).toContain('Expired: 1')
|
||||
expect(summary).toContain('expired (a.test.ts, reviewAfter=2026-01-01)')
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,6 +8,7 @@ export type QuarantineEntry = {
|
||||
reason: string
|
||||
owner: string
|
||||
reviewAfter: string
|
||||
exitCriteria: string
|
||||
}
|
||||
|
||||
export type QuarantineManifest = {
|
||||
@ -16,13 +17,20 @@ export type QuarantineManifest = {
|
||||
|
||||
const defaultManifestPath = join(dirname(fileURLToPath(import.meta.url)), 'quarantine.json')
|
||||
|
||||
export function loadQuarantineManifest(path = defaultManifestPath): QuarantineManifest {
|
||||
export function loadQuarantineManifest(path = defaultManifestPath, options: { enforceReviewDate?: boolean; asOf?: Date } = {}): QuarantineManifest {
|
||||
const manifest = JSON.parse(readFileSync(path, 'utf8')) as QuarantineManifest
|
||||
validateQuarantineManifest(manifest)
|
||||
validateQuarantineManifest(manifest, options)
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function validateQuarantineManifest(manifest: QuarantineManifest) {
|
||||
export function expiredQuarantineEntries(manifest: QuarantineManifest, asOf = new Date()) {
|
||||
return manifest.quarantined.filter((entry) => {
|
||||
const reviewAfter = new Date(`${entry.reviewAfter}T23:59:59.999Z`)
|
||||
return Number.isNaN(reviewAfter.getTime()) || reviewAfter < asOf
|
||||
})
|
||||
}
|
||||
|
||||
export function validateQuarantineManifest(manifest: QuarantineManifest, options: { enforceReviewDate?: boolean; asOf?: Date } = {}) {
|
||||
if (!Array.isArray(manifest.quarantined)) {
|
||||
throw new Error('quarantine manifest must contain a quarantined array')
|
||||
}
|
||||
@ -31,9 +39,12 @@ export function validateQuarantineManifest(manifest: QuarantineManifest) {
|
||||
const paths = new Set<string>()
|
||||
|
||||
for (const entry of manifest.quarantined) {
|
||||
if (!entry.id || !entry.path || !entry.reason || !entry.owner || !entry.reviewAfter) {
|
||||
if (!entry.id || !entry.path || !entry.reason || !entry.owner || !entry.reviewAfter || !entry.exitCriteria) {
|
||||
throw new Error(`invalid quarantine entry: ${JSON.stringify(entry)}`)
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.reviewAfter) || Number.isNaN(new Date(`${entry.reviewAfter}T00:00:00.000Z`).getTime())) {
|
||||
throw new Error(`invalid quarantine reviewAfter date: ${entry.id}`)
|
||||
}
|
||||
if (ids.has(entry.id)) {
|
||||
throw new Error(`duplicate quarantine id: ${entry.id}`)
|
||||
}
|
||||
@ -43,8 +54,75 @@ export function validateQuarantineManifest(manifest: QuarantineManifest) {
|
||||
ids.add(entry.id)
|
||||
paths.add(entry.path)
|
||||
}
|
||||
|
||||
if (options.enforceReviewDate) {
|
||||
const expired = expiredQuarantineEntries(manifest, options.asOf)
|
||||
if (expired.length > 0) {
|
||||
throw new Error(`expired quarantine entries require review: ${expired.map((entry) => entry.id).join(', ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function quarantinedPathSet(manifest = loadQuarantineManifest()) {
|
||||
return new Set(manifest.quarantined.map((entry) => entry.path))
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args = new Map<string, string | boolean>()
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (!arg.startsWith('--')) continue
|
||||
const next = argv[index + 1]
|
||||
if (next && !next.startsWith('--')) {
|
||||
args.set(arg, next)
|
||||
index += 1
|
||||
} else {
|
||||
args.set(arg, true)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
export function renderQuarantineSummary(manifest: QuarantineManifest, asOf = new Date()) {
|
||||
const expired = expiredQuarantineEntries(manifest, asOf)
|
||||
const lines = [
|
||||
'Quarantine manifest',
|
||||
` Entries: ${manifest.quarantined.length}`,
|
||||
` Expired: ${expired.length}`,
|
||||
]
|
||||
|
||||
if (expired.length > 0) {
|
||||
lines.push(' Expired entries:')
|
||||
for (const entry of expired) {
|
||||
lines.push(` - ${entry.id} (${entry.path}, reviewAfter=${entry.reviewAfter})`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const manifestPath = typeof args.get('--manifest') === 'string'
|
||||
? String(args.get('--manifest'))
|
||||
: defaultManifestPath
|
||||
const asOf = typeof args.get('--as-of') === 'string'
|
||||
? new Date(`${args.get('--as-of')}T00:00:00.000Z`)
|
||||
: new Date()
|
||||
|
||||
if (Number.isNaN(asOf.getTime())) {
|
||||
console.error('Invalid --as-of date. Expected YYYY-MM-DD.')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = loadQuarantineManifest(manifestPath, {
|
||||
enforceReviewDate: args.has('--enforce-review-date'),
|
||||
asOf,
|
||||
})
|
||||
console.log(renderQuarantineSummary(manifest, asOf))
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ export function writeReport(report: QualityGateReport, outputDir: string) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
writeFileSync(join(outputDir, 'report.json'), JSON.stringify(report, null, 2) + '\n')
|
||||
writeFileSync(join(outputDir, 'report.md'), renderMarkdownReport(report))
|
||||
writeFileSync(join(outputDir, 'junit.xml'), renderJUnitReport(report))
|
||||
}
|
||||
|
||||
export function renderMarkdownReport(report: QualityGateReport) {
|
||||
@ -52,8 +53,51 @@ export function renderMarkdownReport(report: QualityGateReport) {
|
||||
if (result.artifactDir) {
|
||||
lines.push(`- Artifacts: ${result.artifactDir}`)
|
||||
}
|
||||
if (result.logPath) {
|
||||
lines.push(`- Log: ${result.logPath}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function renderJUnitReport(report: QualityGateReport) {
|
||||
const failures = report.results.filter((result) => result.status === 'failed').length
|
||||
const skipped = report.results.filter((result) => result.status === 'skipped').length
|
||||
const durationSeconds = Math.max(0, (Date.parse(report.finishedAt) - Date.parse(report.startedAt)) / 1000)
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<testsuite name="quality-gate.${escapeXml(report.mode)}" tests="${report.results.length}" failures="${failures}" skipped="${skipped}" time="${durationSeconds.toFixed(3)}">`,
|
||||
]
|
||||
|
||||
for (const result of report.results) {
|
||||
const testcaseTime = Math.max(0, result.durationMs / 1000).toFixed(3)
|
||||
lines.push(` <testcase classname="quality-gate.${escapeXml(report.mode)}" name="${escapeXml(result.id)}" time="${testcaseTime}">`)
|
||||
if (result.status === 'failed') {
|
||||
const message = result.error ?? (result.exitCode === undefined ? 'lane failed' : `exit code ${result.exitCode}`)
|
||||
lines.push(` <failure message="${escapeXml(message)}">${escapeXml([
|
||||
`Title: ${result.title}`,
|
||||
result.command ? `Command: ${result.command.join(' ')}` : null,
|
||||
result.logPath ? `Log: ${result.logPath}` : null,
|
||||
result.artifactDir ? `Artifacts: ${result.artifactDir}` : null,
|
||||
].filter(Boolean).join('\n'))}</failure>`)
|
||||
}
|
||||
if (result.status === 'skipped') {
|
||||
lines.push(` <skipped message="${escapeXml(result.skipReason ?? 'skipped')}"/>`)
|
||||
}
|
||||
lines.push(' </testcase>')
|
||||
}
|
||||
|
||||
lines.push('</testsuite>')
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { lanesForMode } from './modes'
|
||||
import { renderMarkdownReport } from './reporter'
|
||||
import { renderJUnitReport, renderMarkdownReport } from './reporter'
|
||||
import { runQualityGate, runQualityGateLanes } from './runner'
|
||||
import type { LaneDefinition, QualityGateReport } from './types'
|
||||
|
||||
@ -12,13 +12,18 @@ 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('quarantine')
|
||||
expect(lanes).toContain('coverage')
|
||||
expect(lanes.some((lane) => lane.startsWith('baseline:'))).toBe(false)
|
||||
})
|
||||
|
||||
test('baseline mode includes live baseline cases but not native checks', () => {
|
||||
const lanes = lanesForMode('baseline').map((lane) => lane.id)
|
||||
expect(lanes).toContain('baseline-catalog')
|
||||
expect(lanes).toContain('quarantine')
|
||||
expect(lanes).toContain('coverage')
|
||||
expect(lanes).toContain('baseline:failing-unit:current-runtime')
|
||||
expect(lanes).toContain('provider-smoke:current-runtime')
|
||||
expect(lanes).toContain('baseline:multi-file-api:current-runtime')
|
||||
expect(lanes).toContain('desktop-smoke:agent-browser-chat:current-runtime')
|
||||
expect(lanes).not.toContain('native-checks')
|
||||
@ -27,7 +32,10 @@ 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('quarantine')
|
||||
expect(lanes).toContain('coverage')
|
||||
expect(lanes).toContain('baseline:failing-unit:current-runtime')
|
||||
expect(lanes).toContain('provider-smoke:current-runtime')
|
||||
expect(lanes).toContain('desktop-smoke:agent-browser-chat:current-runtime')
|
||||
expect(lanes).toContain('native-checks')
|
||||
})
|
||||
@ -42,6 +50,8 @@ describe('quality gate modes', () => {
|
||||
expect(lanes).toContain('baseline:failing-unit:provider-b-model-b')
|
||||
expect(lanes).toContain('baseline:multi-file-api:provider-a-model-a')
|
||||
expect(lanes).toContain('baseline:multi-file-api:provider-b-model-b')
|
||||
expect(lanes).toContain('provider-smoke:provider-a-model-a')
|
||||
expect(lanes).toContain('provider-smoke:provider-b-model-b')
|
||||
expect(lanes).toContain('desktop-smoke:agent-browser-chat:provider-a-model-a')
|
||||
expect(lanes).toContain('desktop-smoke:agent-browser-chat:provider-b-model-b')
|
||||
})
|
||||
@ -66,6 +76,7 @@ describe('runQualityGate', () => {
|
||||
expect(report.summary.skipped).toBeGreaterThan(0)
|
||||
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, 'junit.xml'), 'utf8')).toContain('<testsuite name="quality-gate.baseline"')
|
||||
} finally {
|
||||
rmSync(artifactsDir, { recursive: true, force: true })
|
||||
}
|
||||
@ -120,6 +131,101 @@ describe('runQualityGate', () => {
|
||||
rmSync(artifactsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('filters lanes by exact id or prefix selector', async () => {
|
||||
const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-'))
|
||||
try {
|
||||
const { report } = await runQualityGate({
|
||||
mode: 'baseline',
|
||||
dryRun: true,
|
||||
allowLive: false,
|
||||
baselineTargets: [],
|
||||
rootDir: process.cwd(),
|
||||
artifactsDir,
|
||||
runId: 'filter-lanes-test',
|
||||
onlyLaneSelectors: ['provider-smoke:*'],
|
||||
})
|
||||
|
||||
expect(report.results.length).toBe(1)
|
||||
expect(report.results[0].id).toBe('provider-smoke:current-runtime')
|
||||
} finally {
|
||||
rmSync(artifactsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('command lanes persist per-lane logs', async () => {
|
||||
const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-'))
|
||||
const lanes: LaneDefinition[] = [
|
||||
{
|
||||
id: 'log-lane',
|
||||
title: 'Log lane',
|
||||
description: 'Writes output',
|
||||
kind: 'command',
|
||||
command: ['bun', '--version'],
|
||||
requiredForModes: ['pr'],
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
const { report } = await runQualityGateLanes({
|
||||
mode: 'pr',
|
||||
dryRun: false,
|
||||
allowLive: false,
|
||||
baselineTargets: [],
|
||||
rootDir: process.cwd(),
|
||||
artifactsDir,
|
||||
runId: 'command-log-test',
|
||||
}, lanes)
|
||||
|
||||
const logPath = report.results[0].logPath
|
||||
expect(report.results[0].status).toBe('passed')
|
||||
expect(logPath).toBeTruthy()
|
||||
expect(readFileSync(logPath!, 'utf8')).toContain('$ bun --version')
|
||||
} 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[] = [
|
||||
{
|
||||
id: 'live-lane',
|
||||
title: 'Live lane',
|
||||
description: 'Requires release evidence',
|
||||
kind: 'provider-smoke',
|
||||
live: true,
|
||||
requiredForModes: ['release'],
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
const { report } = await runQualityGateLanes({
|
||||
mode: 'release',
|
||||
dryRun: false,
|
||||
allowLive: false,
|
||||
baselineTargets: [],
|
||||
rootDir: process.cwd(),
|
||||
artifactsDir,
|
||||
runId: 'release-live-skipped-test',
|
||||
}, lanes, async (lane) => ({
|
||||
id: lane.id,
|
||||
title: lane.title,
|
||||
status: 'skipped',
|
||||
durationMs: 1,
|
||||
skipReason: 'missing credentials',
|
||||
}))
|
||||
|
||||
expect(report.summary).toEqual({
|
||||
passed: 0,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
})
|
||||
expect(report.results[0].error).toBe('missing credentials')
|
||||
} finally {
|
||||
rmSync(artifactsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMarkdownReport', () => {
|
||||
@ -141,11 +247,12 @@ describe('renderMarkdownReport', () => {
|
||||
{
|
||||
id: 'impact-report',
|
||||
title: 'Impact report',
|
||||
status: 'skipped',
|
||||
command: ['bun', 'run', 'check:impact'],
|
||||
durationMs: 1,
|
||||
skipReason: 'dry run',
|
||||
},
|
||||
status: 'skipped',
|
||||
command: ['bun', 'run', 'check:impact'],
|
||||
durationMs: 1,
|
||||
skipReason: 'dry run',
|
||||
logPath: '/tmp/impact-report.log',
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
passed: 0,
|
||||
@ -158,5 +265,44 @@ describe('renderMarkdownReport', () => {
|
||||
expect(markdown).toContain('Skipped: 1')
|
||||
expect(markdown).toContain('`bun run check:impact`')
|
||||
expect(markdown).toContain('dry run')
|
||||
expect(markdown).toContain('/tmp/impact-report.log')
|
||||
})
|
||||
|
||||
test('renders JUnit report for CI test-summary consumers', () => {
|
||||
const report: QualityGateReport = {
|
||||
schemaVersion: 1,
|
||||
runId: 'example',
|
||||
mode: 'release',
|
||||
dryRun: false,
|
||||
allowLive: true,
|
||||
startedAt: '2026-05-02T00:00:00.000Z',
|
||||
finishedAt: '2026-05-02T00:00:02.000Z',
|
||||
rootDir: process.cwd(),
|
||||
git: {
|
||||
sha: 'abc123',
|
||||
dirty: false,
|
||||
},
|
||||
results: [
|
||||
{
|
||||
id: 'provider-smoke:example',
|
||||
title: 'Provider smoke',
|
||||
status: 'failed',
|
||||
durationMs: 500,
|
||||
error: 'API <error>',
|
||||
logPath: '/tmp/provider.log',
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
passed: 0,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const junit = renderJUnitReport(report)
|
||||
expect(junit).toContain('testsuite name="quality-gate.release"')
|
||||
expect(junit).toContain('failures="1"')
|
||||
expect(junit).toContain('API <error>')
|
||||
expect(junit).toContain('/tmp/provider.log')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { baselineCases } from './baseline/cases'
|
||||
import { executeBaselineCase } from './baseline/execute'
|
||||
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'
|
||||
|
||||
@ -28,6 +29,53 @@ async function output(cmd: string[], cwd: string) {
|
||||
return (stdout || stderr).trim()
|
||||
}
|
||||
|
||||
function sanitizeId(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
}
|
||||
|
||||
function matchesLaneSelector(lane: LaneDefinition, selector: string) {
|
||||
const normalized = selector.trim()
|
||||
if (!normalized) return false
|
||||
if (normalized.endsWith('*')) {
|
||||
return lane.id.startsWith(normalized.slice(0, -1))
|
||||
}
|
||||
return lane.id === normalized
|
||||
}
|
||||
|
||||
function filterLanesForOptions(lanes: LaneDefinition[], options: QualityGateOptions) {
|
||||
const only = options.onlyLaneSelectors?.filter(Boolean) ?? []
|
||||
const skip = options.skipLaneSelectors?.filter(Boolean) ?? []
|
||||
let selected = lanes
|
||||
|
||||
if (only.length > 0) {
|
||||
selected = selected.filter((lane) => only.some((selector) => matchesLaneSelector(lane, selector)))
|
||||
}
|
||||
if (skip.length > 0) {
|
||||
selected = selected.filter((lane) => !skip.some((selector) => matchesLaneSelector(lane, selector)))
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
throw new Error(`No quality gate lanes matched selectors. only=${only.join(',') || 'none'} skip=${skip.join(',') || 'none'}`)
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
async function pipeToLog(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
logPath: string,
|
||||
write: (chunk: Buffer) => void,
|
||||
) {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = Buffer.from(value)
|
||||
appendFileSync(logPath, chunk)
|
||||
write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
async function gitInfo(rootDir: string) {
|
||||
const sha = await output(['git', 'rev-parse', '--short', 'HEAD'], rootDir)
|
||||
const status = await output(['git', 'status', '--short'], rootDir)
|
||||
@ -40,8 +88,12 @@ async function gitInfo(rootDir: string) {
|
||||
async function runCommandLane(lane: LaneDefinition, options: QualityGateOptions): Promise<LaneResult> {
|
||||
const started = Date.now()
|
||||
const command = lane.command ?? []
|
||||
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
|
||||
const logPath = join(artifactRoot, 'logs', `${sanitizeId(lane.id)}.log`)
|
||||
|
||||
if (options.dryRun) {
|
||||
mkdirSync(dirname(logPath), { recursive: true })
|
||||
writeFileSync(logPath, `$ ${command.join(' ')}\n[quality-gate] skipped: dry run\n`)
|
||||
return {
|
||||
id: lane.id,
|
||||
title: lane.title,
|
||||
@ -49,15 +101,22 @@ async function runCommandLane(lane: LaneDefinition, options: QualityGateOptions)
|
||||
command,
|
||||
durationMs: Date.now() - started,
|
||||
skipReason: 'dry run',
|
||||
logPath,
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(dirname(logPath), { recursive: true })
|
||||
writeFileSync(logPath, `$ ${command.join(' ')}\n`)
|
||||
const proc = Bun.spawn(command, {
|
||||
cwd: options.rootDir,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const exitCode = await proc.exited
|
||||
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)),
|
||||
])
|
||||
|
||||
return {
|
||||
id: lane.id,
|
||||
@ -66,6 +125,7 @@ async function runCommandLane(lane: LaneDefinition, options: QualityGateOptions)
|
||||
command,
|
||||
durationMs: Date.now() - started,
|
||||
exitCode,
|
||||
logPath,
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,6 +190,29 @@ async function runLane(lane: LaneDefinition, options: QualityGateOptions): Promi
|
||||
)
|
||||
}
|
||||
|
||||
if (lane.kind === 'provider-smoke') {
|
||||
const started = Date.now()
|
||||
|
||||
if (!options.allowLive) {
|
||||
return {
|
||||
id: lane.id,
|
||||
title: lane.title,
|
||||
status: 'skipped',
|
||||
durationMs: Date.now() - started,
|
||||
skipReason: 'provider smoke requires --allow-live',
|
||||
}
|
||||
}
|
||||
|
||||
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
|
||||
return executeProviderSmoke(
|
||||
options.rootDir,
|
||||
join(artifactRoot, 'cases', lane.id.replace(/[^a-zA-Z0-9._-]+/g, '-')),
|
||||
lane.id,
|
||||
lane.title,
|
||||
lane.baselineTarget,
|
||||
)
|
||||
}
|
||||
|
||||
return runCommandLane(lane, options)
|
||||
}
|
||||
|
||||
@ -141,6 +224,29 @@ function summarize(results: LaneResult[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function enforceReleaseLiveLanes(
|
||||
options: QualityGateOptions,
|
||||
lanes: LaneDefinition[],
|
||||
results: LaneResult[],
|
||||
) {
|
||||
if (options.mode !== 'release' || options.dryRun) {
|
||||
return results
|
||||
}
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status !== 'skipped' || !lanes[index]?.live) {
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
status: 'failed' as const,
|
||||
error: result.skipReason ?? 'release live lane was skipped',
|
||||
skipReason: undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function runQualityGate(options: QualityGateOptions) {
|
||||
return runQualityGateLanes(options, lanesForMode(options.mode, options.baselineTargets))
|
||||
}
|
||||
@ -155,13 +261,15 @@ export async function runQualityGateLanes(
|
||||
const artifactsRoot = options.artifactsDir ?? join(options.rootDir, 'artifacts', 'quality-runs')
|
||||
const outputDir = join(artifactsRoot, runId)
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
const selectedLanes = filterLanesForOptions(lanes, options)
|
||||
|
||||
const runOptions = { ...options, runId, runOutputDir: outputDir }
|
||||
const results: LaneResult[] = []
|
||||
for (const lane of lanes) {
|
||||
const rawResults: LaneResult[] = []
|
||||
for (const lane of selectedLanes) {
|
||||
const result = await executeLane(lane, runOptions)
|
||||
results.push(result)
|
||||
rawResults.push(result)
|
||||
}
|
||||
const results = enforceReleaseLiveLanes(options, selectedLanes, rawResults)
|
||||
|
||||
const report: QualityGateReport = {
|
||||
schemaVersion: 1,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export type QualityGateMode = 'pr' | 'baseline' | 'release'
|
||||
|
||||
export type LaneKind = 'command' | 'baseline-case' | 'desktop-smoke'
|
||||
export type LaneKind = 'command' | 'baseline-case' | 'desktop-smoke' | 'provider-smoke'
|
||||
|
||||
export type LaneDefinition = {
|
||||
id: string
|
||||
@ -50,6 +50,7 @@ export type LaneResult = {
|
||||
skipReason?: string
|
||||
error?: string
|
||||
artifactDir?: string
|
||||
logPath?: string
|
||||
}
|
||||
|
||||
export type QualityGateOptions = {
|
||||
@ -61,6 +62,8 @@ export type QualityGateOptions = {
|
||||
artifactsDir?: string
|
||||
runOutputDir?: string
|
||||
runId?: string
|
||||
onlyLaneSelectors?: string[]
|
||||
skipLaneSelectors?: string[]
|
||||
}
|
||||
|
||||
export type QualityGateReport = {
|
||||
|
||||
@ -197,6 +197,10 @@ type State = {
|
||||
mainThreadAgentType: string | undefined
|
||||
// Remote mode (--remote flag)
|
||||
isRemoteMode: boolean
|
||||
// True only when Remote Control is connected with inbound control enabled.
|
||||
// Outbound-only CCR mirror mode keeps the bridge handle but cannot receive
|
||||
// peer messages or user-file requests.
|
||||
replBridgeActive: boolean
|
||||
// Direct connect server URL (for display in header)
|
||||
directConnectServerUrl: string | undefined
|
||||
// System prompt section cache state
|
||||
@ -388,11 +392,7 @@ function getInitialState(): State {
|
||||
mainThreadAgentType: undefined,
|
||||
// Remote mode
|
||||
isRemoteMode: false,
|
||||
...(process.env.USER_TYPE === 'ant'
|
||||
? {
|
||||
replBridgeActive: false,
|
||||
}
|
||||
: {}),
|
||||
replBridgeActive: false,
|
||||
// Direct connect server URL
|
||||
directConnectServerUrl: undefined,
|
||||
// System prompt section cache state
|
||||
@ -1090,6 +1090,14 @@ export function setKairosActive(value: boolean): void {
|
||||
STATE.kairosActive = value
|
||||
}
|
||||
|
||||
export function isReplBridgeActive(): boolean {
|
||||
return STATE.replBridgeActive
|
||||
}
|
||||
|
||||
export function setReplBridgeActive(value: boolean): void {
|
||||
STATE.replBridgeActive = value
|
||||
}
|
||||
|
||||
export function getStrictToolResultPairing(): boolean {
|
||||
return STATE.strictToolResultPairing
|
||||
}
|
||||
@ -1771,4 +1779,3 @@ export function getPromptId(): string | null {
|
||||
export function setPromptId(id: string | null): void {
|
||||
STATE.promptId = id
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
20
src/utils/__tests__/replBridgeState.test.ts
Normal file
20
src/utils/__tests__/replBridgeState.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
isReplBridgeActive,
|
||||
resetStateForTests,
|
||||
setReplBridgeActive,
|
||||
} from '../../bootstrap/state.js'
|
||||
|
||||
afterEach(() => {
|
||||
resetStateForTests()
|
||||
})
|
||||
|
||||
describe('REPL bridge bootstrap state', () => {
|
||||
test('tracks inbound-control bridge activity explicitly', () => {
|
||||
expect(isReplBridgeActive()).toBe(false)
|
||||
|
||||
setReplBridgeActive(true)
|
||||
|
||||
expect(isReplBridgeActive()).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user