diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml index f7b2a658..40a7dfa4 100644 --- a/.github/workflows/build-matrix.yml +++ b/.github/workflows/build-matrix.yml @@ -6,7 +6,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false # Runs on main only — don't cancel artifact builds permissions: contents: read @@ -15,6 +15,7 @@ jobs: build: name: Build (${{ matrix.os }}/${{ matrix.arch }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -28,10 +29,10 @@ jobs: - os: windows arch: amd64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -42,13 +43,13 @@ jobs: GOARCH: ${{ matrix.arch }} run: | ext="" - if [ "${{ matrix.os }}" = "windows" ]; then + if [ "$GOOS" = "windows" ]; then ext=".exe" fi - go build -ldflags="-s -w" -o ckb-${{ matrix.os }}-${{ matrix.arch }}${ext} ./cmd/ckb + go build -ldflags="-s -w" -o "ckb-${GOOS}-${GOARCH}${ext}" ./cmd/ckb - name: Upload artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: ckb-${{ matrix.os }}-${{ matrix.arch }} path: ckb-${{ matrix.os }}-${{ matrix.arch }}* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a74e481..35c830b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -17,17 +17,18 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 with: version: latest args: --timeout=5m @@ -35,11 +36,12 @@ jobs: test: name: Test runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -53,11 +55,12 @@ jobs: golden: name: Golden Tests runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -74,14 +77,46 @@ jobs: exit 1 fi + review-tests: + name: Review Engine Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + cache: true + + - name: Run review engine tests + run: go test -v -race ./internal/query/... -run "TestReview|TestHealth|TestBaseline|TestFingerprint|TestSave|TestList|TestLoad|TestCompare|TestCheckTraceability|TestCheckIndependence|TestClassify|TestEstimate|TestSuggest|TestBFS|TestIsConfig|TestDefault|TestDetect|TestMatch|TestCalculate|TestDetermine|TestSort|TestContainsSource|TestCodeHealth|TestCountLines|TestComplexity|TestFileSize" + + - name: Run format tests + run: go test -v ./cmd/ckb/... -run "TestFormatSARIF|TestFormatCodeClimate|TestFormatGitHubActions|TestFormatHuman_|TestFormatMarkdown|TestFormatCompliance" + + - name: Run review golden tests + run: go test -v ./cmd/ckb/... -run "TestGolden" + + - name: Verify review goldens are committed + run: | + go test ./cmd/ckb/... -run TestGolden -update-golden + if ! git diff --exit-code testdata/review/; then + echo "::error::Review golden files are out of date! Run: go test ./cmd/ckb/... -run TestGolden -update-golden" + git diff testdata/review/ + exit 1 + fi + tidycheck: name: Go Mod Tidy runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -98,11 +133,12 @@ jobs: security: name: Security Scan runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -113,7 +149,7 @@ jobs: govulncheck ./... - name: Run Trivy filesystem scan - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 with: scan-type: 'fs' scan-ref: '.' @@ -123,12 +159,13 @@ jobs: build: name: Build runs-on: ubuntu-latest - needs: [lint, test, tidycheck, security] + timeout-minutes: 10 + needs: [lint, test, review-tests, tidycheck, security] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -140,9 +177,117 @@ jobs: run: ./ckb version - name: Upload binary - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: ckb-linux-amd64 path: ckb retention-days: 7 + pr-review: + name: PR Review + if: always() && github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [build] + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Download CKB binary + id: download + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: ckb-linux-amd64 + + - name: Build CKB (fallback) + if: steps.download.outcome == 'failure' + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: 'go.mod' + cache: true + + - name: Build CKB binary (fallback) + if: steps.download.outcome == 'failure' + run: go build -ldflags="-s -w" -o ckb ./cmd/ckb + + - name: Install CKB + run: chmod +x ckb && sudo mv ckb /usr/local/bin/ + + - name: Initialize and index + run: | + ckb init + ckb index 2>/dev/null || echo "Indexing skipped (no supported indexer)" + + - name: Run review + id: review + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set +e + ckb review --ci --base="${BASE_REF}" --format=json > review.json 2>&1 + EXIT_CODE=$? + set -e + + echo "verdict=$(jq -r '.verdict // "unknown"' review.json)" >> "$GITHUB_OUTPUT" + echo "score=$(jq -r '.score // 0' review.json)" >> "$GITHUB_OUTPUT" + echo "findings=$(jq -r '.findings | length // 0' review.json)" >> "$GITHUB_OUTPUT" + echo "exit_code=${EXIT_CODE}" >> "$GITHUB_OUTPUT" + + - name: GitHub Actions annotations + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: ckb review --base="${BASE_REF}" --format=github-actions 2>/dev/null || true + + - name: Post PR comment + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + MARKDOWN=$(ckb review --base="${BASE_REF}" --format=markdown 2>/dev/null || echo "CKB review failed to generate output.") + MARKER="" + + COMMENT_ID=$(gh api \ + "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + 2>/dev/null | head -1) + + if [ -n "${COMMENT_ID}" ]; then + gh api \ + "repos/${GH_REPO}/issues/comments/${COMMENT_ID}" \ + -X PATCH \ + -f body="${MARKDOWN}" + else + gh api \ + "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ + -f body="${MARKDOWN}" + fi + + - name: Summary + env: + VERDICT: ${{ steps.review.outputs.verdict }} + SCORE: ${{ steps.review.outputs.score }} + FINDINGS: ${{ steps.review.outputs.findings }} + run: | + echo "### CKB Review" >> "$GITHUB_STEP_SUMMARY" + echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Verdict | ${VERDICT} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Findings | ${FINDINGS} |" >> "$GITHUB_STEP_SUMMARY" + + - name: Fail on review verdict + env: + REVIEW_EXIT_CODE: ${{ steps.review.outputs.exit_code }} + SCORE: ${{ steps.review.outputs.score }} + run: | + if [ "${REVIEW_EXIT_CODE}" = "1" ]; then + echo "::error::CKB review failed (score: ${SCORE})" + exit 1 + fi + diff --git a/.github/workflows/ckb.yml b/.github/workflows/ckb.yml index 166e6485..0b43185f 100644 --- a/.github/workflows/ckb.yml +++ b/.github/workflows/ckb.yml @@ -37,8 +37,8 @@ on: default: false concurrency: - group: ckb-${{ github.ref }} - cancel-in-progress: true + group: ckb-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -65,6 +65,9 @@ jobs: name: Analyze runs-on: ubuntu-latest if: github.event_name == 'pull_request' + timeout-minutes: 30 + env: + BASE_REF: ${{ github.base_ref }} outputs: risk: ${{ steps.summary.outputs.risk }} score: ${{ steps.summary.outputs.score }} @@ -72,11 +75,11 @@ jobs: # ─────────────────────────────────────────────────────────────────────── # Setup # ─────────────────────────────────────────────────────────────────────── - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - uses: actions/setup-go@v6 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -92,7 +95,7 @@ jobs: # ─────────────────────────────────────────────────────────────────────── - name: Cache id: cache - uses: actions/cache@v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: .ckb/ key: ckb-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ github.base_ref }} @@ -132,7 +135,7 @@ jobs: - name: PR Summary id: summary run: | - ./ckb pr-summary --base=origin/${{ github.base_ref }} --format=json > analysis.json 2>/dev/null || echo '{}' > analysis.json + ./ckb pr-summary --base=origin/$BASE_REF --format=json > analysis.json 2>/dev/null || echo '{}' > analysis.json echo "risk=$(jq -r '.riskAssessment.level // "unknown"' analysis.json)" >> $GITHUB_OUTPUT echo "score=$(jq -r '.riskAssessment.score // 0' analysis.json)" >> $GITHUB_OUTPUT @@ -144,8 +147,8 @@ jobs: id: impact run: | # Generate both JSON (for metrics) and Markdown (for comment) - ./ckb impact diff --base=origin/${{ github.base_ref }} --depth=2 --format=json > impact.json 2>/dev/null || echo '{"summary":{}}' > impact.json - ./ckb impact diff --base=origin/${{ github.base_ref }} --depth=2 --format=markdown > impact.md 2>/dev/null || echo "## ⚪ Change Impact Analysis\n\nNo impact data available." > impact.md + ./ckb impact diff --base=origin/$BASE_REF --depth=2 --format=json > impact.json 2>/dev/null || echo '{"summary":{}}' > impact.json + ./ckb impact diff --base=origin/$BASE_REF --depth=2 --format=markdown > impact.md 2>/dev/null || echo "## ⚪ Change Impact Analysis\n\nNo impact data available." > impact.md # Extract key metrics echo "symbols_changed=$(jq '.summary.symbolsChanged // 0' impact.json)" >> $GITHUB_OUTPUT @@ -169,7 +172,7 @@ jobs: fi - name: Post Impact Comment - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2 with: header: ckb-impact path: impact.md @@ -180,7 +183,7 @@ jobs: echo '[]' > complexity.json VIOLATIONS=0 - for f in $(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(go|ts|js|py)$' | head -20); do + for f in $(git diff --name-only origin/$BASE_REF...HEAD | grep -E '\.(go|ts|js|py)$' | head -20); do [ -f "$f" ] || continue r=$(./ckb complexity "$f" --format=json 2>/dev/null || echo '{}') cy=$(echo "$r" | jq '.summary.maxCyclomatic // 0') @@ -208,7 +211,7 @@ jobs: id: coupling run: | # Get list of changed files for comparison - changed_files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(go|ts|js|py)$' || true) + changed_files=$(git diff --name-only origin/$BASE_REF...HEAD | grep -E '\.(go|ts|js|py)$' || true) echo '[]' > missing_coupled.json for f in $(echo "$changed_files" | head -10); do @@ -239,7 +242,7 @@ jobs: run: | echo '{"files":[],"breaking":[]}' > contracts.json - contracts=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(proto|graphql|gql|openapi\.ya?ml)$' || true) + contracts=$(git diff --name-only origin/$BASE_REF...HEAD | grep -E '\.(proto|graphql|gql|openapi\.ya?ml)$' || true) if [ -n "$contracts" ]; then # List contract files - breaking change detection not available in CLI @@ -313,7 +316,7 @@ jobs: - name: Affected Tests id: affected_tests run: | - RANGE="origin/${{ github.base_ref }}..HEAD" + RANGE="origin/$BASE_REF..HEAD" ./ckb affected-tests --range="$RANGE" --format=json > affected-tests.json 2>/dev/null || echo '{"tests":[],"strategy":"none"}' > affected-tests.json echo "count=$(jq '.tests | length' affected-tests.json)" >> $GITHUB_OUTPUT @@ -374,7 +377,7 @@ jobs: # ─────────────────────────────────────────────────────────────────────── - name: Comment if: always() - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} INDEX_MODE: ${{ steps.index.outputs.mode }} @@ -925,7 +928,7 @@ jobs: - name: Reviewers if: always() continue-on-error: true - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const fs = require('fs'); @@ -951,14 +954,14 @@ jobs: # ─────────────────────────────────────────────────────────────────────── - name: Save Cache if: always() - uses: actions/cache/save@v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: .ckb/ key: ckb-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ github.base_ref }} - name: Upload if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: ckb-analysis path: '*.json' @@ -971,12 +974,13 @@ jobs: name: Refresh runs-on: ubuntu-latest if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - uses: actions/setup-go@v6 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -988,7 +992,7 @@ jobs: run: go install github.com/sourcegraph/scip-go/cmd/scip-go@latest - name: Cache - uses: actions/cache@v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: .ckb/ key: ckb-${{ runner.os }}-refresh-${{ github.run_id }} @@ -1031,7 +1035,7 @@ jobs: echo "| Language Quality | $(jq '.overallQuality * 100 | floor' reports/languages.json)% |" >> $GITHUB_STEP_SUMMARY - name: Upload - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: ckb-refresh path: reports/ diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml index 72c0bf02..40b48685 100644 --- a/.github/workflows/cov.yml +++ b/.github/workflows/cov.yml @@ -9,7 +9,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -18,13 +18,14 @@ jobs: coverage: name: Test Coverage runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 # Required for Codecov to determine PR base SHA - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -58,7 +59,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY - name: Upload to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 with: files: coverage.out flags: unit @@ -68,7 +69,7 @@ jobs: - name: Upload coverage if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: coverage path: | diff --git a/.github/workflows/nfr.yml b/.github/workflows/nfr.yml index 55cf6349..1241498d 100644 --- a/.github/workflows/nfr.yml +++ b/.github/workflows/nfr.yml @@ -16,11 +16,12 @@ jobs: nfr-head: name: NFR (PR Head) runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -38,7 +39,7 @@ jobs: exit 0 - name: Upload head results - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: nfr-head path: nfr-output.txt @@ -47,13 +48,14 @@ jobs: nfr-base: name: NFR (Base Branch) runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.event.pull_request.base.sha }} - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -70,7 +72,7 @@ jobs: exit 0 - name: Upload base results - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: nfr-base path: nfr-output.txt @@ -79,17 +81,18 @@ jobs: nfr-compare: name: NFR Compare runs-on: ubuntu-latest + timeout-minutes: 10 needs: [nfr-head, nfr-base] if: always() steps: - name: Download head results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: nfr-head path: head/ - name: Download base results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: nfr-base path: base/ @@ -267,7 +270,7 @@ jobs: - name: Comment on PR if: always() && github.event_name == 'pull_request' - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const fs = require('fs'); @@ -305,7 +308,7 @@ jobs: - name: Upload NFR results if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: nfr-results path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e8ce870..73e27bcc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,20 +15,21 @@ permissions: jobs: release: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: '20' registry-url: 'https://registry.npmjs.org' @@ -37,7 +38,7 @@ jobs: run: go test -race ./... - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 with: version: '~> v2' args: release --clean @@ -50,8 +51,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION=${GITHUB_REF#refs/tags/v} - # Wait for release assets to be available - sleep 10 + # Wait for release assets with polling + for i in $(seq 1 30); do + if gh release view "v${VERSION}" --json assets --jq '.assets[].name' 2>/dev/null | grep -q "checksums.txt"; then + break + fi + echo "Waiting for release assets... (attempt $i/30)" + sleep 5 + done curl -sLO "https://github.com/SimplyLiz/CodeMCP/releases/download/v${VERSION}/checksums.txt" - name: Publish npm packages diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 809cbb10..ba43edcf 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -50,16 +50,14 @@ env: MIN_SEVERITY: 'high' concurrency: - group: security-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: security-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name != 'schedule' }} # Permissions inherited by reusable workflows permissions: contents: read security-events: write pull-requests: write - id-token: write - attestations: write jobs: # ============================================================================ diff --git a/.github/workflows/security-dependencies.yml b/.github/workflows/security-dependencies.yml index 1db4ea62..ace9b2f6 100644 --- a/.github/workflows/security-dependencies.yml +++ b/.github/workflows/security-dependencies.yml @@ -50,6 +50,7 @@ jobs: deps: name: Dependency Scan runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read security-events: write @@ -65,12 +66,12 @@ jobs: total_findings: ${{ steps.summary.outputs.total }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 # ==================== Go Setup (if needed) ==================== - name: Set up Go if: inputs.has_go && (inputs.scan_govulncheck || inputs.scan_trivy) - uses: actions/setup-go@v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -78,7 +79,7 @@ jobs: # ==================== Trivy ==================== - name: Setup Trivy if: inputs.scan_trivy - uses: aquasecurity/setup-trivy@v0.2.3 + uses: aquasecurity/setup-trivy@9ea583eb67910444b1f64abf338bd2e105a0a93d # v0.2.3 with: cache: true version: latest @@ -141,7 +142,7 @@ jobs: - name: Upload Trivy SARIF if: inputs.scan_trivy && hashFiles('trivy-vuln.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: sarif_file: trivy-vuln.sarif category: trivy @@ -149,7 +150,7 @@ jobs: - name: Attest SBOM if: inputs.scan_trivy && inputs.generate_sbom && github.event_name != 'pull_request' && hashFiles('sbom.json') != '' - uses: actions/attest-sbom@v2 + uses: actions/attest-sbom@bd218ad0dbcb3e146bd073d1d9c6d78e08aa8a0b # v2 with: subject-path: 'sbom.json' sbom-path: 'sbom.json' @@ -197,10 +198,15 @@ jobs: # ==================== Summary ==================== - name: Calculate totals id: summary + env: + TRIVY_OUT: ${{ steps.trivy.outputs.findings }} + GOVULN_OUT: ${{ steps.govulncheck.outputs.findings }} + OSV_OUT: ${{ steps.osv.outputs.findings }} + LICENSE_OUT: ${{ steps.trivy_license.outputs.findings }} run: | - TRIVY="${{ steps.trivy.outputs.findings || 0 }}" - GOVULN="${{ steps.govulncheck.outputs.findings || 0 }}" - OSV="${{ steps.osv.outputs.findings || 0 }}" + TRIVY="${TRIVY_OUT:-0}" + GOVULN="${GOVULN_OUT:-0}" + OSV="${OSV_OUT:-0}" TOTAL=$((TRIVY + GOVULN + OSV)) echo "total=$TOTAL" >> $GITHUB_OUTPUT @@ -210,11 +216,11 @@ jobs: echo "| Trivy | $TRIVY (${TRIVY_CRITICAL:-0} critical, ${TRIVY_HIGH:-0} high) |" >> $GITHUB_STEP_SUMMARY echo "| Govulncheck | $GOVULN |" >> $GITHUB_STEP_SUMMARY echo "| OSV-Scanner | $OSV |" >> $GITHUB_STEP_SUMMARY - echo "| Licenses | ${{ steps.trivy_license.outputs.findings || 0 }} non-permissive |" >> $GITHUB_STEP_SUMMARY + echo "| Licenses | ${LICENSE_OUT:-0} non-permissive |" >> $GITHUB_STEP_SUMMARY echo "| **Total** | **$TOTAL** |" >> $GITHUB_STEP_SUMMARY - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: dependency-scan-results diff --git a/.github/workflows/security-detect.yml b/.github/workflows/security-detect.yml index 99a3d270..91dd0fca 100644 --- a/.github/workflows/security-detect.yml +++ b/.github/workflows/security-detect.yml @@ -23,6 +23,9 @@ jobs: detect: name: Detect Languages runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read outputs: has_go: ${{ steps.detect.outputs.has_go }} has_python: ${{ steps.detect.outputs.has_python }} @@ -31,7 +34,7 @@ jobs: languages: ${{ steps.detect.outputs.languages }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: sparse-checkout: | go.mod diff --git a/.github/workflows/security-gate.yml b/.github/workflows/security-gate.yml index 4e424870..9c05c2a8 100644 --- a/.github/workflows/security-gate.yml +++ b/.github/workflows/security-gate.yml @@ -73,6 +73,7 @@ jobs: gate: name: Security Gate runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read pull-requests: write @@ -81,30 +82,46 @@ jobs: reason: ${{ steps.evaluate.outputs.reason }} steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: path: results continue-on-error: true - name: Evaluate Security Gate id: evaluate + env: + INPUT_SECRETS: ${{ inputs.secret_findings }} + INPUT_TRUFFLEHOG: ${{ inputs.trufflehog_findings }} + INPUT_GOSEC: ${{ inputs.gosec_findings }} + INPUT_GOSEC_HIGH: ${{ inputs.gosec_high }} + INPUT_BANDIT: ${{ inputs.bandit_findings }} + INPUT_BANDIT_HIGH: ${{ inputs.bandit_high }} + INPUT_SEMGREP: ${{ inputs.semgrep_findings }} + INPUT_TRIVY: ${{ inputs.trivy_findings }} + INPUT_TRIVY_CRITICAL: ${{ inputs.trivy_critical }} + INPUT_TRIVY_HIGH: ${{ inputs.trivy_high }} + INPUT_LICENSES: ${{ inputs.trivy_licenses }} + INPUT_GOVULN: ${{ inputs.govulncheck_findings }} + INPUT_OSV: ${{ inputs.osv_findings }} + INPUT_HAS_GO: ${{ inputs.has_go }} + INPUT_HAS_PYTHON: ${{ inputs.has_python }} run: | # Input aggregation - SECRETS="${{ inputs.secret_findings }}" - TRUFFLEHOG="${{ inputs.trufflehog_findings }}" - GOSEC="${{ inputs.gosec_findings }}" - GOSEC_HIGH="${{ inputs.gosec_high }}" - BANDIT="${{ inputs.bandit_findings }}" - BANDIT_HIGH="${{ inputs.bandit_high }}" - SEMGREP="${{ inputs.semgrep_findings }}" - TRIVY="${{ inputs.trivy_findings }}" - TRIVY_CRITICAL="${{ inputs.trivy_critical }}" - TRIVY_HIGH="${{ inputs.trivy_high }}" - LICENSES="${{ inputs.trivy_licenses }}" - GOVULN="${{ inputs.govulncheck_findings }}" - OSV="${{ inputs.osv_findings }}" - HAS_GO="${{ inputs.has_go }}" - HAS_PYTHON="${{ inputs.has_python }}" + SECRETS="$INPUT_SECRETS" + TRUFFLEHOG="$INPUT_TRUFFLEHOG" + GOSEC="$INPUT_GOSEC" + GOSEC_HIGH="$INPUT_GOSEC_HIGH" + BANDIT="$INPUT_BANDIT" + BANDIT_HIGH="$INPUT_BANDIT_HIGH" + SEMGREP="$INPUT_SEMGREP" + TRIVY="$INPUT_TRIVY" + TRIVY_CRITICAL="$INPUT_TRIVY_CRITICAL" + TRIVY_HIGH="$INPUT_TRIVY_HIGH" + LICENSES="$INPUT_LICENSES" + GOVULN="$INPUT_GOVULN" + OSV="$INPUT_OSV" + HAS_GO="$INPUT_HAS_GO" + HAS_PYTHON="$INPUT_HAS_PYTHON" # Calculate totals SAST=$((GOSEC + BANDIT + SEMGREP)) @@ -184,7 +201,7 @@ jobs: - name: PR Comment if: github.event_name == 'pull_request' - uses: actions/github-script@v7 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const fs = require('fs'); @@ -449,6 +466,8 @@ jobs: - name: Fail on blocking findings if: steps.evaluate.outputs.status == 'failed' + env: + GATE_REASON: ${{ steps.evaluate.outputs.reason }} run: | - echo "::error::Security gate failed: ${{ steps.evaluate.outputs.reason }}" + echo "::error::Security gate failed: $GATE_REASON" exit 1 diff --git a/.github/workflows/security-sast-common.yml b/.github/workflows/security-sast-common.yml index 68d861a2..0f46c887 100644 --- a/.github/workflows/security-sast-common.yml +++ b/.github/workflows/security-sast-common.yml @@ -26,6 +26,7 @@ jobs: semgrep: name: Semgrep SAST runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read security-events: write @@ -35,22 +36,25 @@ jobs: medium: ${{ steps.scan.outputs.medium }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Run Semgrep id: scan - uses: docker://semgrep/semgrep:latest - with: - args: > - semgrep scan - --config=${{ inputs.config }} - ${{ inputs.extra_config != '' && format('--config={0}', inputs.extra_config) || '' }} - --json - --output=semgrep.json - --sarif - --sarif-output=semgrep.sarif - . - continue-on-error: true + env: + SEMGREP_CONFIG: ${{ inputs.config }} + SEMGREP_EXTRA_CONFIG: ${{ inputs.extra_config }} + run: | + EXTRA_ARG="" + if [ -n "$SEMGREP_EXTRA_CONFIG" ]; then + EXTRA_ARG="--config=$SEMGREP_EXTRA_CONFIG" + fi + docker run --rm -v "$PWD:/src" -w /src semgrep/semgrep:1.156.0 \ + semgrep scan \ + --config="$SEMGREP_CONFIG" \ + $EXTRA_ARG \ + --json --output=semgrep.json \ + --sarif --sarif-output=semgrep.sarif \ + . || true - name: Parse results id: parse @@ -87,14 +91,14 @@ jobs: - name: Upload SARIF if: hashFiles('semgrep.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: sarif_file: semgrep.sarif category: semgrep continue-on-error: true - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: semgrep-results diff --git a/.github/workflows/security-sast-go.yml b/.github/workflows/security-sast-go.yml index b2fb1279..9b05d592 100644 --- a/.github/workflows/security-sast-go.yml +++ b/.github/workflows/security-sast-go.yml @@ -32,6 +32,7 @@ jobs: gosec: name: Gosec Security Scan runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read security-events: write @@ -43,10 +44,10 @@ jobs: suppressed: ${{ steps.scan.outputs.suppressed }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -56,12 +57,15 @@ jobs: - name: Run Gosec id: scan + env: + EXCLUDE_DIRS_INPUT: ${{ inputs.exclude_dirs }} + EXCLUDE_RULES_INPUT: ${{ inputs.exclude_rules }} run: | echo "::group::Gosec Security Scan" # Build exclude-dir arguments EXCLUDE_ARGS="" - IFS=',' read -ra DIRS <<< "${{ inputs.exclude_dirs }}" + IFS=',' read -ra DIRS <<< "$EXCLUDE_DIRS_INPUT" for dir in "${DIRS[@]}"; do dir=$(echo "$dir" | xargs) # trim whitespace if [ -n "$dir" ]; then @@ -71,8 +75,8 @@ jobs: # Build exclude rules argument EXCLUDE_RULES="" - if [ -n "${{ inputs.exclude_rules }}" ]; then - EXCLUDE_RULES="-exclude=${{ inputs.exclude_rules }}" + if [ -n "$EXCLUDE_RULES_INPUT" ]; then + EXCLUDE_RULES="-exclude=$EXCLUDE_RULES_INPUT" fi # Run gosec with JSON output @@ -130,14 +134,14 @@ jobs: echo "| **Total** | **$FINDINGS** |" >> $GITHUB_STEP_SUMMARY - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: sarif_file: gosec.sarif category: gosec continue-on-error: true - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: gosec-results diff --git a/.github/workflows/security-sast-python.yml b/.github/workflows/security-sast-python.yml index 4368e50a..253e858d 100644 --- a/.github/workflows/security-sast-python.yml +++ b/.github/workflows/security-sast-python.yml @@ -33,6 +33,7 @@ jobs: bandit: name: Bandit Security Scan runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read security-events: write @@ -43,10 +44,10 @@ jobs: low: ${{ steps.scan.outputs.low }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: '3.x' @@ -55,24 +56,28 @@ jobs: - name: Run Bandit id: scan + env: + EXCLUDE_DIRS_INPUT: ${{ inputs.exclude_dirs }} + SKIP_TESTS_INPUT: ${{ inputs.skip_tests }} + SEVERITY_INPUT: ${{ inputs.severity_threshold }} run: | echo "::group::Bandit Security Scan" # Build exclude argument EXCLUDE_ARG="" - if [ -n "${{ inputs.exclude_dirs }}" ]; then - EXCLUDE_ARG="--exclude ${{ inputs.exclude_dirs }}" + if [ -n "$EXCLUDE_DIRS_INPUT" ]; then + EXCLUDE_ARG="--exclude $EXCLUDE_DIRS_INPUT" fi # Build skip tests argument SKIP_ARG="" - if [ -n "${{ inputs.skip_tests }}" ]; then - SKIP_ARG="--skip ${{ inputs.skip_tests }}" + if [ -n "$SKIP_TESTS_INPUT" ]; then + SKIP_ARG="--skip $SKIP_TESTS_INPUT" fi # Severity filter SEVERITY_ARG="" - case "${{ inputs.severity_threshold }}" in + case "$SEVERITY_INPUT" in high) SEVERITY_ARG="-lll" ;; medium) SEVERITY_ARG="-ll" ;; low) SEVERITY_ARG="-l" ;; @@ -129,14 +134,14 @@ jobs: - name: Upload SARIF if: hashFiles('bandit.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: sarif_file: bandit.sarif category: bandit continue-on-error: true - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: bandit-results diff --git a/.github/workflows/security-secrets.yml b/.github/workflows/security-secrets.yml index a3e65d6c..c6f6ae3f 100644 --- a/.github/workflows/security-secrets.yml +++ b/.github/workflows/security-secrets.yml @@ -44,6 +44,7 @@ jobs: secrets: name: Secret Detection runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read security-events: write @@ -55,14 +56,14 @@ jobs: errors: ${{ steps.summary.outputs.errors }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: ${{ inputs.scan_history && 0 || 50 }} # ==================== CKB Secret Scanner ==================== - name: Set up Go (for CKB) if: inputs.scan_ckb - uses: actions/setup-go@v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: 'go.mod' cache: true @@ -79,10 +80,12 @@ jobs: - name: CKB Secret Scan id: ckb if: inputs.scan_ckb + env: + MIN_SEVERITY: ${{ inputs.min_severity }} run: | if [ -x "./ckb" ]; then ./ckb init 2>/dev/null || true - ./ckb scan-secrets --min-severity="${{ inputs.min_severity }}" \ + ./ckb scan-secrets --min-severity="$MIN_SEVERITY" \ --exclude="internal/secrets/patterns.go" \ --exclude="*_test.go" \ --exclude="testdata/*" \ @@ -92,7 +95,7 @@ jobs: echo "findings=$FINDINGS" >> $GITHUB_OUTPUT # Generate SARIF - ./ckb scan-secrets --min-severity="${{ inputs.min_severity }}" \ + ./ckb scan-secrets --min-severity="$MIN_SEVERITY" \ --exclude="internal/secrets/patterns.go" \ --exclude="*_test.go" \ --exclude="testdata/*" \ @@ -118,7 +121,7 @@ jobs: - name: Upload CKB SARIF to Code Scanning if: inputs.scan_ckb && steps.ckb_sarif.outputs.valid == 'true' - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: sarif_file: ckb-secrets.sarif category: ckb-secrets @@ -148,7 +151,7 @@ jobs: - name: Upload Gitleaks SARIF if: inputs.scan_gitleaks && hashFiles('gitleaks.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: sarif_file: gitleaks.sarif category: gitleaks @@ -157,8 +160,14 @@ jobs: # ==================== TruffleHog ==================== - name: Install TruffleHog if: inputs.scan_trufflehog + env: + TRUFFLEHOG_VERSION: '3.93.8' run: | - curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin + curl -sSfL "https://github.com/trufflesecurity/trufflehog/releases/download/v${TRUFFLEHOG_VERSION}/trufflehog_${TRUFFLEHOG_VERSION}_linux_amd64.tar.gz" -o trufflehog.tar.gz + tar xzf trufflehog.tar.gz trufflehog + chmod +x trufflehog + sudo mv trufflehog /usr/local/bin/ + rm trufflehog.tar.gz - name: TruffleHog Scan id: trufflehog @@ -180,17 +189,22 @@ jobs: # ==================== Summary ==================== - name: Calculate totals id: summary + env: + CKB_FINDINGS: ${{ steps.ckb.outputs.findings || 0 }} + GITLEAKS_FINDINGS: ${{ steps.gitleaks.outputs.findings || 0 }} + TRUFFLEHOG_FINDINGS: ${{ steps.trufflehog.outputs.findings || 0 }} + CKB_SARIF_ERROR: ${{ steps.ckb_sarif.outputs.error || '' }} run: | - CKB="${{ steps.ckb.outputs.findings || 0 }}" - GITLEAKS="${{ steps.gitleaks.outputs.findings || 0 }}" - TRUFFLEHOG="${{ steps.trufflehog.outputs.findings || 0 }}" + CKB="$CKB_FINDINGS" + GITLEAKS="$GITLEAKS_FINDINGS" + TRUFFLEHOG="$TRUFFLEHOG_FINDINGS" TOTAL=$((CKB + GITLEAKS + TRUFFLEHOG)) echo "total=$TOTAL" >> $GITHUB_OUTPUT # Collect errors ERRORS="" - if [ "${{ steps.ckb_sarif.outputs.error || '' }}" != "" ]; then - ERRORS="CKB: ${{ steps.ckb_sarif.outputs.error }}" + if [ "$CKB_SARIF_ERROR" != "" ]; then + ERRORS="CKB: $CKB_SARIF_ERROR" fi echo "errors=$ERRORS" >> $GITHUB_OUTPUT @@ -203,7 +217,7 @@ jobs: echo "| **Total** | **$TOTAL** |" >> $GITHUB_STEP_SUMMARY - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() with: name: secret-scan-results diff --git a/CLAUDE.md b/CLAUDE.md index ba4af813..d6f759f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,11 @@ golangci-lint run # Start MCP server (for AI tool integration) ./ckb mcp +# Run PR review (17 quality checks) +./ckb review +./ckb review --base=develop --format=markdown +./ckb review --checks=breaking,secrets,health --ci + # Auto-configure AI tool integration (interactive) ./ckb setup @@ -115,6 +120,8 @@ claude mcp add ckb -- npx @tastehub/ckb mcp **Index Management (v8.0):** `reindex` (trigger index refresh), enhanced `getStatus` with health tiers +**PR Review (v8.2):** `reviewPR` — unified review with 17 quality checks (breaking, secrets, tests, complexity, health, coupling, hotspots, risk, critical-path, traceability, independence, generated, classify, split, dead-code, test-gaps, blast-radius) + ## Architecture Overview CKB is a code intelligence orchestration layer with three interfaces (CLI, HTTP API, MCP) that all flow through a central query engine. diff --git a/action/ckb-review/action.yml b/action/ckb-review/action.yml new file mode 100644 index 00000000..144bfabd --- /dev/null +++ b/action/ckb-review/action.yml @@ -0,0 +1,176 @@ +name: 'CKB Code Review' +description: 'Automated structural code review with quality gates' +branding: + icon: 'check-circle' + color: 'blue' + +inputs: + checks: + description: 'Comma-separated list of checks to run (default: all)' + required: false + default: '' + fail-on: + description: 'Fail on level: error (default), warning, or none' + required: false + default: '' + comment: + description: 'Post PR comment with markdown results' + required: false + default: 'true' + sarif: + description: 'Upload SARIF to GitHub Code Scanning' + required: false + default: 'false' + critical-paths: + description: 'Comma-separated glob patterns for safety-critical paths' + required: false + default: '' + require-trace: + description: 'Require ticket references in commits' + required: false + default: 'false' + trace-patterns: + description: 'Comma-separated regex patterns for ticket IDs' + required: false + default: '' + require-independent: + description: 'Require independent reviewer (author != reviewer)' + required: false + default: 'false' + max-fanout: + description: 'Maximum fan-out / caller count for blast-radius check (0 = disabled)' + required: false + default: '0' + dead-code-confidence: + description: 'Minimum confidence for dead code findings (0.0-1.0)' + required: false + default: '0.8' + test-gap-lines: + description: 'Minimum function lines for test gap reporting' + required: false + default: '5' + +outputs: + verdict: + description: 'Review verdict: pass, warn, or fail' + value: ${{ steps.review.outputs.verdict }} + score: + description: 'Review score (0-100)' + value: ${{ steps.review.outputs.score }} + findings: + description: 'Number of findings' + value: ${{ steps.review.outputs.findings }} + +runs: + using: 'composite' + steps: + - name: Install CKB + shell: bash + run: npm install -g @tastehub/ckb + + - name: Index codebase + shell: bash + run: ckb index 2>/dev/null || echo "Indexing skipped (no supported indexer)" + + - name: Run review (all formats in one pass) + id: review + shell: bash + env: + INPUT_CHECKS: ${{ inputs.checks }} + INPUT_FAIL_ON: ${{ inputs.fail-on }} + INPUT_CRITICAL_PATHS: ${{ inputs.critical-paths }} + INPUT_REQUIRE_TRACE: ${{ inputs.require-trace }} + INPUT_TRACE_PATTERNS: ${{ inputs.trace-patterns }} + INPUT_REQUIRE_INDEPENDENT: ${{ inputs.require-independent }} + INPUT_MAX_FANOUT: ${{ inputs.max-fanout }} + INPUT_DEAD_CODE_CONFIDENCE: ${{ inputs.dead-code-confidence }} + INPUT_TEST_GAP_LINES: ${{ inputs.test-gap-lines }} + BASE_REF: ${{ github.event.pull_request.base.ref || 'main' }} + run: | + FLAGS="--ci --base=${BASE_REF}" + [ -n "${INPUT_CHECKS}" ] && FLAGS="${FLAGS} --checks=${INPUT_CHECKS}" + [ -n "${INPUT_FAIL_ON}" ] && FLAGS="${FLAGS} --fail-on=${INPUT_FAIL_ON}" + [ -n "${INPUT_CRITICAL_PATHS}" ] && FLAGS="${FLAGS} --critical-paths=${INPUT_CRITICAL_PATHS}" + [ "${INPUT_REQUIRE_TRACE}" = "true" ] && FLAGS="${FLAGS} --require-trace" + [ -n "${INPUT_TRACE_PATTERNS}" ] && FLAGS="${FLAGS} --trace-patterns=${INPUT_TRACE_PATTERNS}" + [ "${INPUT_REQUIRE_INDEPENDENT}" = "true" ] && FLAGS="${FLAGS} --require-independent" + [ "${INPUT_MAX_FANOUT}" != "0" ] && FLAGS="${FLAGS} --max-fanout=${INPUT_MAX_FANOUT}" + [ "${INPUT_DEAD_CODE_CONFIDENCE}" != "0.8" ] && FLAGS="${FLAGS} --dead-code-confidence=${INPUT_DEAD_CODE_CONFIDENCE}" + [ "${INPUT_TEST_GAP_LINES}" != "5" ] && FLAGS="${FLAGS} --test-gap-lines=${INPUT_TEST_GAP_LINES}" + + # Run review for each output format (JSON for outputs, GHA for annotations, markdown for PR comment) + set +e + ckb review ${FLAGS} --format=json > review.json 2>&1 + EXIT_CODE=$? + set -e + + ckb review ${FLAGS} --format=github-actions > review-gha.txt 2>/dev/null || true + ckb review ${FLAGS} --format=markdown > review-markdown.txt 2>/dev/null || true + + # Extract outputs from JSON + echo "verdict=$(jq -r .verdict review.json 2>/dev/null || echo unknown)" >> "$GITHUB_OUTPUT" + echo "score=$(jq -r .score review.json 2>/dev/null || echo 0)" >> "$GITHUB_OUTPUT" + echo "findings=$(jq -r '.findings | length' review.json 2>/dev/null || echo 0)" >> "$GITHUB_OUTPUT" + echo "exit_code=${EXIT_CODE}" >> "$GITHUB_OUTPUT" + + - name: Print GitHub Actions annotations + shell: bash + run: cat review-gha.txt 2>/dev/null || true + + - name: Post PR comment + if: inputs.comment == 'true' && github.event_name == 'pull_request' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + MARKDOWN=$(cat review-markdown.txt 2>/dev/null || echo "CKB review failed to generate markdown output.") + MARKER="" + + # Find existing comment + COMMENT_ID=$(gh api \ + "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + 2>/dev/null | head -1) + + if [ -n "${COMMENT_ID}" ]; then + gh api \ + "repos/${GH_REPO}/issues/comments/${COMMENT_ID}" \ + -X PATCH \ + -f body="${MARKDOWN}" + else + gh api \ + "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ + -f body="${MARKDOWN}" + fi + + - name: Upload SARIF + if: inputs.sarif == 'true' + shell: bash + env: + INPUT_CHECKS: ${{ inputs.checks }} + INPUT_FAIL_ON: ${{ inputs.fail-on }} + INPUT_CRITICAL_PATHS: ${{ inputs.critical-paths }} + INPUT_REQUIRE_TRACE: ${{ inputs.require-trace }} + INPUT_TRACE_PATTERNS: ${{ inputs.trace-patterns }} + INPUT_REQUIRE_INDEPENDENT: ${{ inputs.require-independent }} + BASE_REF: ${{ github.event.pull_request.base.ref || 'main' }} + run: | + FLAGS="--base=${BASE_REF}" + [ -n "${INPUT_CHECKS}" ] && FLAGS="${FLAGS} --checks=${INPUT_CHECKS}" + [ -n "${INPUT_CRITICAL_PATHS}" ] && FLAGS="${FLAGS} --critical-paths=${INPUT_CRITICAL_PATHS}" + ckb review ${FLAGS} --format=sarif > results.sarif + + - name: Upload SARIF to GitHub + if: inputs.sarif == 'true' + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 + with: + sarif_file: results.sarif + + - name: Set exit code + shell: bash + if: steps.review.outputs.exit_code != '0' + env: + REVIEW_EXIT_CODE: ${{ steps.review.outputs.exit_code }} + run: exit "${REVIEW_EXIT_CODE}" diff --git a/ci/gitlab-ckb-review.yml b/ci/gitlab-ckb-review.yml new file mode 100644 index 00000000..d27dc6a4 --- /dev/null +++ b/ci/gitlab-ckb-review.yml @@ -0,0 +1,79 @@ +# CKB Code Review — GitLab CI/CD Template +# +# Include in your .gitlab-ci.yml: +# +# include: +# - remote: 'https://raw.githubusercontent.com/SimplyLiz/CodeMCP/main/ci/gitlab-ckb-review.yml' +# +# Or copy this file into your project and include locally: +# +# include: +# - local: 'ci/gitlab-ckb-review.yml' +# +# Override variables as needed: +# +# variables: +# CKB_FAIL_ON: "warning" +# CKB_CHECKS: "breaking,secrets,tests" +# CKB_CRITICAL_PATHS: "drivers/**,protocol/**" + +variables: + CKB_VERSION: "latest" + CKB_FAIL_ON: "" + CKB_CHECKS: "" + CKB_CRITICAL_PATHS: "" + CKB_REQUIRE_TRACE: "false" + CKB_TRACE_PATTERNS: "" + CKB_REQUIRE_INDEPENDENT: "false" + +.ckb-review-base: + image: node:20-slim + before_script: + - npm install -g @tastehub/ckb@${CKB_VERSION} + - ckb index 2>/dev/null || echo "Indexing skipped" + +ckb-review: + extends: .ckb-review-base + stage: test + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + script: + - | + FLAGS="--ci --base=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-main}" + [ -n "$CKB_CHECKS" ] && FLAGS="$FLAGS --checks=$CKB_CHECKS" + [ -n "$CKB_FAIL_ON" ] && FLAGS="$FLAGS --fail-on=$CKB_FAIL_ON" + [ -n "$CKB_CRITICAL_PATHS" ] && FLAGS="$FLAGS --critical-paths=$CKB_CRITICAL_PATHS" + [ "$CKB_REQUIRE_TRACE" = "true" ] && FLAGS="$FLAGS --require-trace" + [ -n "$CKB_TRACE_PATTERNS" ] && FLAGS="$FLAGS --trace-patterns=$CKB_TRACE_PATTERNS" + [ "$CKB_REQUIRE_INDEPENDENT" = "true" ] && FLAGS="$FLAGS --require-independent" + + echo "Running: ckb review $FLAGS" + ckb review $FLAGS --format=json > review.json || true + ckb review $FLAGS --format=human + + VERDICT=$(cat review.json | python3 -c "import sys,json; print(json.load(sys.stdin)['verdict'])" 2>/dev/null || echo "unknown") + echo "CKB_VERDICT=$VERDICT" >> build.env + artifacts: + reports: + dotenv: build.env + paths: + - review.json + when: always + +ckb-code-quality: + extends: .ckb-review-base + stage: test + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + script: + - | + FLAGS="--base=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-main}" + [ -n "$CKB_CHECKS" ] && FLAGS="$FLAGS --checks=$CKB_CHECKS" + [ -n "$CKB_CRITICAL_PATHS" ] && FLAGS="$FLAGS --critical-paths=$CKB_CRITICAL_PATHS" + + ckb review $FLAGS --format=codeclimate > gl-code-quality-report.json + artifacts: + reports: + codequality: gl-code-quality-report.json + when: always + allow_failure: true diff --git a/cmd/ckb/engine_helper.go b/cmd/ckb/engine_helper.go index 5d72324b..ff0cf482 100644 --- a/cmd/ckb/engine_helper.go +++ b/cmd/ckb/engine_helper.go @@ -114,10 +114,15 @@ func newContext() context.Context { // newLogger creates a logger with the specified format. // Logs always go to stderr to keep stdout clean for command output. // Respects global -v/-q flags and CKB_DEBUG env var. -func newLogger(_ string) *slog.Logger { +func newLogger(format string) *slog.Logger { level := slogutil.LevelFromVerbosity(verbosity, quiet) if os.Getenv("CKB_DEBUG") == "1" { level = slog.LevelDebug } + // In human format, suppress warnings (stale SCIP, etc.) — they clutter + // the review output. Errors still surface. + if format == "human" && level < slog.LevelError { + level = slog.LevelError + } return slogutil.NewLogger(os.Stderr, level) } diff --git a/cmd/ckb/format.go b/cmd/ckb/format.go index 21eba772..98414ff0 100644 --- a/cmd/ckb/format.go +++ b/cmd/ckb/format.go @@ -10,9 +10,13 @@ import ( type OutputFormat string const ( - FormatJSON OutputFormat = "json" - FormatHuman OutputFormat = "human" - FormatSARIF OutputFormat = "sarif" + FormatJSON OutputFormat = "json" + FormatHuman OutputFormat = "human" + FormatSARIF OutputFormat = "sarif" + FormatMarkdown OutputFormat = "markdown" + FormatGitHubActions OutputFormat = "github-actions" + FormatCodeClimate OutputFormat = "codeclimate" + FormatCompliance OutputFormat = "compliance" ) // FormatResponse formats a response according to the specified format diff --git a/cmd/ckb/format_review_codeclimate.go b/cmd/ckb/format_review_codeclimate.go new file mode 100644 index 00000000..4055d87b --- /dev/null +++ b/cmd/ckb/format_review_codeclimate.go @@ -0,0 +1,134 @@ +package main + +import ( + "crypto/md5" // #nosec G501 — MD5 used for fingerprinting, not security + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +// Code Climate JSON format for GitLab Code Quality +// https://docs.gitlab.com/ee/ci/testing/code_quality.html + +type codeClimateIssue struct { + Type string `json:"type"` + CheckName string `json:"check_name"` + Description string `json:"description"` + Content *codeClimateContent `json:"content,omitempty"` + Categories []string `json:"categories"` + Location codeClimateLocation `json:"location"` + Severity string `json:"severity"` // blocker, critical, major, minor, info + Fingerprint string `json:"fingerprint"` +} + +type codeClimateContent struct { + Body string `json:"body"` +} + +type codeClimateLocation struct { + Path string `json:"path"` + Lines *codeClimateLines `json:"lines,omitempty"` +} + +type codeClimateLines struct { + Begin int `json:"begin"` + End int `json:"end,omitempty"` +} + +// formatReviewCodeClimate generates Code Climate JSON for GitLab. +func formatReviewCodeClimate(resp *query.ReviewPRResponse) (string, error) { + issues := make([]codeClimateIssue, 0, len(resp.Findings)) + + for _, f := range resp.Findings { + issue := codeClimateIssue{ + Type: "issue", + CheckName: f.RuleID, + Description: f.Message, + Categories: ccCategories(f.Category), + Severity: ccSeverity(f.Severity), + Fingerprint: ccFingerprint(f), + Location: codeClimateLocation{ + Path: f.File, + }, + } + + if issue.CheckName == "" { + issue.CheckName = fmt.Sprintf("ckb/%s", f.Check) + } + + if f.File == "" { + issue.Location.Path = "." + } + + if f.StartLine > 0 { + issue.Location.Lines = &codeClimateLines{ + Begin: f.StartLine, + } + if f.EndLine > 0 { + issue.Location.Lines.End = f.EndLine + } + } + + if f.Detail != "" { + issue.Content = &codeClimateContent{Body: f.Detail} + } else if f.Suggestion != "" { + issue.Content = &codeClimateContent{Body: f.Suggestion} + } + + issues = append(issues, issue) + } + + data, err := json.MarshalIndent(issues, "", " ") + if err != nil { + return "", fmt.Errorf("marshal CodeClimate: %w", err) + } + return string(data), nil +} + +func ccSeverity(severity string) string { + switch severity { + case "error": + return "critical" + case "warning": + return "major" + default: + return "minor" + } +} + +func ccCategories(category string) []string { + switch category { + case "security": + return []string{"Security"} + case "breaking": + return []string{"Compatibility"} + case "complexity": + return []string{"Complexity"} + case "testing": + return []string{"Bug Risk"} + case "coupling": + return []string{"Duplication"} // closest CC category for coupling + case "risk": + return []string{"Bug Risk"} + case "critical": + return []string{"Security", "Bug Risk"} + case "compliance": + return []string{"Style"} // closest CC category for compliance + case "health": + return []string{"Complexity"} + default: + return []string{"Bug Risk"} + } +} + +func ccFingerprint(f query.ReviewFinding) string { + h := md5.New() // #nosec G401 — MD5 for fingerprinting, not security + h.Write([]byte(f.RuleID)) + h.Write([]byte{0}) + h.Write([]byte(f.File)) + h.Write([]byte{0}) + h.Write([]byte(f.Message)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/cmd/ckb/format_review_compliance.go b/cmd/ckb/format_review_compliance.go new file mode 100644 index 00000000..b96f09c3 --- /dev/null +++ b/cmd/ckb/format_review_compliance.go @@ -0,0 +1,179 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +// formatReviewCompliance generates a compliance evidence report suitable for audit. +// Covers: traceability, reviewer independence, critical-path findings, health grades. +func formatReviewCompliance(resp *query.ReviewPRResponse) string { + var b strings.Builder + + b.WriteString("=" + strings.Repeat("=", 69) + "\n") + b.WriteString(" CKB COMPLIANCE EVIDENCE REPORT\n") + b.WriteString("=" + strings.Repeat("=", 69) + "\n\n") + + b.WriteString(fmt.Sprintf("Generated: %s\n", time.Now().Format(time.RFC3339))) + b.WriteString(fmt.Sprintf("CKB Version: %s\n", resp.CkbVersion)) + b.WriteString(fmt.Sprintf("Schema: %s\n", resp.SchemaVersion)) + b.WriteString(fmt.Sprintf("Verdict: %s (%d/100)\n\n", strings.ToUpper(resp.Verdict), resp.Score)) + + // --- Section 1: Summary --- + b.WriteString("1. CHANGE SUMMARY\n") + b.WriteString(strings.Repeat("-", 40) + "\n") + b.WriteString(fmt.Sprintf(" Total Files: %d\n", resp.Summary.TotalFiles)) + b.WriteString(fmt.Sprintf(" Reviewable Files: %d\n", resp.Summary.ReviewableFiles)) + b.WriteString(fmt.Sprintf(" Generated Files: %d (excluded)\n", resp.Summary.GeneratedFiles)) + b.WriteString(fmt.Sprintf(" Critical Files: %d\n", resp.Summary.CriticalFiles)) + b.WriteString(fmt.Sprintf(" Total Changes: %d\n", resp.Summary.TotalChanges)) + b.WriteString(fmt.Sprintf(" Modules Changed: %d\n", resp.Summary.ModulesChanged)) + if len(resp.Summary.Languages) > 0 { + b.WriteString(fmt.Sprintf(" Languages: %s\n", strings.Join(resp.Summary.Languages, ", "))) + } + b.WriteString("\n") + + // --- Section 2: Quality Gate Results --- + b.WriteString("2. QUALITY GATE RESULTS\n") + b.WriteString(strings.Repeat("-", 40) + "\n") + b.WriteString(fmt.Sprintf(" %-20s %-8s %s\n", "CHECK", "STATUS", "DETAIL")) + b.WriteString(fmt.Sprintf(" %-20s %-8s %s\n", strings.Repeat("-", 20), strings.Repeat("-", 8), strings.Repeat("-", 30))) + for _, c := range resp.Checks { + b.WriteString(fmt.Sprintf(" %-20s %-8s %s\n", c.Name, strings.ToUpper(c.Status), c.Summary)) + } + b.WriteString(fmt.Sprintf("\n Passed: %d Warned: %d Failed: %d Skipped: %d\n\n", + resp.Summary.ChecksPassed, resp.Summary.ChecksWarned, + resp.Summary.ChecksFailed, resp.Summary.ChecksSkipped)) + + // --- Section 3: Traceability --- + b.WriteString("3. TRACEABILITY\n") + b.WriteString(strings.Repeat("-", 40) + "\n") + traceFound := false + for _, c := range resp.Checks { + if c.Name == "traceability" { + traceFound = true + b.WriteString(fmt.Sprintf(" Status: %s\n", strings.ToUpper(c.Status))) + b.WriteString(fmt.Sprintf(" Detail: %s\n", c.Summary)) + if result, ok := c.Details.(query.TraceabilityResult); ok { + if len(result.TicketRefs) > 0 { + b.WriteString(" References:\n") + for _, ref := range result.TicketRefs { + b.WriteString(fmt.Sprintf(" - %s (source: %s", ref.ID, ref.Source)) + if ref.Commit != "" { + b.WriteString(fmt.Sprintf(", commit: %s", ref.Commit[:minInt(8, len(ref.Commit))])) + } + b.WriteString(")\n") + } + } + } + } + } + if !traceFound { + b.WriteString(" Not configured (traceability patterns not set)\n") + } + b.WriteString("\n") + + // --- Section 4: Reviewer Independence --- + b.WriteString("4. REVIEWER INDEPENDENCE\n") + b.WriteString(strings.Repeat("-", 40) + "\n") + indepFound := false + for _, c := range resp.Checks { + if c.Name == "independence" { + indepFound = true + b.WriteString(fmt.Sprintf(" Status: %s\n", strings.ToUpper(c.Status))) + b.WriteString(fmt.Sprintf(" Detail: %s\n", c.Summary)) + if result, ok := c.Details.(query.IndependenceResult); ok { + b.WriteString(fmt.Sprintf(" Authors: %s\n", strings.Join(result.Authors, ", "))) + b.WriteString(fmt.Sprintf(" Min Reviewers: %d\n", result.MinReviewers)) + } + } + } + if !indepFound { + b.WriteString(" Not configured (requireIndependentReview not set)\n") + } + b.WriteString("\n") + + // --- Section 5: Critical Path Findings --- + b.WriteString("5. SAFETY-CRITICAL PATH FINDINGS\n") + b.WriteString(strings.Repeat("-", 40) + "\n") + critCount := 0 + for _, f := range resp.Findings { + if f.Category == "critical" || f.RuleID == "ckb/traceability/critical-orphan" || f.RuleID == "ckb/independence/critical-path-review" { + critCount++ + b.WriteString(fmt.Sprintf(" [%s] %s\n", strings.ToUpper(f.Severity), f.Message)) + if f.File != "" { + b.WriteString(fmt.Sprintf(" File: %s\n", f.File)) + } + if f.Suggestion != "" { + b.WriteString(fmt.Sprintf(" Action: %s\n", f.Suggestion)) + } + } + } + if critCount == 0 { + b.WriteString(" No safety-critical findings.\n") + } + b.WriteString("\n") + + // --- Section 6: Code Health --- + b.WriteString("6. CODE HEALTH\n") + b.WriteString(strings.Repeat("-", 40) + "\n") + if resp.HealthReport != nil && len(resp.HealthReport.Deltas) > 0 { + b.WriteString(fmt.Sprintf(" %-40s %-8s %-8s %s\n", "FILE", "BEFORE", "AFTER", "DELTA")) + b.WriteString(fmt.Sprintf(" %-40s %-8s %-8s %s\n", strings.Repeat("-", 40), strings.Repeat("-", 8), strings.Repeat("-", 8), strings.Repeat("-", 8))) + for _, d := range resp.HealthReport.Deltas { + b.WriteString(fmt.Sprintf(" %-40s %-8s %-8s %+d\n", + truncatePath(d.File, 40), + fmt.Sprintf("%s(%d)", d.GradeBefore, d.HealthBefore), + fmt.Sprintf("%s(%d)", d.Grade, d.HealthAfter), + d.Delta)) + } + b.WriteString(fmt.Sprintf("\n Degraded: %d Improved: %d Average Delta: %+.1f\n", + resp.HealthReport.Degraded, resp.HealthReport.Improved, resp.HealthReport.AverageDelta)) + } else { + b.WriteString(" No health data available.\n") + } + b.WriteString("\n") + + // --- Section 7: All Findings --- + b.WriteString("7. COMPLETE FINDINGS\n") + b.WriteString(strings.Repeat("-", 40) + "\n") + if len(resp.Findings) > 0 { + for i, f := range resp.Findings { + b.WriteString(fmt.Sprintf(" %d. [%s] [%s] %s\n", i+1, strings.ToUpper(f.Severity), f.RuleID, f.Message)) + if f.File != "" { + loc := f.File + if f.StartLine > 0 { + loc = fmt.Sprintf("%s:%d", f.File, f.StartLine) + } + b.WriteString(fmt.Sprintf(" File: %s\n", loc)) + } + } + } else { + b.WriteString(" No findings.\n") + } + b.WriteString("\n") + + // --- Footer --- + b.WriteString(strings.Repeat("=", 70) + "\n") + b.WriteString(" END OF COMPLIANCE EVIDENCE REPORT\n") + b.WriteString(strings.Repeat("=", 70) + "\n") + + return b.String() +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func truncatePath(path string, maxLen int) string { + if len(path) <= maxLen { + return path + } + return "..." + path[len(path)-maxLen+3:] +} diff --git a/cmd/ckb/format_review_golden_test.go b/cmd/ckb/format_review_golden_test.go new file mode 100644 index 00000000..9b00c5d3 --- /dev/null +++ b/cmd/ckb/format_review_golden_test.go @@ -0,0 +1,294 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +var updateGolden = flag.Bool("update-golden", false, "Update golden files") + +const goldenDir = "../../testdata/review" + +// goldenResponse returns a rich response exercising all formatter code paths. +func goldenResponse() *query.ReviewPRResponse { + return &query.ReviewPRResponse{ + CkbVersion: "8.2.0", + SchemaVersion: "8.2", + Tool: "reviewPR", + Verdict: "warn", + Score: 68, + Narrative: "Changes 25 files across 3 modules (Go, TypeScript). 2 breaking API changes detected; 2 safety-critical files changed. 2 safety-critical files need focused review.", + PRTier: "medium", + Summary: query.ReviewSummary{ + TotalFiles: 25, + TotalChanges: 480, + GeneratedFiles: 3, + ReviewableFiles: 22, + CriticalFiles: 2, + ChecksPassed: 4, + ChecksWarned: 2, + ChecksFailed: 1, + ChecksSkipped: 1, + TopRisks: []string{"2 breaking API changes", "Critical path touched"}, + Languages: []string{"Go", "TypeScript"}, + ModulesChanged: 3, + }, + Checks: []query.ReviewCheck{ + {Name: "breaking", Status: "fail", Severity: "error", Summary: "2 breaking API changes detected", Duration: 120}, + {Name: "critical", Status: "fail", Severity: "error", Summary: "2 safety-critical files changed", Duration: 15}, + {Name: "complexity", Status: "warn", Severity: "warning", Summary: "+8 cyclomatic (engine.go)", Duration: 340}, + {Name: "coupling", Status: "warn", Severity: "warning", Summary: "2 missing co-change files", Duration: 210}, + {Name: "secrets", Status: "pass", Severity: "error", Summary: "No secrets detected", Duration: 95}, + {Name: "tests", Status: "pass", Severity: "warning", Summary: "12 tests cover the changes", Duration: 180}, + {Name: "risk", Status: "pass", Severity: "warning", Summary: "Risk score: 0.42 (low)", Duration: 150}, + {Name: "hotspots", Status: "pass", Severity: "info", Summary: "No volatile files touched", Duration: 45}, + {Name: "generated", Status: "info", Severity: "info", Summary: "3 generated files detected and excluded"}, + }, + Findings: []query.ReviewFinding{ + { + Check: "breaking", + Severity: "error", + File: "api/handler.go", + StartLine: 42, + Message: "Removed public function HandleAuth()", + Category: "breaking", + RuleID: "ckb/breaking/removed-symbol", + Tier: 1, + }, + { + Check: "breaking", + Severity: "error", + File: "api/middleware.go", + StartLine: 15, + Message: "Changed signature of ValidateToken()", + Category: "breaking", + RuleID: "ckb/breaking/changed-signature", + Tier: 1, + }, + { + Check: "critical", + Severity: "error", + File: "drivers/hw/plc_comm.go", + StartLine: 78, + Message: "Safety-critical path changed (pattern: drivers/**)", + Suggestion: "Requires sign-off from safety team", + Category: "critical", + RuleID: "ckb/critical/safety-path", + Tier: 1, + }, + { + Check: "critical", + Severity: "error", + File: "protocol/modbus.go", + Message: "Safety-critical path changed (pattern: protocol/**)", + Suggestion: "Requires sign-off from safety team", + Category: "critical", + RuleID: "ckb/critical/safety-path", + Tier: 1, + }, + { + Check: "complexity", + Severity: "warning", + File: "internal/query/engine.go", + StartLine: 155, + EndLine: 210, + Message: "Complexity 12→20 in parseQuery()", + Suggestion: "Consider extracting helper functions", + Category: "complexity", + RuleID: "ckb/complexity/increase", + Tier: 2, + }, + { + Check: "coupling", + Severity: "warning", + File: "internal/query/engine.go", + Message: "Missing co-change: engine_test.go (87% co-change rate)", + Category: "coupling", + RuleID: "ckb/coupling/missing-cochange", + Tier: 2, + }, + { + Check: "coupling", + Severity: "warning", + File: "protocol/modbus.go", + Message: "Missing co-change: modbus_test.go (91% co-change rate)", + Category: "coupling", + RuleID: "ckb/coupling/missing-cochange", + Tier: 2, + }, + { + Check: "hotspots", + Severity: "info", + File: "config/settings.go", + Message: "Hotspot file (score: 0.78) — extra review attention recommended", + Category: "risk", + RuleID: "ckb/hotspots/volatile-file", + Tier: 3, + }, + }, + Reviewers: []query.SuggestedReview{ + {Owner: "alice", Coverage: 0.85, Confidence: 0.9}, + {Owner: "bob", Coverage: 0.45, Confidence: 0.7}, + }, + Generated: []query.GeneratedFileInfo{ + {File: "api/types.pb.go", Reason: "Matches pattern *.pb.go", SourceFile: "api/types.proto"}, + {File: "parser/parser.tab.c", Reason: "flex/yacc generated output", SourceFile: "parser/parser.y"}, + {File: "ui/generated.ts", Reason: "Matches pattern *.generated.*"}, + }, + SplitSuggestion: &query.PRSplitSuggestion{ + ShouldSplit: true, + Reason: "25 files across 3 independent clusters — split recommended", + Clusters: []query.PRCluster{ + {Name: "API Handler Refactor", Files: []string{"api/handler.go", "api/middleware.go"}, FileCount: 8, Additions: 240, Deletions: 120, Independent: true}, + {Name: "Protocol Update", Files: []string{"protocol/modbus.go"}, FileCount: 5, Additions: 130, Deletions: 60, Independent: true}, + {Name: "Driver Changes", Files: []string{"drivers/hw/plc_comm.go"}, FileCount: 12, Additions: 80, Deletions: 30, Independent: false}, + }, + }, + ChangeBreakdown: &query.ChangeBreakdown{ + Summary: map[string]int{ + "new": 5, + "modified": 10, + "refactoring": 3, + "test": 4, + "generated": 3, + }, + }, + ReviewEffort: &query.ReviewEffort{ + EstimatedMinutes: 95, + EstimatedHours: 1.58, + Complexity: "complex", + Factors: []string{ + "22 reviewable files (44min base)", + "3 module context switches (15min)", + "2 safety-critical files (20min)", + }, + }, + HealthReport: &query.CodeHealthReport{ + Deltas: []query.CodeHealthDelta{ + {File: "api/handler.go", HealthBefore: 82, HealthAfter: 70, Delta: -12, Grade: "B", GradeBefore: "B", TopFactor: "significant health degradation"}, + {File: "internal/query/engine.go", HealthBefore: 75, HealthAfter: 68, Delta: -7, Grade: "C", GradeBefore: "B", TopFactor: "minor health decrease"}, + {File: "protocol/modbus.go", HealthBefore: 60, HealthAfter: 65, Delta: 5, Grade: "C", GradeBefore: "C", TopFactor: "unchanged"}, + }, + AverageDelta: -4.67, + WorstFile: "protocol/modbus.go", + WorstGrade: "C", + Degraded: 2, + Improved: 1, + }, + } +} + +func TestGolden_Human(t *testing.T) { + t.Parallel() + resp := goldenResponse() + output := formatReviewHuman(resp) + checkGolden(t, "human.txt", output) +} + +func TestGolden_Markdown(t *testing.T) { + t.Parallel() + resp := goldenResponse() + output := formatReviewMarkdown(resp) + checkGolden(t, "markdown.md", output) +} + +func TestGolden_GitHubActions(t *testing.T) { + t.Parallel() + resp := goldenResponse() + output := formatReviewGitHubActions(resp) + checkGolden(t, "github-actions.txt", output) +} + +func TestGolden_SARIF(t *testing.T) { + t.Parallel() + resp := goldenResponse() + output, err := formatReviewSARIF(resp) + if err != nil { + t.Fatalf("formatReviewSARIF: %v", err) + } + // Normalize: re-marshal with sorted keys for stable output + var parsed interface{} + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("unmarshal SARIF: %v", err) + } + normalized, _ := json.MarshalIndent(parsed, "", " ") + checkGolden(t, "sarif.json", string(normalized)) +} + +func TestGolden_CodeClimate(t *testing.T) { + t.Parallel() + resp := goldenResponse() + output, err := formatReviewCodeClimate(resp) + if err != nil { + t.Fatalf("formatReviewCodeClimate: %v", err) + } + checkGolden(t, "codeclimate.json", output) +} + +func TestGolden_JSON(t *testing.T) { + t.Parallel() + resp := goldenResponse() + output, err := formatJSON(resp) + if err != nil { + t.Fatalf("formatJSON: %v", err) + } + checkGolden(t, "json.json", output) +} + +func TestGolden_Compliance(t *testing.T) { + t.Parallel() + resp := goldenResponse() + output := formatReviewCompliance(resp) + // Normalize the timestamp line which changes every run. + output = regexp.MustCompile(`(?m)^Generated:.*$`).ReplaceAllString(output, "Generated: ") + checkGolden(t, "compliance.txt", output) +} + +func checkGolden(t *testing.T, filename, actual string) { + t.Helper() + path := filepath.Join(goldenDir, filename) + + if *updateGolden { + if err := os.WriteFile(path, []byte(actual), 0644); err != nil { + t.Fatalf("write golden file: %v", err) + } + t.Logf("Updated golden file: %s", path) + return + } + + expected, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Golden file %s not found. Run with -update-golden to create it.\n%v", path, err) + } + + // Normalize line endings + expectedStr := strings.ReplaceAll(string(expected), "\r\n", "\n") + actualStr := strings.ReplaceAll(actual, "\r\n", "\n") + + if expectedStr != actualStr { + // Show first difference + expLines := strings.Split(expectedStr, "\n") + actLines := strings.Split(actualStr, "\n") + for i := 0; i < len(expLines) || i < len(actLines); i++ { + exp := "" + act := "" + if i < len(expLines) { + exp = expLines[i] + } + if i < len(actLines) { + act = actLines[i] + } + if exp != act { + t.Errorf("Golden file mismatch at line %d:\n expected: %q\n actual: %q\n\nRun with -update-golden to update.", i+1, exp, act) + return + } + } + } +} diff --git a/cmd/ckb/format_review_sarif.go b/cmd/ckb/format_review_sarif.go new file mode 100644 index 00000000..8f5c26ed --- /dev/null +++ b/cmd/ckb/format_review_sarif.go @@ -0,0 +1,215 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + + "github.com/SimplyLiz/CodeMCP/internal/query" + "github.com/SimplyLiz/CodeMCP/internal/version" +) + +// SARIF v2.1.0 types (subset needed for CKB output) + +type sarifLog struct { + Version string `json:"version"` + Schema string `json:"$schema"` + Runs []sarifRun `json:"runs"` +} + +type sarifRun struct { + Tool sarifTool `json:"tool"` + Results []sarifResult `json:"results"` +} + +type sarifTool struct { + Driver sarifDriver `json:"driver"` +} + +type sarifDriver struct { + Name string `json:"name"` + Version string `json:"version"` + InformationURI string `json:"informationUri"` + Rules []sarifRule `json:"rules"` + SemanticVersion string `json:"semanticVersion"` +} + +type sarifRule struct { + ID string `json:"id"` + ShortDescription sarifMessage `json:"shortDescription"` + DefaultConfig *sarifConfiguration `json:"defaultConfiguration,omitempty"` +} + +type sarifConfiguration struct { + Level string `json:"level"` // "error", "warning", "note" +} + +type sarifMessage struct { + Text string `json:"text"` +} + +type sarifResult struct { + RuleID string `json:"ruleId"` + Level string `json:"level"` // "error", "warning", "note" + Message sarifMessage `json:"message"` + Locations []sarifLocation `json:"locations,omitempty"` + PartialFingerprints map[string]string `json:"partialFingerprints,omitempty"` + RelatedLocations []sarifRelatedLoc `json:"relatedLocations,omitempty"` + Fixes []sarifFix `json:"fixes,omitempty"` +} + +type sarifLocation struct { + PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"` +} + +type sarifPhysicalLocation struct { + ArtifactLocation sarifArtifactLocation `json:"artifactLocation"` + Region *sarifRegion `json:"region,omitempty"` +} + +type sarifArtifactLocation struct { + URI string `json:"uri"` +} + +type sarifRegion struct { + StartLine int `json:"startLine,omitempty"` + EndLine int `json:"endLine,omitempty"` +} + +type sarifRelatedLoc struct { + ID int `json:"id"` + Message sarifMessage `json:"message"` + PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"` +} + +type sarifFix struct { + Description sarifMessage `json:"description"` + Changes []sarifArtifactChange `json:"artifactChanges"` +} + +type sarifArtifactChange struct { + ArtifactLocation sarifArtifactLocation `json:"artifactLocation"` +} + +// formatReviewSARIF generates SARIF v2.1.0 output for GitHub Code Scanning. +func formatReviewSARIF(resp *query.ReviewPRResponse) (string, error) { + // Collect unique rules + ruleMap := make(map[string]sarifRule) + for _, f := range resp.Findings { + ruleID := f.RuleID + if ruleID == "" { + ruleID = fmt.Sprintf("ckb/%s/unknown", f.Check) + } + if _, exists := ruleMap[ruleID]; !exists { + level := sarifLevel(f.Severity) + ruleMap[ruleID] = sarifRule{ + ID: ruleID, + ShortDescription: sarifMessage{Text: ruleID}, + DefaultConfig: &sarifConfiguration{Level: level}, + } + } + } + + rules := make([]sarifRule, 0, len(ruleMap)) + for _, r := range ruleMap { + rules = append(rules, r) + } + sort.Slice(rules, func(i, j int) bool { return rules[i].ID < rules[j].ID }) + + // Build results + results := make([]sarifResult, 0, len(resp.Findings)) + for _, f := range resp.Findings { + ruleID := f.RuleID + if ruleID == "" { + ruleID = fmt.Sprintf("ckb/%s/unknown", f.Check) + } + + result := sarifResult{ + RuleID: ruleID, + Level: sarifLevel(f.Severity), + Message: sarifMessage{Text: f.Message}, + PartialFingerprints: map[string]string{ + "ckb/v1": sarifFingerprint(f), + }, + } + + if f.File != "" { + loc := sarifLocation{ + PhysicalLocation: sarifPhysicalLocation{ + ArtifactLocation: sarifArtifactLocation{URI: f.File}, + }, + } + if f.StartLine > 0 { + loc.PhysicalLocation.Region = &sarifRegion{ + StartLine: f.StartLine, + } + if f.EndLine > 0 { + loc.PhysicalLocation.Region.EndLine = f.EndLine + } + } + result.Locations = []sarifLocation{loc} + } + + if f.Suggestion != "" { + // Add suggestion as a related location message rather than a Fix, + // since SARIF v2.1.0 requires Fixes to include artifactChanges. + result.RelatedLocations = append(result.RelatedLocations, sarifRelatedLoc{ + ID: 1, + Message: sarifMessage{Text: "Suggestion: " + f.Suggestion}, + PhysicalLocation: sarifPhysicalLocation{ + ArtifactLocation: sarifArtifactLocation{URI: f.File}, + }, + }) + } + + results = append(results, result) + } + + log := sarifLog{ + Version: "2.1.0", + Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + Runs: []sarifRun{ + { + Tool: sarifTool{ + Driver: sarifDriver{ + Name: "CKB", + Version: version.Version, + SemanticVersion: version.Version, + InformationURI: "https://github.com/SimplyLiz/CodeMCP", + Rules: rules, + }, + }, + Results: results, + }, + }, + } + + data, err := json.MarshalIndent(log, "", " ") + if err != nil { + return "", fmt.Errorf("marshal SARIF: %w", err) + } + return string(data), nil +} + +func sarifLevel(severity string) string { + switch severity { + case "error": + return "error" + case "warning": + return "warning" + default: + return "note" + } +} + +func sarifFingerprint(f query.ReviewFinding) string { + h := sha256.New() + h.Write([]byte(f.RuleID)) + h.Write([]byte{0}) + h.Write([]byte(f.File)) + h.Write([]byte{0}) + h.Write([]byte(f.Message)) + return hex.EncodeToString(h.Sum(nil))[:16] +} diff --git a/cmd/ckb/format_review_test.go b/cmd/ckb/format_review_test.go new file mode 100644 index 00000000..570375dd --- /dev/null +++ b/cmd/ckb/format_review_test.go @@ -0,0 +1,429 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +func testResponse() *query.ReviewPRResponse { + return &query.ReviewPRResponse{ + CkbVersion: "8.2.0", + SchemaVersion: "8.2", + Tool: "reviewPR", + Verdict: "warn", + Score: 72, + Summary: query.ReviewSummary{ + TotalFiles: 10, + TotalChanges: 200, + ReviewableFiles: 8, + GeneratedFiles: 2, + CriticalFiles: 1, + ChecksPassed: 3, + ChecksWarned: 2, + ChecksFailed: 1, + Languages: []string{"Go", "TypeScript"}, + ModulesChanged: 2, + }, + Checks: []query.ReviewCheck{ + {Name: "breaking", Status: "fail", Severity: "error", Summary: "2 breaking changes"}, + {Name: "secrets", Status: "pass", Severity: "error", Summary: "No secrets"}, + {Name: "complexity", Status: "warn", Severity: "warning", Summary: "+5 cyclomatic"}, + }, + Findings: []query.ReviewFinding{ + { + Check: "breaking", + Severity: "error", + File: "api/handler.go", + StartLine: 42, + Message: "Removed public function HandleAuth()", + Category: "breaking", + RuleID: "ckb/breaking/removed-symbol", + }, + { + Check: "complexity", + Severity: "warning", + File: "internal/query/engine.go", + StartLine: 155, + Message: "Complexity 12→20 in parseQuery()", + Category: "complexity", + RuleID: "ckb/complexity/increase", + Suggestion: "Consider extracting helper functions", + }, + { + Check: "risk", + Severity: "info", + File: "config.go", + Message: "High churn file", + Category: "risk", + RuleID: "ckb/risk/high-score", + }, + }, + Reviewers: []query.SuggestedReview{ + {Owner: "alice", Coverage: 0.85}, + }, + } +} + +// --- SARIF Tests --- + +func TestFormatSARIF_ValidJSON(t *testing.T) { + t.Parallel() + resp := testResponse() + output, err := formatReviewSARIF(resp) + if err != nil { + t.Fatalf("formatReviewSARIF error: %v", err) + } + + var sarif sarifLog + if err := json.Unmarshal([]byte(output), &sarif); err != nil { + t.Fatalf("invalid SARIF JSON: %v", err) + } + + if sarif.Version != "2.1.0" { + t.Errorf("version = %q, want %q", sarif.Version, "2.1.0") + } +} + +func TestFormatSARIF_HasRuns(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewSARIF(resp) + + var sarif sarifLog + if err := json.Unmarshal([]byte(output), &sarif); err != nil { + t.Fatalf("unmarshal SARIF: %v", err) + } + + if len(sarif.Runs) != 1 { + t.Fatalf("runs = %d, want 1", len(sarif.Runs)) + } + + run := sarif.Runs[0] + if run.Tool.Driver.Name != "CKB" { + t.Errorf("tool name = %q, want %q", run.Tool.Driver.Name, "CKB") + } +} + +func TestFormatSARIF_Results(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewSARIF(resp) + + var sarif sarifLog + if err := json.Unmarshal([]byte(output), &sarif); err != nil { + t.Fatalf("unmarshal SARIF: %v", err) + } + + results := sarif.Runs[0].Results + if len(results) != 3 { + t.Fatalf("results = %d, want 3", len(results)) + } + + // Check first result + r := results[0] + if r.RuleID != "ckb/breaking/removed-symbol" { + t.Errorf("ruleId = %q, want %q", r.RuleID, "ckb/breaking/removed-symbol") + } + if r.Level != "error" { + t.Errorf("level = %q, want %q", r.Level, "error") + } + if len(r.Locations) == 0 { + t.Fatal("expected locations") + } + if r.Locations[0].PhysicalLocation.Region.StartLine != 42 { + t.Errorf("startLine = %d, want 42", r.Locations[0].PhysicalLocation.Region.StartLine) + } +} + +func TestFormatSARIF_Fingerprints(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewSARIF(resp) + + var sarif sarifLog + if err := json.Unmarshal([]byte(output), &sarif); err != nil { + t.Fatalf("unmarshal SARIF: %v", err) + } + + for _, r := range sarif.Runs[0].Results { + if r.PartialFingerprints == nil { + t.Error("expected partialFingerprints") + } + if _, ok := r.PartialFingerprints["ckb/v1"]; !ok { + t.Error("expected ckb/v1 fingerprint") + } + } +} + +func TestFormatSARIF_Rules(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewSARIF(resp) + + var sarif sarifLog + if err := json.Unmarshal([]byte(output), &sarif); err != nil { + t.Fatalf("unmarshal SARIF: %v", err) + } + + rules := sarif.Runs[0].Tool.Driver.Rules + if len(rules) != 3 { + t.Errorf("rules = %d, want 3", len(rules)) + } +} + +func TestFormatSARIF_Suggestions(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewSARIF(resp) + + var sarif sarifLog + if err := json.Unmarshal([]byte(output), &sarif); err != nil { + t.Fatalf("unmarshal SARIF: %v", err) + } + + // The complexity finding has a suggestion, now in relatedLocations + hasSuggestion := false + for _, r := range sarif.Runs[0].Results { + for _, rl := range r.RelatedLocations { + if strings.Contains(rl.Message.Text, "Consider extracting helper functions") { + hasSuggestion = true + } + } + } + if !hasSuggestion { + t.Error("expected at least one result with suggestion in relatedLocations") + } +} + +func TestFormatSARIF_EmptyFindings(t *testing.T) { + t.Parallel() + resp := &query.ReviewPRResponse{ + CkbVersion: "8.2.0", + Verdict: "pass", + Score: 100, + } + output, err := formatReviewSARIF(resp) + if err != nil { + t.Fatalf("error: %v", err) + } + if !strings.Contains(output, `"results": []`) { + t.Error("expected empty results array") + } +} + +// --- CodeClimate Tests --- + +func TestFormatCodeClimate_ValidJSON(t *testing.T) { + t.Parallel() + resp := testResponse() + output, err := formatReviewCodeClimate(resp) + if err != nil { + t.Fatalf("formatReviewCodeClimate error: %v", err) + } + + var issues []codeClimateIssue + if err := json.Unmarshal([]byte(output), &issues); err != nil { + t.Fatalf("invalid CodeClimate JSON: %v", err) + } + + if len(issues) != 3 { + t.Fatalf("issues = %d, want 3", len(issues)) + } +} + +func TestFormatCodeClimate_Severity(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewCodeClimate(resp) + + var issues []codeClimateIssue + if err := json.Unmarshal([]byte(output), &issues); err != nil { + t.Fatalf("unmarshal CodeClimate: %v", err) + } + + severities := make(map[string]int) + for _, i := range issues { + severities[i.Severity]++ + } + + if severities["critical"] != 1 { + t.Errorf("critical = %d, want 1", severities["critical"]) + } + if severities["major"] != 1 { + t.Errorf("major = %d, want 1", severities["major"]) + } + if severities["minor"] != 1 { + t.Errorf("minor = %d, want 1", severities["minor"]) + } +} + +func TestFormatCodeClimate_Fingerprints(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewCodeClimate(resp) + + var issues []codeClimateIssue + if err := json.Unmarshal([]byte(output), &issues); err != nil { + t.Fatalf("unmarshal CodeClimate: %v", err) + } + + fps := make(map[string]bool) + for _, i := range issues { + if i.Fingerprint == "" { + t.Error("empty fingerprint") + } + if fps[i.Fingerprint] { + t.Errorf("duplicate fingerprint: %s", i.Fingerprint) + } + fps[i.Fingerprint] = true + } +} + +func TestFormatCodeClimate_Location(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewCodeClimate(resp) + + var issues []codeClimateIssue + if err := json.Unmarshal([]byte(output), &issues); err != nil { + t.Fatalf("unmarshal CodeClimate: %v", err) + } + + if issues[0].Location.Path != "api/handler.go" { + t.Errorf("path = %q, want %q", issues[0].Location.Path, "api/handler.go") + } + if issues[0].Location.Lines == nil || issues[0].Location.Lines.Begin != 42 { + t.Error("expected lines.begin = 42") + } +} + +func TestFormatCodeClimate_Categories(t *testing.T) { + t.Parallel() + resp := testResponse() + output, _ := formatReviewCodeClimate(resp) + + var issues []codeClimateIssue + if err := json.Unmarshal([]byte(output), &issues); err != nil { + t.Fatalf("unmarshal CodeClimate: %v", err) + } + + // Breaking → Compatibility + if len(issues[0].Categories) == 0 || issues[0].Categories[0] != "Compatibility" { + t.Errorf("breaking category = %v, want [Compatibility]", issues[0].Categories) + } + // Complexity → Complexity + if len(issues[1].Categories) == 0 || issues[1].Categories[0] != "Complexity" { + t.Errorf("complexity category = %v, want [Complexity]", issues[1].Categories) + } +} + +func TestFormatCodeClimate_EmptyFindings(t *testing.T) { + t.Parallel() + resp := &query.ReviewPRResponse{Verdict: "pass", Score: 100} + output, err := formatReviewCodeClimate(resp) + if err != nil { + t.Fatalf("error: %v", err) + } + if output != "[]" { + t.Errorf("expected empty array, got %q", output) + } +} + +// --- GitHub Actions Format Tests --- + +func TestFormatGitHubActions_Annotations(t *testing.T) { + t.Parallel() + resp := testResponse() + output := formatReviewGitHubActions(resp) + + if !strings.Contains(output, "::error file=api/handler.go,line=42::") { + t.Error("expected error annotation with file and line") + } + if !strings.Contains(output, "::warning file=internal/query/engine.go,line=155::") { + t.Error("expected warning annotation") + } + if !strings.Contains(output, "::notice file=config.go::") { + t.Error("expected notice annotation") + } +} + +// --- Human Format Tests --- + +func TestFormatHuman_ContainsVerdict(t *testing.T) { + t.Parallel() + resp := testResponse() + output := formatReviewHuman(resp) + + if !strings.Contains(output, "WARN") { + t.Error("expected WARN in output") + } + if !strings.Contains(output, "10 files") { + t.Error("expected file count in header") + } +} + +func TestFormatHuman_ContainsChecks(t *testing.T) { + t.Parallel() + resp := testResponse() + output := formatReviewHuman(resp) + + if !strings.Contains(output, "breaking") { + t.Error("expected breaking check") + } + if !strings.Contains(output, "secrets") { + t.Error("expected secrets check") + } +} + +// --- Markdown Format Tests --- + +func TestFormatMarkdown_ContainsTable(t *testing.T) { + t.Parallel() + resp := testResponse() + output := formatReviewMarkdown(resp) + + if !strings.Contains(output, "| Check | Status | Detail |") { + t.Error("expected markdown table header") + } + if !strings.Contains(output, "") { + t.Error("expected review marker for update-in-place") + } +} + +func TestFormatMarkdown_ContainsFindings(t *testing.T) { + t.Parallel() + resp := testResponse() + output := formatReviewMarkdown(resp) + + if !strings.Contains(output, "Findings (3)") { + t.Error("expected findings section with count") + } +} + +// --- Compliance Format Tests --- + +func TestFormatCompliance_HasSections(t *testing.T) { + t.Parallel() + resp := testResponse() + output := formatReviewCompliance(resp) + + sections := []string{ + "1. CHANGE SUMMARY", + "2. QUALITY GATE RESULTS", + "3. TRACEABILITY", + "4. REVIEWER INDEPENDENCE", + "5. SAFETY-CRITICAL PATH FINDINGS", + "6. CODE HEALTH", + "7. COMPLETE FINDINGS", + "END OF COMPLIANCE EVIDENCE REPORT", + } + + for _, s := range sections { + if !strings.Contains(output, s) { + t.Errorf("missing section: %s", s) + } + } +} diff --git a/cmd/ckb/review.go b/cmd/ckb/review.go new file mode 100644 index 00000000..b5e1f3ae --- /dev/null +++ b/cmd/ckb/review.go @@ -0,0 +1,939 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +// Display caps for formatter output. Consistent across human and markdown formats. +const ( + maxDisplayFindings = 10 + maxDisplayClusters = 10 +) + +var ( + reviewFormat string + reviewBaseBranch string + reviewHeadBranch string + reviewChecks []string + reviewCI bool + reviewFailOn string + // Policy overrides + reviewBlockBreaking bool + reviewBlockSecrets bool + reviewRequireTests bool + reviewMaxRisk float64 + reviewMaxComplexity int + reviewMaxFiles int + // Critical paths + reviewCriticalPaths []string + // Lint dedup + reviewLintReport string + // Traceability + reviewTracePatterns []string + reviewRequireTrace bool + // Independence + reviewRequireIndependent bool + reviewMinReviewers int + // New analyzer flags + reviewStaged bool + reviewScope string + reviewMaxBlastRadius int + reviewMaxFanOut int + reviewDeadCodeConfidence float64 + reviewTestGapLines int +) + +var reviewCmd = &cobra.Command{ + Use: "review [scope]", + Short: "Comprehensive PR review with quality gates", + Long: `Run a unified code review that orchestrates multiple checks in parallel: + +- Breaking API changes (SCIP-based) +- Secret detection +- Affected tests +- Complexity delta (tree-sitter) +- Coupling gaps (git co-change analysis) +- Hotspot overlap +- Risk scoring +- Safety-critical path checks +- Code health scoring (8-factor weighted score) +- Dead code detection (SCIP-based) +- Test gap analysis (tree-sitter) +- Blast radius / fan-out analysis (SCIP-based) +- Finding baseline management + +Output formats: human (default), json, markdown, github-actions + +Examples: + ckb review # Review current branch vs main + ckb review --base=develop # Custom base branch + ckb review --staged # Review staged changes only + ckb review internal/query/ # Scope to path prefix + ckb review --checks=breaking,secrets # Only specific checks + ckb review --checks=dead-code,test-gaps,blast-radius # New analyzers + ckb review --checks=health # Only code health check + ckb review --ci # CI mode (exit codes: 0=pass, 1=fail, 2=warn) + ckb review --format=markdown # PR comment ready output + ckb review --format=github-actions # GitHub Actions annotations + ckb review --critical-paths=drivers/**,protocol/** # Safety-critical paths + ckb review baseline save --tag=v1.0 # Save finding baseline + ckb review baseline diff # Compare against baseline`, + Args: cobra.MaximumNArgs(1), + Run: runReview, +} + +func init() { + reviewCmd.Flags().StringVar(&reviewFormat, "format", "human", "Output format (human, json, markdown, github-actions, sarif, codeclimate, compliance)") + reviewCmd.Flags().StringVar(&reviewBaseBranch, "base", "main", "Base branch to compare against") + reviewCmd.Flags().StringVar(&reviewHeadBranch, "head", "", "Head branch (default: current branch)") + reviewCmd.Flags().StringSliceVar(&reviewChecks, "checks", nil, "Comma-separated list of checks (breaking,secrets,tests,complexity,coupling,hotspots,risk,critical,generated,classify,split,health,traceability,independence,dead-code,test-gaps,blast-radius)") + reviewCmd.Flags().BoolVar(&reviewCI, "ci", false, "CI mode: exit 1 on fail, exit 2 on warn") + reviewCmd.Flags().StringVar(&reviewFailOn, "fail-on", "", "Override fail level (error, warning, none)") + + // Policy overrides + reviewCmd.Flags().BoolVar(&reviewBlockBreaking, "block-breaking", true, "Fail on breaking changes") + reviewCmd.Flags().BoolVar(&reviewBlockSecrets, "block-secrets", true, "Fail on detected secrets") + reviewCmd.Flags().BoolVar(&reviewRequireTests, "require-tests", false, "Warn if no tests cover changes") + reviewCmd.Flags().Float64Var(&reviewMaxRisk, "max-risk", 0.7, "Maximum risk score (0 = disabled)") + reviewCmd.Flags().IntVar(&reviewMaxComplexity, "max-complexity", 0, "Maximum complexity delta (0 = disabled)") + reviewCmd.Flags().IntVar(&reviewMaxFiles, "max-files", 0, "Maximum file count (0 = disabled)") + reviewCmd.Flags().StringSliceVar(&reviewCriticalPaths, "critical-paths", nil, "Glob patterns for safety-critical paths") + reviewCmd.Flags().StringVar(&reviewLintReport, "lint-report", "", "Path to existing SARIF lint report to deduplicate against") + + // Traceability + reviewCmd.Flags().StringSliceVar(&reviewTracePatterns, "trace-patterns", nil, "Regex patterns for ticket IDs (e.g., JIRA-\\d+)") + reviewCmd.Flags().BoolVar(&reviewRequireTrace, "require-trace", false, "Require ticket references in commits") + + // Independence + reviewCmd.Flags().BoolVar(&reviewRequireIndependent, "require-independent", false, "Require independent reviewer (author != reviewer)") + reviewCmd.Flags().IntVar(&reviewMinReviewers, "min-reviewers", 0, "Minimum number of independent reviewers") + + // New analyzers + reviewCmd.Flags().BoolVar(&reviewStaged, "staged", false, "Review staged changes instead of branch diff") + reviewCmd.Flags().StringVar(&reviewScope, "scope", "", "Filter to path prefix or symbol name") + reviewCmd.Flags().IntVar(&reviewMaxBlastRadius, "max-blast-radius", 0, "Maximum blast radius delta (0 = disabled)") + reviewCmd.Flags().IntVar(&reviewMaxFanOut, "max-fanout", 0, "Maximum fan-out / caller count (0 = disabled)") + reviewCmd.Flags().Float64Var(&reviewDeadCodeConfidence, "dead-code-confidence", 0.8, "Minimum confidence for dead code findings") + reviewCmd.Flags().IntVar(&reviewTestGapLines, "test-gap-lines", 5, "Minimum function lines for test gap reporting") + + rootCmd.AddCommand(reviewCmd) +} + +func runReview(cmd *cobra.Command, args []string) { + start := time.Now() + logger := newLogger(reviewFormat) + + repoRoot := mustGetRepoRoot() + engine := mustGetEngine(repoRoot, logger) + ctx := newContext() + + policy := query.DefaultReviewPolicy() + policy.BlockBreakingChanges = reviewBlockBreaking + policy.BlockSecrets = reviewBlockSecrets + policy.RequireTests = reviewRequireTests + policy.MaxRiskScore = reviewMaxRisk + policy.MaxComplexityDelta = reviewMaxComplexity + policy.MaxFiles = reviewMaxFiles + if reviewFailOn != "" { + policy.FailOnLevel = reviewFailOn + } + if len(reviewCriticalPaths) > 0 { + policy.CriticalPaths = reviewCriticalPaths + } + if len(reviewTracePatterns) > 0 { + policy.TraceabilityPatterns = reviewTracePatterns + policy.RequireTraceability = true + } + if reviewRequireTrace { + policy.RequireTraceability = true + } + if reviewRequireIndependent { + policy.RequireIndependentReview = true + } + if reviewMinReviewers > 0 { + policy.MinReviewers = reviewMinReviewers + } + if reviewMaxBlastRadius > 0 { + policy.MaxBlastRadiusDelta = reviewMaxBlastRadius + } + if reviewMaxFanOut > 0 { + policy.MaxFanOut = reviewMaxFanOut + } + policy.DeadCodeMinConfidence = reviewDeadCodeConfidence + policy.TestGapMinLines = reviewTestGapLines + + // Validate inputs + if reviewMaxRisk < 0 { + fmt.Fprintf(os.Stderr, "Error: --max-risk must be >= 0 (got %.2f)\n", reviewMaxRisk) + os.Exit(1) + } + if reviewFailOn != "" { + validLevels := map[string]bool{"error": true, "warning": true, "none": true} + if !validLevels[reviewFailOn] { + fmt.Fprintf(os.Stderr, "Error: --fail-on must be one of: error, warning, none (got %q)\n", reviewFailOn) + os.Exit(1) + } + } + + // Positional arg overrides --scope + scope := reviewScope + if len(args) > 0 { + scope = args[0] + } + + opts := query.ReviewPROptions{ + BaseBranch: reviewBaseBranch, + HeadBranch: reviewHeadBranch, + Policy: policy, + Checks: reviewChecks, + Staged: reviewStaged, + Scope: scope, + } + + response, err := engine.ReviewPR(ctx, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Error running review: %v\n", err) + os.Exit(1) + } + + // Deduplicate against external lint report + if reviewLintReport != "" { + suppressed, lintErr := deduplicateLintFindings(response, reviewLintReport) + if lintErr != nil { + fmt.Fprintf(os.Stderr, "Warning: could not parse lint report: %v\n", lintErr) + } else if suppressed > 0 { + logger.Debug("Deduplicated findings against lint report", + "suppressed", suppressed, "remaining", len(response.Findings)) + } + } + + // Format output + var output string + switch OutputFormat(reviewFormat) { + case FormatMarkdown: + output = formatReviewMarkdown(response) + case FormatGitHubActions: + output = formatReviewGitHubActions(response) + case FormatCompliance: + output = formatReviewCompliance(response) + case FormatSARIF: + var fmtErr error + output, fmtErr = formatReviewSARIF(response) + if fmtErr != nil { + fmt.Fprintf(os.Stderr, "Error formatting SARIF: %v\n", fmtErr) + os.Exit(1) + } + case FormatCodeClimate: + var fmtErr error + output, fmtErr = formatReviewCodeClimate(response) + if fmtErr != nil { + fmt.Fprintf(os.Stderr, "Error formatting CodeClimate: %v\n", fmtErr) + os.Exit(1) + } + case FormatJSON: + var fmtErr error + output, fmtErr = formatJSON(response) + if fmtErr != nil { + fmt.Fprintf(os.Stderr, "Error formatting output: %v\n", fmtErr) + os.Exit(1) + } + default: + output = formatReviewHuman(response) + } + + fmt.Println(output) + + logger.Debug("Review completed", + "baseBranch", reviewBaseBranch, + "headBranch", reviewHeadBranch, + "verdict", response.Verdict, + "score", response.Score, + "checks", len(response.Checks), + "findings", len(response.Findings), + "duration", time.Since(start).Milliseconds(), + ) + + // CI mode exit codes + if reviewCI { + switch response.Verdict { + case "fail": + os.Exit(1) + case "warn": + os.Exit(2) + } + } +} + +// --- Output Formatters --- + +func formatReviewHuman(resp *query.ReviewPRResponse) string { + var b strings.Builder + + // --- Header: verdict + stats, no score (#7) --- + verdictIcon := "✓" + verdictLabel := "PASS" + switch resp.Verdict { + case "fail": + verdictIcon = "✗" + verdictLabel = "FAIL" + case "warn": + verdictIcon = "⚠" + verdictLabel = "WARN" + } + + b.WriteString(fmt.Sprintf("CKB Review: %s %s · %d files · %d lines\n", + verdictIcon, verdictLabel, resp.Summary.TotalFiles, resp.Summary.TotalChanges)) + b.WriteString(strings.Repeat("═", 56) + "\n") + + if resp.Summary.GeneratedFiles > 0 || resp.Summary.CriticalFiles > 0 { + b.WriteString(fmt.Sprintf("%d reviewable", resp.Summary.ReviewableFiles)) + if resp.Summary.GeneratedFiles > 0 { + b.WriteString(fmt.Sprintf(" · %d generated (excluded)", resp.Summary.GeneratedFiles)) + } + if resp.Summary.CriticalFiles > 0 { + b.WriteString(fmt.Sprintf(" · %d critical", resp.Summary.CriticalFiles)) + } + b.WriteString("\n") + } + + // Narrative + if resp.Narrative != "" { + b.WriteString("\n" + wrapIndent(resp.Narrative, " ", 72) + "\n") + } + b.WriteString("\n") + + // --- Checks: collapse passes into one line (#4) --- + b.WriteString("Checks:\n") + var passNames []string + for _, c := range resp.Checks { + switch c.Status { + case "fail": + b.WriteString(fmt.Sprintf(" ✗ %-20s %s\n", c.Name, c.Summary)) + case "warn": + b.WriteString(fmt.Sprintf(" ⚠ %-20s %s\n", c.Name, c.Summary)) + case "info": + b.WriteString(fmt.Sprintf(" ○ %-20s %s\n", c.Name, c.Summary)) + case "pass": + passNames = append(passNames, c.Name) + // skip: omit entirely + } + } + if len(passNames) > 0 { + b.WriteString(fmt.Sprintf(" ✓ %s\n", strings.Join(passNames, " · "))) + } + b.WriteString("\n") + + // --- Top Findings: filter summary restatements (#1), group co-changes (#2) --- + if len(resp.Findings) > 0 { + actionable, tier3Count := filterActionableFindings(resp.Findings) + grouped := groupCoChangeFindings(actionable) + if len(grouped) > 0 { + b.WriteString("Top Findings:\n") + limit := maxDisplayFindings + if len(grouped) < limit { + limit = len(grouped) + } + for _, g := range grouped[:limit] { + loc := g.file + if loc == "" { + loc = "(global)" + } + b.WriteString(fmt.Sprintf(" ⚠ %s\n", loc)) + for _, msg := range g.messages { + b.WriteString(fmt.Sprintf(" %s\n", msg)) + } + if g.hint != "" { + b.WriteString(fmt.Sprintf(" %s\n", g.hint)) + } + } + remaining := len(grouped) - limit + if remaining > 0 || tier3Count > 0 { + parts := []string{} + if remaining > 0 { + parts = append(parts, fmt.Sprintf("%d more findings", remaining)) + } + if tier3Count > 0 { + parts = append(parts, fmt.Sprintf("%d informational", tier3Count)) + } + b.WriteString(fmt.Sprintf(" ... and %s\n", strings.Join(parts, ", "))) + } + b.WriteString("\n") + } + } + + // --- Review Effort: cap absurd estimates --- + if resp.ReviewEffort != nil { + estimate := formatEffortEstimate(resp.ReviewEffort, resp.SplitSuggestion, + resp.Summary.TotalFiles, resp.Summary.TotalChanges) + b.WriteString(fmt.Sprintf("Estimated Review: %s\n", estimate)) + if resp.ReviewEffort.EstimatedMinutes <= 480 && resp.PRTier != "large" { + for _, f := range resp.ReviewEffort.Factors { + b.WriteString(fmt.Sprintf(" · %s\n", f)) + } + } + b.WriteString("\n") + } + + // Change Breakdown — skip for large PRs + if resp.PRTier != "large" && resp.ChangeBreakdown != nil && len(resp.ChangeBreakdown.Summary) > 0 { + b.WriteString("Change Breakdown:\n") + cats := sortedMapKeys(resp.ChangeBreakdown.Summary) + for _, cat := range cats { + b.WriteString(fmt.Sprintf(" %-12s %d files\n", cat, resp.ChangeBreakdown.Summary[cat])) + } + b.WriteString("\n") + } + + // PR Split Suggestion + if resp.SplitSuggestion != nil && resp.SplitSuggestion.ShouldSplit { + b.WriteString("PR Split:\n") + clusterLimit := maxDisplayClusters + clusters := resp.SplitSuggestion.Clusters + if len(clusters) > clusterLimit { + clusters = clusters[:clusterLimit] + } + for _, c := range clusters { + b.WriteString(fmt.Sprintf(" %-22s %d files +%d −%d\n", + c.Name, c.FileCount, c.Additions, c.Deletions)) + } + if len(resp.SplitSuggestion.Clusters) > clusterLimit { + b.WriteString(fmt.Sprintf(" ... %d more (ckb review --split for full list)\n", + len(resp.SplitSuggestion.Clusters)-clusterLimit)) + } + b.WriteString("\n") + } + + // --- Code Health: collapse for large PRs (#5) --- + if resp.HealthReport != nil && len(resp.HealthReport.Deltas) > 0 { + if resp.PRTier == "large" { + // One-liner for large PRs — only show if something degraded + if resp.HealthReport.Degraded > 0 { + worst := worstDegraded(resp.HealthReport.Deltas) + b.WriteString(fmt.Sprintf("Code Health: %d degraded (avg %+.1f) · worst: %s (%s→%s)\n\n", + resp.HealthReport.Degraded, resp.HealthReport.AverageDelta, + worst.File, worst.GradeBefore, worst.Grade)) + } else { + // Count new files + newCount := 0 + for _, d := range resp.HealthReport.Deltas { + if d.NewFile { + newCount++ + } + } + if newCount > 0 { + b.WriteString(fmt.Sprintf("Code Health: 0 degraded · %d new (avg %d)\n\n", + newCount, avgHealth(resp.HealthReport.Deltas))) + } + } + } else { + // Per-file detail for small/medium PRs + b.WriteString("Code Health:\n") + shown := 0 + for _, d := range resp.HealthReport.Deltas { + if d.Delta == 0 && !d.NewFile { + continue + } + if shown >= 10 { + continue + } + arrow := "→" + label := "" + if d.NewFile { + arrow = "★" + label = " (new)" + } else if d.Delta < 0 { + arrow = "↓" + } else if d.Delta > 0 { + arrow = "↑" + } + b.WriteString(fmt.Sprintf(" %s %s %s (%d)%s\n", + d.Grade, arrow, d.File, d.HealthAfter, label)) + shown++ + } + if resp.HealthReport.Degraded > 0 || resp.HealthReport.Improved > 0 { + b.WriteString(fmt.Sprintf(" %d degraded · %d improved · avg %+.1f\n", + resp.HealthReport.Degraded, resp.HealthReport.Improved, resp.HealthReport.AverageDelta)) + } + b.WriteString("\n") + } + } + + // --- Reviewers: clean email display (#6) --- + if len(resp.Reviewers) > 0 { + b.WriteString("Reviewers: ") + var parts []string + for _, r := range resp.Reviewers { + name := formatReviewerName(r.Owner) + parts = append(parts, fmt.Sprintf("%s (%.0f%%)", name, r.Coverage*100)) + } + b.WriteString(strings.Join(parts, " · ")) + b.WriteString("\n") + } + + return b.String() +} + +// formatReviewerName cleans up reviewer identity for display. +// Emails become local part only; usernames get @ prefix. +func formatReviewerName(owner string) string { + if strings.Contains(owner, "@") { + return strings.Split(owner, "@")[0] + } + return "@" + owner +} + +// formatEffortEstimate returns a human-readable effort string, capping absurd values. +func formatEffortEstimate(effort *query.ReviewEffort, split *query.PRSplitSuggestion, files, lines int) string { + if effort.EstimatedMinutes > 480 { + clusters := 0 + if split != nil { + clusters = len(split.Clusters) + } + if clusters > 0 { + return fmt.Sprintf("not feasible as a single PR (%d files, %d lines, %d clusters)", + files, lines, clusters) + } + return fmt.Sprintf("not feasible as a single PR (%d files, %d lines)", files, lines) + } + return fmt.Sprintf("~%dmin (%s)", effort.EstimatedMinutes, effort.Complexity) +} + +// wrapIndent wraps text to a given width with consistent indentation. +func wrapIndent(s, indent string, width int) string { + words := strings.Fields(s) + var lines []string + line := indent + for _, w := range words { + if len(line)+len(w)+1 > width && line != indent { + lines = append(lines, line) + line = indent + w + } else { + if line == indent { + line += w + } else { + line += " " + w + } + } + } + if line != indent { + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +// worstDegraded finds the file with the largest health degradation. +func worstDegraded(deltas []query.CodeHealthDelta) query.CodeHealthDelta { + var worst query.CodeHealthDelta + for _, d := range deltas { + if !d.NewFile && d.Delta < worst.Delta { + worst = d + } + } + return worst +} + +// groupedFinding represents one or more co-change findings collapsed into one entry. +type groupedFinding struct { + severity string + file string + messages []string + hint string +} + +// groupCoChangeFindings collapses per-file co-change findings into single +// grouped entries, preserving insertion order so co-changes don't get pushed +// to the back behind non-grouped findings. +func groupCoChangeFindings(findings []query.ReviewFinding) []groupedFinding { + var result []groupedFinding + byFile := map[string]*groupedFinding{} + groupPositions := map[string]int{} // key → index in result + + for _, f := range findings { + if !strings.HasPrefix(f.Message, "Missing co-change:") { + result = append(result, groupedFinding{ + severity: f.Severity, + file: f.File, + messages: []string{f.Message}, + hint: f.Hint, + }) + continue + } + key := f.File + if _, ok := byFile[key]; ok { + byFile[key].messages = append(byFile[key].messages, f.Message) + } else { + g := &groupedFinding{severity: f.Severity, file: key} + byFile[key] = g + groupPositions[key] = len(result) + result = append(result, groupedFinding{}) // placeholder + } + } + // Fill placeholders with collapsed groups + for key, pos := range groupPositions { + g := byFile[key] + var targets []string + for _, msg := range g.messages { + targets = append(targets, strings.TrimPrefix(msg, "Missing co-change: ")) + } + result[pos] = groupedFinding{ + severity: g.severity, + file: g.file, + messages: []string{"Usually changed with: " + strings.Join(targets, ", ")}, + } + } + return result +} + +func formatReviewMarkdown(resp *query.ReviewPRResponse) string { + var b strings.Builder + + // Header + verdictEmoji := "✅" + switch resp.Verdict { + case "fail": + verdictEmoji = "🔴" + case "warn": + verdictEmoji = "🟡" + } + + b.WriteString(fmt.Sprintf("## CKB Review: %s %s — %d/100\n\n", + verdictEmoji, strings.ToUpper(resp.Verdict), resp.Score)) + + b.WriteString(fmt.Sprintf("**%d files** (+%d changes) · **%d modules**", + resp.Summary.TotalFiles, resp.Summary.TotalChanges, resp.Summary.ModulesChanged)) + if len(resp.Summary.Languages) > 0 { + b.WriteString(" · `" + strings.Join(resp.Summary.Languages, "` `") + "`") + } + b.WriteString("\n") + + if resp.Summary.GeneratedFiles > 0 || resp.Summary.CriticalFiles > 0 { + b.WriteString(fmt.Sprintf("**%d reviewable**", resp.Summary.ReviewableFiles)) + if resp.Summary.GeneratedFiles > 0 { + b.WriteString(fmt.Sprintf(" · %d generated (excluded)", resp.Summary.GeneratedFiles)) + } + if resp.Summary.CriticalFiles > 0 { + b.WriteString(fmt.Sprintf(" · **%d safety-critical**", resp.Summary.CriticalFiles)) + } + b.WriteString("\n") + } + b.WriteString("\n") + + // Narrative + if resp.Narrative != "" { + b.WriteString("> " + resp.Narrative + "\n\n") + } + + // Checks table + b.WriteString("| Check | Status | Detail |\n") + b.WriteString("|-------|--------|--------|\n") + for _, c := range resp.Checks { + statusEmoji := "✅ PASS" + switch c.Status { + case "fail": + statusEmoji = "🔴 FAIL" + case "warn": + statusEmoji = "🟡 WARN" + case "skip": + statusEmoji = "⚪ SKIP" + case "info": + statusEmoji = "ℹ️ INFO" + } + b.WriteString(fmt.Sprintf("| %s | %s | %s |\n", c.Name, statusEmoji, escapeMdTable(c.Summary))) + } + b.WriteString("\n") + + // Top Risks — the review narrative between checks and findings + if len(resp.Summary.TopRisks) > 0 { + b.WriteString("### Top Risks\n\n") + for _, risk := range resp.Summary.TopRisks { + b.WriteString(fmt.Sprintf("- %s\n", risk)) + } + b.WriteString("\n") + } + + // Findings — Tier 1+2 only, capped at 10 + if len(resp.Findings) > 0 { + actionable, tier3Count := filterActionableFindings(resp.Findings) + label := fmt.Sprintf("Findings (%d)", len(actionable)) + if tier3Count > 0 { + label = fmt.Sprintf("Findings (%d actionable, %d informational)", len(actionable), tier3Count) + } + if len(actionable) > 0 { + b.WriteString(fmt.Sprintf("
%s\n\n", label)) + b.WriteString("| Severity | File | Finding |\n") + b.WriteString("|----------|------|---------|\n") + limit := maxDisplayFindings + if len(actionable) < limit { + limit = len(actionable) + } + for _, f := range actionable[:limit] { + sevEmoji := "ℹ️" + switch f.Severity { + case "error": + sevEmoji = "🔴" + case "warning": + sevEmoji = "🟡" + } + loc := f.File + if f.StartLine > 0 { + loc = fmt.Sprintf("`%s:%d`", f.File, f.StartLine) + } else if f.File != "" { + loc = fmt.Sprintf("`%s`", f.File) + } + msg := escapeMdTable(f.Message) + if f.Hint != "" { + msg += " *" + escapeMdTable(f.Hint) + "*" + } + b.WriteString(fmt.Sprintf("| %s | %s | %s |\n", sevEmoji, loc, msg)) + } + if len(actionable) > limit { + b.WriteString(fmt.Sprintf("\n... and %d more\n", len(actionable)-limit)) + } + b.WriteString("\n
\n\n") + } + } + + // Change Breakdown — skip for large PRs + if resp.PRTier != "large" && resp.ChangeBreakdown != nil && len(resp.ChangeBreakdown.Summary) > 0 { + b.WriteString("
Change Breakdown\n\n") + b.WriteString("| Category | Files | Review Priority |\n") + b.WriteString("|----------|-------|-----------------|\n") + priorityEmoji := map[string]string{ + "new": "🔴 Full review", "churn": "🔴 Stability concern", + "refactoring": "🟡 Verify correctness", "modified": "🟡 Standard review", + "test": "🟡 Verify coverage", "moved": "🟢 Quick check", + "config": "🟢 Quick check", "generated": "⚪ Skip (review source)", + } + cats := sortedMapKeys(resp.ChangeBreakdown.Summary) + for _, cat := range cats { + count := resp.ChangeBreakdown.Summary[cat] + priority := priorityEmoji[cat] + if priority == "" { + priority = "🟡 Review" + } + b.WriteString(fmt.Sprintf("| %s | %d | %s |\n", cat, count, priority)) + } + b.WriteString("\n
\n\n") + } + + // PR Split Suggestion + if resp.SplitSuggestion != nil && resp.SplitSuggestion.ShouldSplit { + clusters := resp.SplitSuggestion.Clusters + clusterLimit := maxDisplayClusters + b.WriteString(fmt.Sprintf("
✂️ Suggested PR Split (%d clusters)\n\n", + len(clusters))) + b.WriteString("| Cluster | Files | Changes | Independent |\n") + b.WriteString("|---------|-------|---------|-------------|\n") + if len(clusters) > clusterLimit { + clusters = clusters[:clusterLimit] + } + for _, c := range clusters { + indep := "✅" + if !c.Independent { + indep = "❌" + } + b.WriteString(fmt.Sprintf("| %s | %d | +%d −%d | %s |\n", + c.Name, c.FileCount, c.Additions, c.Deletions, indep)) + } + if len(resp.SplitSuggestion.Clusters) > clusterLimit { + b.WriteString(fmt.Sprintf("\n... and %d more clusters\n", + len(resp.SplitSuggestion.Clusters)-clusterLimit)) + } + b.WriteString("\n
\n\n") + } + + // Code Health — show degraded files first, then new files; skip unchanged + if resp.HealthReport != nil && len(resp.HealthReport.Deltas) > 0 { + // Separate into degraded, improved, and new + var degraded, improved, newFiles []query.CodeHealthDelta + for _, d := range resp.HealthReport.Deltas { + switch { + case d.NewFile: + newFiles = append(newFiles, d) + case d.Delta < 0: + degraded = append(degraded, d) + case d.Delta > 0: + improved = append(improved, d) + } + } + + healthTitle := "Code Health" + if len(degraded) > 0 { + healthTitle = fmt.Sprintf("Code Health — %d degraded", len(degraded)) + } + b.WriteString(fmt.Sprintf("
%s\n\n", healthTitle)) + + if len(degraded) > 0 { + b.WriteString("**Degraded:**\n\n") + b.WriteString("| File | Before | After | Delta | Grade |\n") + b.WriteString("|------|--------|-------|-------|-------|\n") + limit := 10 + if len(degraded) < limit { + limit = len(degraded) + } + for _, d := range degraded[:limit] { + b.WriteString(fmt.Sprintf("| `%s` | %d | %d | %+d | %s→%s |\n", + d.File, d.HealthBefore, d.HealthAfter, d.Delta, d.GradeBefore, d.Grade)) + } + if len(degraded) > limit { + b.WriteString(fmt.Sprintf("\n... and %d more degraded files\n", len(degraded)-limit)) + } + b.WriteString("\n") + } + if len(improved) > 0 { + b.WriteString(fmt.Sprintf("**Improved:** %d file(s)\n\n", len(improved))) + } + if len(newFiles) > 0 { + b.WriteString(fmt.Sprintf("**New files:** %d (avg health: %d)\n\n", + len(newFiles), avgHealth(newFiles))) + } + + if resp.HealthReport.Degraded > 0 || resp.HealthReport.Improved > 0 { + b.WriteString(fmt.Sprintf("%d degraded · %d improved · avg %+.1f\n", + resp.HealthReport.Degraded, resp.HealthReport.Improved, resp.HealthReport.AverageDelta)) + } + b.WriteString("\n
\n\n") + } + + // Review Effort + if resp.ReviewEffort != nil { + b.WriteString(fmt.Sprintf("**Estimated review:** %s\n\n", + formatEffortEstimate(resp.ReviewEffort, resp.SplitSuggestion, + resp.Summary.TotalFiles, resp.Summary.TotalChanges))) + } + + // Reviewers + if len(resp.Reviewers) > 0 { + var parts []string + for _, r := range resp.Reviewers { + parts = append(parts, fmt.Sprintf("%s (%.0f%%)", formatReviewerName(r.Owner), r.Coverage*100)) + } + b.WriteString("**Reviewers:** " + strings.Join(parts, " · ") + "\n\n") + } + + // Marker for update-in-place + b.WriteString("\n") + + return b.String() +} + +// filterActionableFindings separates Tier 1+2 (actionable) from Tier 3 (informational), +// strips summary-restatement findings, and priority-sorts the result so the +// budget cap keeps the most important findings. +func filterActionableFindings(findings []query.ReviewFinding) (actionable []query.ReviewFinding, tier3Count int) { + for _, f := range findings { + if isSummaryRestatement(f.Message) { + tier3Count++ + continue + } + if f.Tier <= 2 { + actionable = append(actionable, f) + } else { + tier3Count++ + } + } + // Priority sort: tier 1 first, then by severity within tier + sort.SliceStable(actionable, func(i, j int) bool { + return findingScore(actionable[i]) > findingScore(actionable[j]) + }) + return +} + +func findingScore(f query.ReviewFinding) int { + base := map[int]int{1: 1000, 2: 100, 3: 10}[f.Tier] + sev := map[string]int{"error": 3, "warning": 2, "info": 1}[f.Severity] + return base + sev +} + +// isSummaryRestatement returns true for findings that just restate what's +// already visible in the header/narrative (file count, churn, hotspots, modules). +func isSummaryRestatement(msg string) bool { + summaryPrefixes := []string{ + "Large PR with ", + "Medium-sized PR with ", + "High churn: ", + "Moderate churn: ", + "Touches ", + "Spans ", + "Small, focused change", + } + for _, p := range summaryPrefixes { + if strings.HasPrefix(msg, p) { + return true + } + } + return false +} + +func avgHealth(deltas []query.CodeHealthDelta) int { + if len(deltas) == 0 { + return 0 + } + total := 0 + for _, d := range deltas { + total += d.HealthAfter + } + return total / len(deltas) +} + +// escapeMdTable escapes pipe characters that would break markdown table formatting. +func escapeMdTable(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} + +func sortedMapKeys(m map[string]int) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func formatReviewGitHubActions(resp *query.ReviewPRResponse) string { + var b strings.Builder + + for _, f := range resp.Findings { + level := "notice" + switch f.Severity { + case "error": + level = "error" + case "warning": + level = "warning" + } + + msg := escapeGHA(f.Message) + ruleID := escapeGHA(f.RuleID) + + if f.File != "" { + if f.StartLine > 0 { + b.WriteString(fmt.Sprintf("::%s file=%s,line=%d::%s [%s]\n", + level, f.File, f.StartLine, msg, ruleID)) + } else { + b.WriteString(fmt.Sprintf("::%s file=%s::%s [%s]\n", + level, f.File, msg, ruleID)) + } + } else { + b.WriteString(fmt.Sprintf("::%s::%s [%s]\n", level, msg, ruleID)) + } + } + + return b.String() +} + +// escapeGHA escapes special characters for GitHub Actions workflow commands. +// See: https://github.com/actions/toolkit/blob/main/packages/core/src/command.ts +func escapeGHA(s string) string { + s = strings.ReplaceAll(s, "%", "%25") + s = strings.ReplaceAll(s, "\r", "%0D") + s = strings.ReplaceAll(s, "\n", "%0A") + return s +} diff --git a/cmd/ckb/review_baseline.go b/cmd/ckb/review_baseline.go new file mode 100644 index 00000000..c409791a --- /dev/null +++ b/cmd/ckb/review_baseline.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +var ( + baselineTag string + baselineBaseBranch string + baselineHeadBranch string +) + +var baselineCmd = &cobra.Command{ + Use: "baseline", + Short: "Manage review finding baselines", + Long: `Save, list, and compare review finding baselines. + +Baselines let you snapshot current findings so future reviews +can distinguish new issues from pre-existing ones. + +Examples: + ckb review baseline save # Save with auto-generated tag + ckb review baseline save --tag=v1.0 # Save with named tag + ckb review baseline list # List saved baselines + ckb review baseline diff --tag=latest # Compare current findings against baseline`, +} + +var baselineSaveCmd = &cobra.Command{ + Use: "save", + Short: "Save current findings as a baseline", + Run: runBaselineSave, +} + +var baselineListCmd = &cobra.Command{ + Use: "list", + Short: "List saved baselines", + Run: runBaselineList, +} + +var baselineDiffCmd = &cobra.Command{ + Use: "diff", + Short: "Compare current findings against a baseline", + Run: runBaselineDiff, +} + +func init() { + baselineSaveCmd.Flags().StringVar(&baselineTag, "tag", "", "Baseline tag (default: timestamp)") + baselineSaveCmd.Flags().StringVar(&baselineBaseBranch, "base", "main", "Base branch") + baselineSaveCmd.Flags().StringVar(&baselineHeadBranch, "head", "", "Head branch") + + baselineDiffCmd.Flags().StringVar(&baselineTag, "tag", "latest", "Baseline tag to compare against") + baselineDiffCmd.Flags().StringVar(&baselineBaseBranch, "base", "main", "Base branch") + baselineDiffCmd.Flags().StringVar(&baselineHeadBranch, "head", "", "Head branch") + + baselineCmd.AddCommand(baselineSaveCmd) + baselineCmd.AddCommand(baselineListCmd) + baselineCmd.AddCommand(baselineDiffCmd) + reviewCmd.AddCommand(baselineCmd) +} + +func runBaselineSave(cmd *cobra.Command, args []string) { + logger := newLogger("human") + repoRoot := mustGetRepoRoot() + engine := mustGetEngine(repoRoot, logger) + ctx := newContext() + + // Run review to get current findings + opts := query.ReviewPROptions{ + BaseBranch: baselineBaseBranch, + HeadBranch: baselineHeadBranch, + } + + resp, err := engine.ReviewPR(ctx, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Error running review: %v\n", err) + os.Exit(1) + } + + if err := engine.SaveBaseline(resp.Findings, baselineTag, baselineBaseBranch, baselineHeadBranch); err != nil { + fmt.Fprintf(os.Stderr, "Error saving baseline: %v\n", err) + os.Exit(1) + } + + tag := baselineTag + if tag == "" { + tag = "(auto-generated)" + } + fmt.Printf("Baseline saved: %s (%d findings)\n", tag, len(resp.Findings)) +} + +func runBaselineList(cmd *cobra.Command, args []string) { + logger := newLogger("human") + repoRoot := mustGetRepoRoot() + engine := mustGetEngine(repoRoot, logger) + + baselines, err := engine.ListBaselines() + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing baselines: %v\n", err) + os.Exit(1) + } + + if len(baselines) == 0 { + fmt.Println("No baselines saved yet. Use 'ckb review baseline save' to create one.") + return + } + + fmt.Printf("%-20s %-20s %s\n", "TAG", "CREATED", "FINDINGS") + fmt.Println(strings.Repeat("-", 50)) + for _, b := range baselines { + fmt.Printf("%-20s %-20s %d\n", b.Tag, b.CreatedAt.Format("2006-01-02 15:04"), b.FindingCount) + } +} + +func runBaselineDiff(cmd *cobra.Command, args []string) { + logger := newLogger("human") + repoRoot := mustGetRepoRoot() + engine := mustGetEngine(repoRoot, logger) + ctx := newContext() + + // Load baseline + baseline, err := engine.LoadBaseline(baselineTag) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading baseline %q: %v\n", baselineTag, err) + os.Exit(1) + } + + // Run current review + opts := query.ReviewPROptions{ + BaseBranch: baselineBaseBranch, + HeadBranch: baselineHeadBranch, + } + + resp, err := engine.ReviewPR(ctx, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Error running review: %v\n", err) + os.Exit(1) + } + + // Compare + newFindings, unchanged, resolved := query.CompareWithBaseline(resp.Findings, baseline) + + fmt.Printf("Baseline: %s (%s)\n", baseline.Tag, baseline.CreatedAt.Format("2006-01-02 15:04")) + fmt.Printf("Compared: %d current vs %d baseline findings\n\n", len(resp.Findings), baseline.FindingCount) + + if len(newFindings) > 0 { + fmt.Printf("NEW (%d):\n", len(newFindings)) + for _, f := range newFindings { + loc := f.File + if f.StartLine > 0 { + loc = fmt.Sprintf("%s:%d", f.File, f.StartLine) + } + fmt.Printf(" + %-7s %-40s %s\n", strings.ToUpper(f.Severity), loc, f.Message) + } + fmt.Println() + } + + if len(resolved) > 0 { + fmt.Printf("RESOLVED (%d):\n", len(resolved)) + for _, f := range resolved { + fmt.Printf(" - %-7s %-40s %s\n", strings.ToUpper(f.Severity), f.File, f.Message) + } + fmt.Println() + } + + fmt.Printf("UNCHANGED: %d\n", len(unchanged)) + + if len(newFindings) == 0 && len(resolved) > 0 { + fmt.Println("\nProgress: findings are being resolved!") + } else if len(newFindings) > 0 { + fmt.Printf("\nRegression: %d new finding(s) introduced\n", len(newFindings)) + } +} diff --git a/cmd/ckb/review_lintdedup.go b/cmd/ckb/review_lintdedup.go new file mode 100644 index 00000000..3c7b081c --- /dev/null +++ b/cmd/ckb/review_lintdedup.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +// deduplicateLintFindings removes CKB findings that overlap with an existing +// SARIF lint report. This prevents CKB from flagging issues the user's linter +// already catches, which the research identifies as an instant credibility loss. +// +// Matching is done by (file, line, ruleId-prefix). We don't require exact rule +// IDs because CKB rules (ckb/...) and linter rules (e.g., golangci-lint) use +// different naming. Instead we match on location + message similarity. +// +// Returns the number of suppressed findings. Modifies response in place. +func deduplicateLintFindings(resp *query.ReviewPRResponse, sarifPath string) (int, error) { + data, err := os.ReadFile(sarifPath) + if err != nil { + return 0, fmt.Errorf("read lint report: %w", err) + } + + lintKeys, err := parseSARIFKeys(data) + if err != nil { + return 0, err + } + + if len(lintKeys) == 0 { + return 0, nil + } + + // Filter findings + kept := make([]query.ReviewFinding, 0, len(resp.Findings)) + suppressed := 0 + for _, f := range resp.Findings { + key := lintKey(f.File, f.StartLine) + if lintKeys[key] { + suppressed++ + continue + } + kept = append(kept, f) + } + + resp.Findings = kept + return suppressed, nil +} + +// lintKey builds a dedup key from file path and line number. +// Two findings on the same file:line are considered duplicates regardless of +// the specific rule, since the user has already seen the linter's version. +func lintKey(file string, line int) string { + // Normalize: strip leading ./ or / for comparison + file = strings.TrimPrefix(file, "./") + file = strings.TrimPrefix(file, "/") + return fmt.Sprintf("%s:%d", file, line) +} + +// parseSARIFKeys extracts file:line keys from a SARIF v2.1.0 report. +func parseSARIFKeys(data []byte) (map[string]bool, error) { + // Minimal SARIF parse — only the fields we need + var report struct { + Runs []struct { + Results []struct { + Locations []struct { + PhysicalLocation struct { + ArtifactLocation struct { + URI string `json:"uri"` + } `json:"artifactLocation"` + Region struct { + StartLine int `json:"startLine"` + } `json:"region"` + } `json:"physicalLocation"` + } `json:"locations"` + } `json:"results"` + } `json:"runs"` + } + + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("parse SARIF: %w", err) + } + + keys := make(map[string]bool) + for _, run := range report.Runs { + for _, result := range run.Results { + for _, loc := range result.Locations { + file := loc.PhysicalLocation.ArtifactLocation.URI + line := loc.PhysicalLocation.Region.StartLine + if file != "" && line > 0 { + keys[lintKey(file, line)] = true + } + } + } + } + + return keys, nil +} diff --git a/cmd/ckb/review_lintdedup_test.go b/cmd/ckb/review_lintdedup_test.go new file mode 100644 index 00000000..c6c33d1c --- /dev/null +++ b/cmd/ckb/review_lintdedup_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +func TestDeduplicateLintFindings(t *testing.T) { + t.Parallel() + + sarifReport := `{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "golangci-lint"}}, + "results": [ + { + "ruleId": "errcheck", + "level": "warning", + "message": {"text": "error return value not checked"}, + "locations": [{ + "physicalLocation": { + "artifactLocation": {"uri": "internal/query/engine.go"}, + "region": {"startLine": 42} + } + }] + }, + { + "ruleId": "unused", + "level": "warning", + "message": {"text": "unused variable"}, + "locations": [{ + "physicalLocation": { + "artifactLocation": {"uri": "pkg/config.go"}, + "region": {"startLine": 10} + } + }] + } + ] + }] +}` + + dir := t.TempDir() + sarifPath := filepath.Join(dir, "lint.sarif") + if err := os.WriteFile(sarifPath, []byte(sarifReport), 0644); err != nil { + t.Fatal(err) + } + + resp := &query.ReviewPRResponse{ + Findings: []query.ReviewFinding{ + {Check: "complexity", Severity: "warning", File: "internal/query/engine.go", StartLine: 42, Message: "Complexity increase"}, + {Check: "breaking", Severity: "error", File: "internal/query/engine.go", StartLine: 100, Message: "Breaking change"}, + {Check: "coupling", Severity: "warning", File: "pkg/config.go", StartLine: 10, Message: "Missing co-change"}, + {Check: "secrets", Severity: "error", File: "cmd/main.go", StartLine: 5, Message: "Potential secret"}, + }, + } + + suppressed, err := deduplicateLintFindings(resp, sarifPath) + if err != nil { + t.Fatalf("deduplicateLintFindings: %v", err) + } + + if suppressed != 2 { + t.Errorf("expected 2 suppressed, got %d", suppressed) + } + if len(resp.Findings) != 2 { + t.Errorf("expected 2 remaining findings, got %d", len(resp.Findings)) + } + + // Verify the right findings survived + for _, f := range resp.Findings { + if f.File == "internal/query/engine.go" && f.StartLine == 42 { + t.Error("finding at engine.go:42 should have been suppressed") + } + if f.File == "pkg/config.go" && f.StartLine == 10 { + t.Error("finding at config.go:10 should have been suppressed") + } + } +} + +func TestDeduplicateLintFindings_EmptyReport(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sarifPath := filepath.Join(dir, "empty.sarif") + if err := os.WriteFile(sarifPath, []byte(`{"version":"2.1.0","runs":[{"results":[]}]}`), 0644); err != nil { + t.Fatal(err) + } + + resp := &query.ReviewPRResponse{ + Findings: []query.ReviewFinding{ + {Check: "breaking", Severity: "error", File: "a.go", StartLine: 1, Message: "test"}, + }, + } + + suppressed, err := deduplicateLintFindings(resp, sarifPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if suppressed != 0 { + t.Errorf("expected 0 suppressed, got %d", suppressed) + } + if len(resp.Findings) != 1 { + t.Errorf("expected 1 finding, got %d", len(resp.Findings)) + } +} + +func TestDeduplicateLintFindings_MissingFile(t *testing.T) { + t.Parallel() + + resp := &query.ReviewPRResponse{} + _, err := deduplicateLintFindings(resp, "/nonexistent/path.sarif") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestDeduplicateLintFindings_InvalidJSON(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sarifPath := filepath.Join(dir, "bad.sarif") + if err := os.WriteFile(sarifPath, []byte(`not json`), 0644); err != nil { + t.Fatal(err) + } + + resp := &query.ReviewPRResponse{} + _, err := deduplicateLintFindings(resp, sarifPath) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestLintKey_NormalizesPath(t *testing.T) { + t.Parallel() + + tests := []struct { + file string + line int + want string + }{ + {"internal/query/engine.go", 42, "internal/query/engine.go:42"}, + {"./internal/query/engine.go", 42, "internal/query/engine.go:42"}, + {"/internal/query/engine.go", 42, "internal/query/engine.go:42"}, + } + + for _, tt := range tests { + got := lintKey(tt.file, tt.line) + if got != tt.want { + t.Errorf("lintKey(%q, %d) = %q, want %q", tt.file, tt.line, got, tt.want) + } + } +} diff --git a/docs/plans/ckb_review_architecture.svg b/docs/plans/ckb_review_architecture.svg new file mode 100644 index 00000000..f12adeb4 --- /dev/null +++ b/docs/plans/ckb_review_architecture.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + ckb review + target: file | symbol | --staged | --diff + + + + + + + + + Scope resolver + git diff / SCIP symbol walk / path glob + + + + + + +Parallel analyzer passes + + + + + + Coupling + fan-in / fan-out + blast radius delta + + + + + + Churn risk + commit frequency + author count + + + + + + Complexity + tree-sitter delta + health scoring + + + + + + Dead code + unreferenced + symbols + + + + + + Test coverage + contract gaps + surface vs tests + + + + + + + + + + + + + + + + + + + + Finding aggregator + deduplicate · score · rank by severity + + + + + +Output renderer + + + + + terminal (default) + colour · inline diff + + + + JSON / SARIF + CI · IDE integration + + + + Markdown report + PR comment ready + + + + + + + + + + + Exit code: 0 pass · 1 warnings · 2 errors + CI-friendly · --fail-on configurable + + + + + + + +CKB index +SCIP graph +git history +call graph + + + + +Analyzer pass + +Output format + +CI integration + \ No newline at end of file diff --git a/docs/plans/review-cicd.md b/docs/plans/review-cicd.md new file mode 100644 index 00000000..d62511ac --- /dev/null +++ b/docs/plans/review-cicd.md @@ -0,0 +1,995 @@ +# CKB Review — CI/CD Code Review Engine + +## Entscheidung + +**Direkt in CKB integriert** — kein Modul-System, keine separate App. + +Begründung: +- Engine-zentrische Architektur: eine Methode auf `Engine` → automatisch CLI + HTTP + MCP +- `PresetReview` existiert bereits, wird erweitert +- Alle Analyse-Bausteine sind implementiert — es fehlt nur Orchestrierung + Präsentation +- Kein LLM nötig — rein strukturelle/statische Analyse + +## Architektur + +![Review Architecture](ckb_review_architecture.svg) + +``` +ckb review (CLI) ─┐ +POST /review/pr ─┤──→ Engine.ReviewPR() ──→ Orchestriert: +reviewPR (MCP) ─┘ │ ├─ SummarizePR() [existiert] + │ ├─ CompareAPI() [existiert] + │ ├─ GetAffectedTests() [existiert] + │ ├─ AuditRisk() [existiert] + │ ├─ GetHotspots() [existiert] + │ ├─ GetOwnership() [existiert] + │ ├─ ScanSecrets() [existiert] + │ ├─ CheckCouplingGaps() [NEU] + │ ├─ CompareComplexity() [NEU] + │ ├─ SuggestPRSplit() [NEU] + │ ├─ DetectGeneratedFiles() [NEU] + │ └─ CheckCriticalPaths() [NEU] + │ + ▼ + ReviewPRResponse + │ + ┌────┴────────────────────┐ + ▼ ▼ ▼ ▼ + human markdown sarif codeclimate + (CLI) (PR comment) (GitHub (GitLab + + annotations) Scanning) native) +``` + +## Phase 1: Engine — `internal/query/review.go` + +### ReviewPROptions + +```go +type ReviewPROptions struct { + BaseBranch string `json:"baseBranch"` // default: "main" + HeadBranch string `json:"headBranch"` // default: HEAD + Policy *ReviewPolicy `json:"policy"` // Quality gates (or from .ckb/review.json) + Checks []string `json:"checks"` // Filter: ["breaking","secrets","tests","complexity","coupling","risk","hotspots","size","split","generated","critical"] + MaxInline int `json:"maxInline"` // Max inline suggestions (default: 10) +} + +type ReviewPolicy struct { + // Gates (fail if violated) + NoBreakingChanges bool `json:"noBreakingChanges"` // default: true + NoSecrets bool `json:"noSecrets"` // default: true + RequireTests bool `json:"requireTests"` // default: false + MaxRiskScore float64 `json:"maxRiskScore"` // default: 0.7 (0 = disabled) + MaxComplexityDelta int `json:"maxComplexityDelta"` // default: 0 (disabled) + MaxFiles int `json:"maxFiles"` // default: 0 (disabled) + + // Behavior + FailOnLevel string `json:"failOnLevel"` // "error" (default), "warning", "none" + HoldTheLine bool `json:"holdTheLine"` // Only flag issues on changed lines (default: true) + + // Large PR handling + SplitThreshold int `json:"splitThreshold"` // Suggest split above N files (default: 50) + + // Generated file detection + GeneratedPatterns []string `json:"generatedPatterns"` // Glob patterns for generated files + GeneratedMarkers []string `json:"generatedMarkers"` // Comment markers: ["DO NOT EDIT", "Generated by"] + + // Safety-critical paths (SCADA, automotive, medical, etc.) + CriticalPaths []string `json:"criticalPaths"` // Glob patterns: ["drivers/hw/**", "protocol/**"] + CriticalSeverity string `json:"criticalSeverity"` // Severity when critical paths are touched (default: "error") +} +``` + +### ReviewPRResponse + +```go +type ReviewPRResponse struct { + Verdict string `json:"verdict"` // "pass", "warn", "fail" + Score int `json:"score"` // 0-100 (100 = perfect) + Summary ReviewSummary `json:"summary"` + Checks []ReviewCheck `json:"checks"` + Findings []ReviewFinding `json:"findings"` // All findings, sorted by severity + Reviewers []ReviewerAssignment `json:"reviewers"` // Reviewers with per-cluster assignments + SplitSuggestion *PRSplitSuggestion `json:"splitSuggestion,omitempty"` // If PR is large + ReviewEffort *ReviewEffort `json:"reviewEffort,omitempty"` // Estimated review time + Provenance *Provenance `json:"provenance"` +} + +type ReviewSummary struct { + TotalFiles int `json:"totalFiles"` + TotalChanges int `json:"totalChanges"` // additions + deletions + GeneratedFiles int `json:"generatedFiles"` // Files detected as generated (excluded from review) + ReviewableFiles int `json:"reviewableFiles"` // TotalFiles - GeneratedFiles + CriticalFiles int `json:"criticalFiles"` // Files in critical paths + ChecksPassed int `json:"checksPassed"` + ChecksWarned int `json:"checksWarned"` + ChecksFailed int `json:"checksFailed"` + ChecksSkipped int `json:"checksSkipped"` + TopRisks []string `json:"topRisks"` // Top 3 human-readable risk factors + Languages []string `json:"languages"` + ModulesChanged int `json:"modulesChanged"` +} + +type ReviewCheck struct { + Name string `json:"name"` // "breaking-changes", "secrets", "tests", etc. + Status string `json:"status"` // "pass", "warn", "fail", "skip" + Severity string `json:"severity"` // "error", "warning", "info" + Summary string `json:"summary"` // One-line: "2 breaking changes detected" + Details interface{} `json:"details"` // Check-specific: breaking.Changes[], etc. + Duration int64 `json:"durationMs"` +} + +type ReviewFinding struct { + Check string `json:"check"` // Which check produced this + Severity string `json:"severity"` // "error", "warning", "info" + File string `json:"file"` + StartLine int `json:"startLine,omitempty"` + EndLine int `json:"endLine,omitempty"` + Message string `json:"message"` // Short: "Removed public function Foo()" + Detail string `json:"detail,omitempty"` // Longer explanation + Suggestion string `json:"suggestion,omitempty"` // Concrete action to take + Category string `json:"category"` // "breaking", "security", "testing", "complexity", "coupling", "risk", "critical", "generated", "split" + RuleID string `json:"ruleId,omitempty"` // For SARIF: "ckb/breaking/removed-symbol" +} + +// --- New types for large-PR handling --- + +// PRSplitSuggestion recommends how to split a large PR into independent chunks. +type PRSplitSuggestion struct { + Reason string `json:"reason"` // "PR has 623 files across 8 independent clusters" + Clusters []PRCluster `json:"clusters"` // Independent change clusters + EstimatedGain string `json:"estimatedGain"` // "3x faster review (3×2h vs 1×6h)" +} + +type PRCluster struct { + Name string `json:"name"` // Auto-generated: "Protocol Handler Refactor" + Module string `json:"module"` // Primary module + Files []string `json:"files"` // Files in this cluster + FileCount int `json:"fileCount"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CouplingScore float64 `json:"couplingScore"` // Internal cohesion (0-1, high = tightly coupled) + Independent bool `json:"independent"` // true if no coupling to other clusters + Reviewers []string `json:"reviewers"` // Suggested reviewers for THIS cluster +} + +// ReviewerAssignment extends SuggestedReview with per-cluster assignments. +type ReviewerAssignment struct { + Owner string `json:"owner"` + TotalFiles int `json:"totalFiles"` // Total files they should review + Coverage float64 `json:"coverage"` // % of reviewable files they own + Confidence float64 `json:"confidence"` + Assignments []ClusterAssignment `json:"assignments"` // What to review per cluster +} + +type ClusterAssignment struct { + Cluster string `json:"cluster"` // Cluster name + FileCount int `json:"fileCount"` // Files to review in this cluster + Reason string `json:"reason"` // "Primary owner of protocol/ (84% commits)" +} + +// ReviewEffort estimates review time based on metrics. +type ReviewEffort struct { + EstimatedHours float64 `json:"estimatedHours"` // Total for this PR + SplitEstimate float64 `json:"splitEstimate"` // Per-chunk if split + Factors []string `json:"factors"` // What drives the estimate + Complexity string `json:"complexity"` // "low", "medium", "high" +} + +// GeneratedFileInfo tracks detected generated files. +type GeneratedFileInfo struct { + File string `json:"file"` + Reason string `json:"reason"` // "Matches pattern *.generated.go" or "Contains 'DO NOT EDIT' marker" + SourceFile string `json:"sourceFile,omitempty"` // The source that generates this (e.g. .y → .c for flex/yacc) +} +``` + +### Orchestrierung + +```go +func (e *Engine) ReviewPR(ctx context.Context, opts ReviewPROptions) (*ReviewPRResponse, error) { + // 1. Load policy from .ckb/review.json if not provided + // 2. Run enabled checks in parallel (errgroup) + // 3. Collect findings, apply hold-the-line filter + // 4. Sort findings by severity (error > warning > info), then by file + // 5. Calculate score (100 - deductions per finding) + // 6. Determine verdict based on policy.FailOnLevel + // 7. Get suggested reviewers from ownership + // 8. Return response +} +``` + +**Parallelisierung:** Alle Checks laufen parallel via `errgroup`. Jeder Check ist unabhängig. Die Engine cached Hotspot-Daten intern, also kein doppeltes Laden. + +### Neue Sub-Checks + +#### CheckCouplingGaps — `internal/query/review_coupling.go` + +Nutzt `internal/coupling/` (existiert). Vergleicht das Changeset mit historischen Co-Change-Patterns. + +```go +type CouplingGap struct { + ChangedFile string `json:"changedFile"` + MissingFile string `json:"missingFile"` + CoChangeRate float64 `json:"coChangeRate"` // 0-1, how often they change together + LastCoChange string `json:"lastCoChange"` // Date +} +``` + +Output: "You changed `handler.go` but not `handler_test.go` (87% co-change rate)" + +#### CompareComplexity — `internal/query/review_complexity.go` + +Nutzt `internal/complexity/` (existiert, tree-sitter-basiert). Berechnet Delta pro File. + +```go +type ComplexityDelta struct { + File string `json:"file"` + CyclomaticBefore int `json:"cyclomaticBefore"` + CyclomaticAfter int `json:"cyclomaticAfter"` + CyclomaticDelta int `json:"cyclomaticDelta"` + CognitiveBefore int `json:"cognitiveBefore"` + CognitiveAfter int `json:"cognitiveAfter"` + CognitiveDelta int `json:"cognitiveDelta"` + HottestFunction string `json:"hottestFunction,omitempty"` // Function with highest delta +} +``` + +Output: "Cyclomatic complexity of `parseQuery()` in `engine.go` increased 12 → 18 (+50%)" + +#### SuggestPRSplit — `internal/query/review_split.go` + +Analysiert das Changeset und gruppiert Files in unabhängige Cluster basierend auf: +1. **Modul-Zugehörigkeit** — Files im selben Modul gehören zusammen +2. **Coupling-Daten** — Files die historisch zusammen geändert werden gehören zusammen +3. **Import/Include-Chains** — Files die sich gegenseitig referenzieren gehören zusammen (via SCIP) + +```go +func (e *Engine) SuggestPRSplit(ctx context.Context, changedFiles []string) (*PRSplitSuggestion, error) { + // 1. Build adjacency graph from coupling data + SCIP references + // 2. Find connected components (= independent clusters) + // 3. Name clusters by primary module + // 4. Calculate per-cluster metrics + // 5. Assign reviewers per cluster from ownership data + // 6. Estimate review time reduction +} +``` + +Output bei 600-File-PR: +``` +PR Split Suggestion: 623 files across 4 independent clusters + + Cluster 1: "Protocol Handler Refactor" — 120 files (+2,340 −890) + Reviewers: @alice (protocol owner), @bob (network module) + + Cluster 2: "UI Widget Migration" — 85 files (+1,200 −430) + Reviewers: @charlie (frontend owner) + + Cluster 3: "Config Schema v3" — 53 files (+340 −120) + Reviewers: @alice (config owner) + + Cluster 4: "Test Updates" — 365 files (+4,100 −3,800) + Reviewers: @dave (test infrastructure) + + Clusters 1+2 are fully independent — safe to split into separate PRs. + Cluster 3 depends on Cluster 1 — must be merged after or together. + Estimated review time: 6h as-is → 3×2h if split. +``` + +Triggert automatisch wenn `totalFiles > policy.SplitThreshold` (default: 50). + +#### DetectGeneratedFiles — `internal/query/review_generated.go` + +Erkennt generierte Files über drei Wege: +1. **Marker-Comments** — `"DO NOT EDIT"`, `"Generated by"`, `"AUTO-GENERATED"` in den ersten 10 Zeilen +2. **Glob-Patterns** — Konfigurierbar in Policy: `["*.generated.*", "*.pb.go", "parser.tab.c"]` +3. **Source-Mapping** — Erkennt flex/yacc Paare: wenn `parser.y` im Changeset ist und `parser.tab.c` auch, dann ist `.tab.c` generated + +```go +type GeneratedFileResult struct { + GeneratedFiles []GeneratedFileInfo `json:"generatedFiles"` + TotalExcluded int `json:"totalExcluded"` + SourceFiles []string `json:"sourceFiles"` // The actual files to review (.y, .l, .proto, etc.) +} +``` + +Generierte Files werden: +- Aus der Review-Findings-Liste **ausgeschlossen** (kein Noise) +- Im Summary als eigene Zeile gezeigt: "365 generated files excluded, 258 reviewable" +- **Aber:** Wenn die Source-Datei (.y, .l, .proto) geändert wurde, wird das als eigenes Finding gemeldet mit Link zum generierten Output + +Besonders relevant für: +- **flex/yacc** → `.l`/`.y` → `.c`/`.h` +- **protobuf** → `.proto` → `.pb.go`/`.pb.cc` +- **code generators** → templates → output + +#### CheckCriticalPaths — `internal/query/review_critical.go` + +Prüft ob der PR Files in safety-critical Pfaden berührt (konfiguriert in Policy). + +```go +type CriticalPathResult struct { + CriticalFiles []CriticalFileHit `json:"criticalFiles"` + Escalated bool `json:"escalated"` // true if any critical file was touched +} + +type CriticalFileHit struct { + File string `json:"file"` + Pattern string `json:"pattern"` // Which criticalPaths pattern matched + Additions int `json:"additions"` + Deletions int `json:"deletions"` + BlastRadius int `json:"blastRadius"` // How many other files depend on this + Suggestion string `json:"suggestion"` // "Requires sign-off from safety team" +} +``` + +Output: +``` +⚠ CRITICAL PATH: 3 files in safety-critical paths changed + + drivers/hw/plc_comm.cpp:42 Pattern: drivers/hw/** + Blast radius: 47 files depend on this + → Requires sign-off from safety team + + protocol/modbus_handler.cpp Pattern: protocol/** + Blast radius: 23 files + → Requires sign-off from safety team + + plc/runtime/interpreter.cpp Pattern: plc/** + Blast radius: 112 files + → Requires sign-off from safety team + integration test run +``` + +Bei SCADA/Industrie: konfigurierbar mit eigenen Severity-Leveln und erzwungenen Reviewer-Zuweisungen. + +### Review Effort Estimation + +Basierend auf: +- File count (reviewable, nicht generated) +- Durchschnittliche Complexity der geänderten Files +- Anzahl Module (context switches = langsamer) +- Critical path files (brauchen mehr Aufmerksamkeit) +- Hotspot files (brauchen mehr Aufmerksamkeit) + +Formel (empirisch, kalibrierbar): +``` +base = reviewableFiles * 2min ++ complexFiles * 5min ++ criticalFiles * 15min ++ hotspotFiles * 5min ++ moduleSwitches * 10min (context switch overhead) +``` + +Output: "Estimated review effort: ~6h (258 files, 3 critical, 12 hotspots, 8 module switches)" + +## Phase 2: CLI — `cmd/ckb/review.go` + +```bash +# Local development +ckb review # Review current branch vs main +ckb review --base=develop # Custom base branch +ckb review --checks=breaking,secrets # Only specific checks + +# CI mode +ckb review --ci # Exit codes: 0=pass, 1=fail, 2=warn +ckb review --ci --fail-on=warning # Stricter: warn also fails + +# Output formats +ckb review --format=human # Default: colored terminal output +ckb review --format=json # Machine-readable +ckb review --format=markdown # PR comment ready +ckb review --format=sarif # GitHub Code Scanning +ckb review --format=codeclimate # GitLab Code Quality +ckb review --format=github-actions # ::error file=...:: annotations + +# Policy override +ckb review --no-breaking --require-tests --max-risk=0.5 +``` + +### Output Formate + +#### `human` — Terminal + +``` +╭─ CKB Review: feature/scada-protocol-v3 → main ──────────────╮ +│ Verdict: ⚠ WARN Score: 58/100 │ +│ 623 files · +8,340 −4,890 · 8 modules │ +│ 365 generated (excluded) · 258 reviewable · 3 critical │ +│ Estimated review: ~6h (split → 3×2h) │ +╰──────────────────────────────────────────────────────────────╯ + +Checks: + ✗ FAIL breaking-changes 2 breaking API changes detected + ✗ FAIL secrets 1 potential secret found + ✗ FAIL critical-paths 3 safety-critical files changed + ⚠ WARN pr-split 623 files in 4 independent clusters — split recommended + ⚠ WARN complexity +8 cyclomatic (plc_comm.cpp) + ⚠ WARN coupling 2 commonly co-changed files missing + ✓ PASS affected-tests 12 tests cover the changes + ✓ PASS risk-score 0.42 (low) + ✓ PASS hotspots No additional volatile files + ○ INFO generated 365 generated files detected (parser.tab.c, lexer.c, ...) + +Top Findings: + CRIT drivers/hw/plc_comm.cpp:42 Safety-critical path · blast radius: 47 files + CRIT protocol/modbus_handler.cpp Safety-critical path · blast radius: 23 files + CRIT plc/runtime/interpreter.cpp Safety-critical path · blast radius: 112 files + ERROR internal/api/handler.go:42 Removed public function HandleAuth() + ERROR config/secrets.go:3 Possible API key in string literal + WARN plc/runtime/interpreter.cpp Complexity 14→22 in execInstruction() + WARN protocol/modbus_handler.cpp Missing co-change: modbus_handler_test.cpp (91%) + +PR Split Suggestion: + Cluster 1: "Protocol Handler Refactor" 120 files · @alice, @bob + Cluster 2: "UI Widget Migration" 85 files · @charlie + Cluster 3: "Config Schema v3" 53 files · @alice (depends on Cluster 1) + Cluster 4: "Test Updates" 365 files · @dave + +Reviewer Assignments: + @alice → Protocol Handler (120 files) + Config Schema (53 files) + @bob → Protocol Handler (120 files, co-reviewer) + @charlie → UI Widgets (85 files) + @dave → Test Updates (365 files) +``` + +#### `markdown` — PR Comment + +```markdown +## CKB Review: ⚠ WARN — 58/100 + +**623 files** (+8,340 −4,890) · **8 modules** · `C++` `Custom Script` +**258 reviewable** · 365 generated (excluded) · **3 safety-critical** · Est. ~6h + +| Check | Status | Detail | +|-------|--------|--------| +| Critical Paths | 🔴 FAIL | 3 safety-critical files changed (blast radius: 182) | +| Breaking Changes | 🔴 FAIL | 2 breaking API changes | +| Secrets | 🔴 FAIL | 1 potential secret | +| PR Split | 🟡 WARN | 4 independent clusters — split recommended | +| Complexity | 🟡 WARN | +8 cyclomatic (`plc_comm.cpp`) | +| Coupling | 🟡 WARN | 2 missing co-change files | +| Affected Tests | ✅ PASS | 12 tests cover changes | +| Risk Score | ✅ PASS | 0.42 (low) | +| Generated Files | ℹ️ INFO | 365 files excluded (parser.tab.c, lexer.c, ...) | + +
🔴 Critical Path Findings (3) + +| File | Blast Radius | Action Required | +|------|-------------|-----------------| +| `drivers/hw/plc_comm.cpp:42` | 47 dependents | Safety team sign-off | +| `protocol/modbus_handler.cpp` | 23 dependents | Safety team sign-off | +| `plc/runtime/interpreter.cpp` | 112 dependents | Safety team sign-off + integration test | + +
+ +
📋 All Findings (7) + +| Severity | File | Finding | +|----------|------|---------| +| 🔴 | `drivers/hw/plc_comm.cpp:42` | Safety-critical · blast radius: 47 | +| 🔴 | `protocol/modbus_handler.cpp` | Safety-critical · blast radius: 23 | +| 🔴 | `plc/runtime/interpreter.cpp` | Safety-critical · blast radius: 112 | +| 🔴 | `internal/api/handler.go:42` | Removed public function `HandleAuth()` | +| 🔴 | `config/secrets.go:3` | Possible API key in string literal | +| 🟡 | `plc/runtime/interpreter.cpp` | Complexity 14→22 in `execInstruction()` | +| 🟡 | `protocol/modbus_handler.cpp` | Missing co-change: `modbus_handler_test.cpp` (91%) | + +
+ +
✂️ Suggested PR Split (4 clusters) + +| Cluster | Files | Changes | Reviewers | Independent | +|---------|-------|---------|-----------|-------------| +| Protocol Handler Refactor | 120 | +2,340 −890 | @alice, @bob | ✅ | +| UI Widget Migration | 85 | +1,200 −430 | @charlie | ✅ | +| Config Schema v3 | 53 | +340 −120 | @alice | ❌ (depends on Protocol) | +| Test Updates | 365 | +4,100 −3,800 | @dave | ✅ | + +Split estimate: **3×2h** instead of 1×6h + +
+ +**Reviewers:** @alice (Protocol + Config, 173 files) · @bob (Protocol co-review) · @charlie (UI, 85 files) · @dave (Tests, 365 files) + + +``` + +Das `` erlaubt der GitHub Action, den eigenen Comment zu finden und zu updaten statt neue zu posten. + +#### `sarif` — GitHub Code Scanning + +SARIF v2.1.0 mit CKB als `tool.driver`. Über die Basics hinaus: + +- **`codeFlows`** — Für Impact-Findings: zeigt den Propagationspfad von der Änderung durch die Abhängigkeitskette. GitHub rendert das als "Data Flow" Tab im Alert. +- **`relatedLocations`** — Für Coupling-Findings: zeigt die fehlenden Co-Change-Files als Related Locations. +- **`partialFingerprints`** — Ermöglicht Deduplizierung über Commits hinweg. Findings die in Commit N und N+1 identisch sind, werden nicht doppelt gemeldet. +- **`fixes[]`** — SARIF-Spec unterstützt Fix-Vorschläge als Replacement-Objects. GitHub rendert das noch nicht, aber wenn sie es tun, sind wir vorbereitet. + +#### `codeclimate` — GitLab Code Quality + +Code Climate JSON-Format mit `fingerprint` für Deduplizierung. GitLab rendert das nativ als MR-Widget mit Inline-Annotations im Diff. + +#### `github-actions` — Workflow Commands + +``` +::error file=internal/api/handler.go,line=42::Removed public function HandleAuth() [ckb/breaking/removed-symbol] +::error file=config/secrets.go,line=3::Possible API key in string literal [ckb/secrets/api-key] +::warning file=internal/query/engine.go,line=155::Complexity 12→20 in parseQuery() [ckb/complexity/increase] +``` + +Einfachste Integration — braucht keine API-Calls, GitHub erzeugt automatisch Check-Annotations. + +## Phase 3: MCP Tool — `reviewPR` + +```go +// internal/mcp/tool_impls_review.go +func (s *MCPServer) toolReviewPR(params map[string]interface{}) (*envelope.Response, error) +``` + +Registrierung in `RegisterTools()`, aufgenommen in `PresetReview` und `PresetCore`. + +In `PresetCore` aufnehmen weil: es ist das universelle "vor dem PR aufmachen" Tool. Ein Aufruf statt 6 separate Tool-Calls. + +## Phase 4: HTTP API + +``` +POST /review/pr + Body: ReviewPROptions (JSON) + Response: ReviewPRResponse (JSON) + +GET /review/pr?base=main&head=HEAD&checks=breaking,secrets + Response: ReviewPRResponse (JSON) +``` + +Handler in `internal/api/handlers_review.go`. + +## Phase 5: Review Policy — `.ckb/review.json` + +```json +{ + "version": 1, + "preset": "moderate", + "checks": { + "breaking-changes": { "enabled": true, "severity": "error" }, + "secrets": { "enabled": true, "severity": "error" }, + "critical-paths": { "enabled": true, "severity": "error" }, + "affected-tests": { "enabled": true, "severity": "warning", "requireNew": false }, + "complexity": { "enabled": true, "severity": "warning", "maxDelta": 10 }, + "coupling": { "enabled": true, "severity": "warning", "minCoChangeRate": 0.7 }, + "risk-score": { "enabled": true, "severity": "warning", "maxScore": 0.7 }, + "pr-split": { "enabled": true, "severity": "warning", "threshold": 50 }, + "hotspots": { "enabled": true, "severity": "info" }, + "generated": { "enabled": true, "severity": "info" } + }, + "holdTheLine": true, + "exclude": ["vendor/**", "**/*.generated.go"], + "generatedPatterns": ["*.generated.*", "*.pb.go", "*.pb.cc", "parser.tab.c", "lex.yy.c"], + "generatedMarkers": ["DO NOT EDIT", "Generated by", "AUTO-GENERATED", "This file is generated"], + "criticalPaths": [], + "presets": { + "strict": { "failOnLevel": "warning", "requireTests": true, "noBreakingChanges": true }, + "moderate": { "failOnLevel": "error", "noBreakingChanges": true, "noSecrets": true }, + "permissive": { "failOnLevel": "none" }, + "industrial": { + "failOnLevel": "error", + "noBreakingChanges": true, + "noSecrets": true, + "criticalPaths": ["drivers/**", "protocol/**", "plc/**", "safety/**"], + "criticalSeverity": "error", + "splitThreshold": 30, + "requireTests": true, + "requireTraceability": true, + "requireIndependentReview": true, + "minHealthGrade": "C", + "noHealthDegradation": true + } + } +} +``` + +Geladen über `internal/config/` — fällt auf Defaults zurück wenn nicht vorhanden. + +Das `industrial` Preset ist speziell für SCADA/Automotive/Medical Use Cases mit strengeren Defaults. + +## Phase 6: GitHub Action + +```yaml +# action.yml +name: 'CKB Code Review' +description: 'Automated code review with structural analysis' +inputs: + policy: + description: 'Review policy preset (strict/moderate/permissive)' + default: 'moderate' + checks: + description: 'Comma-separated list of checks to run' + default: '' # all + comment: + description: 'Post PR comment with results' + default: 'true' + sarif: + description: 'Upload SARIF to GitHub Code Scanning' + default: 'false' + fail-on: + description: 'Fail on level (error/warning/none)' + default: '' # from policy +runs: + using: 'composite' + steps: + - name: Install CKB + run: npm install -g @tastehub/ckb + + - name: Index (cached) + run: ckb index + # TODO: Cache .ckb/index between runs + + - name: Run review + id: review + run: | + ckb review --ci --format=json > review.json + ckb review --format=github-actions + echo "verdict=$(jq -r .verdict review.json)" >> $GITHUB_OUTPUT + + - name: Post PR comment + if: inputs.comment == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + // Read markdown output + // Find existing comment by marker + // Create or update comment + + - name: Upload SARIF + if: inputs.sarif == 'true' + run: ckb review --format=sarif > results.sarif + # Then use github/codeql-action/upload-sarif + + - name: Set exit code + if: steps.review.outputs.verdict == 'fail' + run: exit 1 +``` + +Nutzung: + +```yaml +# .github/workflows/review.yml +name: Code Review +on: [pull_request] + +jobs: + review: + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: tastehub/ckb-review@v1 + with: + policy: moderate + comment: true + sarif: true +``` + +## Phase 7: Baseline & Finding Lifecycle — `ckb review baseline` + +Inspiriert von Qodana, PVS-Studio und Trunk: Findings werden nicht nur als "da/nicht da" behandelt, sondern haben einen Lifecycle. + +### Konzept + +```bash +# Baseline setzen (z.B. nach einem Release) +ckb review baseline save --tag=v2.0.0 + +# Review mit Baseline-Vergleich +ckb review --baseline=v2.0.0 +``` + +Findings werden klassifiziert als: +- **New** — Neu eingeführt durch diesen PR +- **Unchanged** — Existierte schon in der Baseline +- **Resolved** — War in der Baseline, ist jetzt behoben + +**Warum das wichtig ist:** Ohne Baseline sieht das Team bei der ersten Einführung hunderte Pre-Existing-Findings. Das tötet die Adoption. Mit Baseline: "Ihr habt 342 bekannte Findings. Dieser PR führt 2 neue ein und löst 1 auf." + +```go +type FindingLifecycle struct { + Status string `json:"status"` // "new", "unchanged", "resolved" + BaselineTag string `json:"baselineTag"` // Which baseline it's compared against + FirstSeen string `json:"firstSeen"` // When this finding was first detected +} +``` + +Baseline wird als SARIF-Snapshot in `.ckb/baselines/` gespeichert. Fingerprinting über `ruleId + file + codeSnippetHash` (überlebt Line-Shifts). + +### CLI + +```bash +ckb review baseline save [--tag=TAG] # Save current state as baseline +ckb review baseline list # Show available baselines +ckb review baseline diff v1.0 v2.0 # Compare two baselines +ckb review --baseline=latest # Compare against most recent baseline +ckb review --new-only # Shortcut: only show new findings +``` + +## Phase 8: Change Classification + +Inspiriert von GitClear: Jede Codeänderung wird kategorisiert. Das gibt dem Review Kontext über die *Art* der Änderung. + +### Kategorien + +| Kategorie | Beschreibung | Review-Aufwand | +|-----------|-------------|----------------| +| **New Code** | Komplett neuer Code | Hoch — braucht volles Review | +| **Refactoring** | Strukturelle Änderung, gleiche Logik | Mittel — Fokus auf Korrektheit der Transformation | +| **Moved Code** | Code an andere Stelle verschoben | Niedrig — Prüfen ob Referenzen stimmen | +| **Churn** | Code der kürzlich geschrieben und jetzt geändert wird | Hoch — deutet auf Instabilität | +| **Config/Build** | Build-Konfiguration, CI, Dependency-Updates | Niedrig — aber Security-Check | +| **Test** | Test-Code | Mittel — Tests müssen korrekt sein | +| **Generated** | Generierter Code | Skip — Source reviewen | + +### Erkennung + +- **Moved Code**: Git rename detection + Inhalt-Ähnlichkeit (>80% = moved) +- **Refactoring**: Gleiche Symbole, andere Struktur (SCIP-basiert). Beispiel: Funktion extrahiert → alte Stelle hat jetzt Call statt Inline-Code. +- **Churn**: File wurde in den letzten 30 Tagen >2× geändert (via `internal/hotspots/`) +- **New vs Modified**: Git diff status (A vs M) + +```go +type ChangeClassification struct { + File string `json:"file"` + Category string `json:"category"` // "new", "refactoring", "moved", "churn", "config", "test", "generated" + Confidence float64 `json:"confidence"` // 0-1 + Detail string `json:"detail"` // "Renamed from old/path.go (94% similar)" +} +``` + +### Impact auf Review + +Im Markdown-Output: +```markdown +### Change Breakdown +| Category | Files | Lines | Review Priority | +|----------|-------|-------|-----------------| +| New Code | 23 | +1,200 | 🔴 Full review | +| Refactoring | 45 | +890 −820 | 🟡 Verify correctness | +| Moved Code | 120 | +3,400 −3,400 | 🟢 Quick check | +| Churn | 8 | +340 −290 | 🔴 Stability concern | +| Test Updates | 62 | +2,100 −1,800 | 🟡 Verify coverage | +| Generated | 365 | +4,100 −3,800 | ⚪ Skip (review source) | +``` + +Das sagt dem Reviewer: "Von 623 Files musst du 23 wirklich genau anschauen, 45 auf Korrektheit prüfen, und den Rest kannst du schnell durchgehen." Das ist der Game-Changer bei 600-File-PRs. + +## Phase 9: Code Health Score & Delta + +Inspiriert von CodeScene: Ein aggregierter Health-Score pro File, der den *Zustand* des Codes beschreibt, nicht nur die Änderung. + +### Health-Faktoren (gewichtet) + +| Faktor | Gewicht | Quelle | +|--------|---------|--------| +| Cyclomatic Complexity | 20% | `internal/complexity/` | +| Cognitive Complexity | 15% | `internal/complexity/` | +| File Size (LOC) | 10% | Git | +| Churn Rate (30d) | 15% | `internal/hotspots/` | +| Coupling Degree | 10% | `internal/coupling/` | +| Bus Factor | 10% | `internal/ownership/` | +| Test Coverage (if available) | 10% | External (Coverage-Report) | +| Age of Last Refactoring | 10% | Git | + +### Score-System + +- **A (90-100)**: Gesunder Code +- **B (70-89)**: Akzeptabel +- **C (50-69)**: Aufmerksamkeit nötig +- **D (30-49)**: Refactoring empfohlen +- **F (0-29)**: Risiko + +### Delta im Review + +```go +type CodeHealthDelta struct { + File string `json:"file"` + HealthBefore int `json:"healthBefore"` // 0-100 + HealthAfter int `json:"healthAfter"` // 0-100 + Delta int `json:"delta"` // negative = degradation + Grade string `json:"grade"` // A/B/C/D/F + GradeBefore string `json:"gradeBefore"` + TopFactor string `json:"topFactor"` // "Cyclomatic complexity increased" +} +``` + +Output: "`engine.go` health: B→C (−12 points, complexity +8)" + +**Quality Gate**: "No file health may drop below D" oder "Average health delta must be ≥ 0" (Code darf nicht schlechter werden). + +## Phase 10: Traceability Check + +Relevant für regulierte Industrie (IEC 61508, IEC 62443, ISO 26262, DO-178C). + +### Konzept + +Jeder Commit/PR muss auf ein Ticket/Requirement verweisen. CKB prüft das. + +```go +type TraceabilityCheck struct { + Enabled bool `json:"enabled"` + Patterns []string `json:"patterns"` // Regex: ["JIRA-\\d+", "REQ-\\d+", "#\\d+"] + Sources []string `json:"sources"` // Where to look: ["commit-message", "branch-name", "pr-title"] + Severity string `json:"severity"` // "error" for SIL 3+, "warning" otherwise +} +``` + +### Was geprüft wird + +1. **Commit-to-Ticket Link**: Mindestens ein Commit im PR referenziert ein Ticket +2. **Orphan Code Warning**: Neue Files die keinem Requirement zugeordnet sind (nur bei `requireTraceability: true`) +3. **Traceability Report**: Exportierbarer Bericht welche Änderungen zu welchen Tickets gehören — für Audits + +### Policy + +```json +{ + "traceability": { + "enabled": true, + "patterns": ["JIRA-\\d+", "REQ-\\d+"], + "sources": ["commit-message", "branch-name"], + "severity": "warning", + "requireForCriticalPaths": true + } +} +``` + +Bei `requireForCriticalPaths: true`: Änderungen an Safety-Critical Paths **müssen** ein Ticket referenzieren (severity: error). + +## Phase 11: Reviewer Independence Enforcement + +IEC 61508 SIL 3+, DO-178C DAL A, ISO 26262 ASIL D verlangen unabhängige Verifikation: der Reviewer darf nicht der Autor sein. + +### Konzept + +```go +type IndependenceCheck struct { + Enabled bool `json:"enabled"` + ForCriticalPaths bool `json:"forCriticalPaths"` // Only enforce for critical paths + MinReviewers int `json:"minReviewers"` // Minimum independent reviewers (default: 1) +} +``` + +Output: "Safety-critical files changed — requires review by independent reviewer (not @author)" + +Das ist ein Check, kein Enforcement — CKB kann GitHub Merge-Rules nicht setzen. Aber es gibt eine klare Warnung/Error und die GitHub Action kann das als `REQUEST_CHANGES` posten. + +## Vergleich: CKB Review vs LLM-basierte Reviews + +| Dimension | CKB Review | LLM Review | SonarQube | CodeScene | +|-----------|-----------|------------|-----------|-----------| +| Breaking Changes | ✅ SCIP-basiert | ⚠️ Best-effort | ❌ | ❌ | +| Secret Detection | ✅ Pattern | ⚠️ Halluzination | ✅ | ❌ | +| Coupling Gaps | ✅ Git-History | ❌ | ❌ | ✅ | +| Complexity Delta | ✅ Tree-sitter | ⚠️ Schätzung | ✅ | ✅ | +| Code Health Score | ✅ 8-Faktor | ❌ | ✅ (partial) | ✅ (25-Faktor) | +| Change Classification | ✅ | ❌ | ❌ | ⚠️ (partial) | +| PR Split Suggestion | ✅ | ❌ | ❌ | ❌ | +| Generated File Detection | ✅ | ⚠️ | ❌ | ❌ | +| Critical Path Enforcement | ✅ | ❌ | ❌ | ❌ | +| Baseline/Finding Lifecycle | ✅ | ❌ | ✅ | ✅ | +| Traceability | ✅ | ❌ | ❌ | ❌ | +| Affected Tests | ✅ Symbol-Graph | ⚠️ Heuristik | ❌ | ❌ | +| Blast Radius | ✅ SCIP | ⚠️ | ❌ | ❌ | +| Reviewer Assignment | ✅ Per-Cluster | ❌ | ❌ | ✅ | +| Review Time Estimate | ✅ | ❌ | ❌ | ⚠️ | +| Code Quality (semantisch) | ❌ | ✅ | ❌ | ❌ | +| Architektur-Feedback | ❌ | ✅ | ❌ | ❌ | +| Geschwindigkeit | ✅ <5s | ⚠️ 30-60s | ⚠️ 1-5min | ✅ <10s | +| Kosten pro Review | ✅ $0 | ⚠️ $0.10-5 | ✅ $0 | ⚠️ $$ | +| Reproduzierbarkeit | ✅ 100% | ⚠️ | ✅ 100% | ✅ 100% | + +**Positionierung:** CKB Review ist das einzige Tool das PR-Splitting, Blast-Radius, Change Classification, Critical Path Enforcement und Traceability in einem Paket vereint. Komplementär zu SonarQube (Bug/Smell-Detection) und LLM-Reviews (semantisches Verständnis). + +**Differenzierung gegenüber CodeScene:** CodeScene hat den besten Health-Score (25 Faktoren), aber kein Symbol-Graph-basiertes Impact-Tracking, keine PR-Split-Vorschläge, keine SCIP-Integration. CKB hat tiefere strukturelle Analyse, CodeScene hat breitere Behavioral-Analyse. Kein direkter Konkurrent, eher komplementär. + +## Implementierungs-Reihenfolge + +### Batch 1 — MVP Engine (parallel) + +Ziel: Funktionierendes `ckb review` mit den Kern-Checks. + +| # | Beschreibung | File | +|---|-------------|------| +| 1 | Engine: `ReviewPR()` Orchestrierung + Types | `internal/query/review.go` | +| 2 | Engine: `CheckCouplingGaps()` | `internal/query/review_coupling.go` | +| 3 | Engine: `CompareComplexity()` | `internal/query/review_complexity.go` | +| 4 | Engine: `DetectGeneratedFiles()` | `internal/query/review_generated.go` | +| 5 | Config: `.ckb/review.json` loading + presets | `internal/config/review.go` | + +### Batch 2 — MVP Interfaces (parallel, nach Batch 1) + +Ziel: CLI + Markdown + MCP. + +| # | Beschreibung | File | +|---|-------------|------| +| 6 | CLI: `ckb review` Command | `cmd/ckb/review.go` | +| 7 | Format: human output | `cmd/ckb/format_review.go` | +| 8 | Format: markdown output | `cmd/ckb/format_review.go` | +| 9 | MCP: `reviewPR` tool | `internal/mcp/tool_impls_review.go` | +| 10 | Preset: Add to `PresetReview` + `PresetCore` | `internal/mcp/presets.go` | + +### Batch 3 — Large PR Intelligence (nach Batch 2) + +Ziel: Das SCADA/Enterprise-Differenzierungsfeature. + +| # | Beschreibung | File | +|---|-------------|------| +| 11 | Engine: `SuggestPRSplit()` — Cluster-Analyse | `internal/query/review_split.go` | +| 12 | Engine: `ClassifyChanges()` — New/Refactor/Moved/Churn | `internal/query/review_classify.go` | +| 13 | Engine: `CheckCriticalPaths()` | `internal/query/review_critical.go` | +| 14 | Engine: Reviewer Cluster-Assignments | `internal/query/review_reviewers.go` | +| 15 | Engine: `EstimateReviewEffort()` | `internal/query/review_effort.go` | + +### Batch 4 — Code Health & Baseline (nach Batch 2) + +Ziel: Finding-Lifecycle und aggregierte Qualitätsmetrik. + +| # | Beschreibung | File | +|---|-------------|------| +| 16 | Engine: `CodeHealthScore()` + Delta | `internal/query/review_health.go` | +| 17 | Baseline: Save/Load/Compare SARIF snapshots | `internal/query/review_baseline.go` | +| 18 | Finding Lifecycle: New/Unchanged/Resolved | `internal/query/review_lifecycle.go` | +| 19 | CLI: `ckb review baseline` subcommands | `cmd/ckb/review_baseline.go` | + +### Batch 5 — Industrial/Compliance (nach Batch 3) + +Ziel: Features für regulierte Industrie. + +| # | Beschreibung | File | +|---|-------------|------| +| 20 | Traceability Check (commit-to-ticket) | `internal/query/review_traceability.go` | +| 21 | Reviewer Independence Enforcement | `internal/query/review_independence.go` | +| 22 | Industrial preset mit SIL-Level-Konfiguration | `internal/config/review.go` | +| 23 | Compliance Evidence Export (PDF/JSON) | `cmd/ckb/format_review_compliance.go` | + +### Batch 6 — CI/CD & Output Formats (parallel, nach Batch 2) + +| # | Beschreibung | File | +|---|-------------|------| +| 24 | Format: SARIF (mit codeFlows, partialFingerprints) | `cmd/ckb/format_review_sarif.go` | +| 25 | Format: Code Climate JSON (GitLab) | `cmd/ckb/format_review_codeclimate.go` | +| 26 | Format: GitHub Actions annotations | `cmd/ckb/format_review.go` | +| 27 | HTTP: `/review/pr` endpoint | `internal/api/handlers_review.go` | +| 28 | GitHub Action (composite) | `action/ckb-review/action.yml` | +| 29 | GitLab CI template | `ci/gitlab-ckb-review.yml` | + +### Batch 7 — Tests (durchgehend) + +| # | Beschreibung | File | +|---|-------------|------| +| 30 | Unit Tests für alle Engine-Operationen | `internal/query/review_*_test.go` | +| 31 | Integration Tests (CLI + Format) | `cmd/ckb/review_test.go` | +| 32 | Golden-File Tests für Output-Formate | `testdata/review/` | + +### Roadmap-Zusammenfassung + +``` +MVP (Batch 1+2) → v8.2: Funktionierendes ckb review +Large PR (Batch 3) → v8.3: PR-Split, Change Classification, Critical Paths +Health & Baseline (Batch 4) → v8.3: Code Health Score, Finding Lifecycle +Industrial (Batch 5) → v8.4: Traceability, Compliance, SIL Levels +CI/CD (Batch 6) → v8.3-8.4: Parallel zu den anderen Batches +``` + +### Was bewusst NICHT in CKB Review gehört + +| Feature | Warum nicht | Wo stattdessen | +|---------|------------|----------------| +| MISRA/CERT Enforcement | Braucht spezialisierten Parser | cppcheck, Helix QAC, PVS-Studio | +| Formale Verifikation | Mathematische Beweisführung | Polyspace | +| Bug-/Smell-Detection | Mustererkennung auf Code-Ebene | SonarQube | +| WCET-Analyse | Hardware-spezifisch | aiT, RapiTime | +| Stack-Tiefe-Analyse | Compiler-spezifisch | GCC -fstack-usage, PVS-Studio | +| Taint-Analyse | Source-to-Sink-Tracking | Semgrep, Snyk Code | + +CKB Review ergänzt diese Tools — es orchestriert und präsentiert, es ersetzt nicht spezialisierte Analyzer. Die SARIF- und CodeClimate-Outputs können mit Outputs dieser Tools in einer CI-Pipeline kombiniert werden. diff --git a/examples/github-actions/README.md b/examples/github-actions/README.md index cc931822..917ad2df 100644 --- a/examples/github-actions/README.md +++ b/examples/github-actions/README.md @@ -4,9 +4,25 @@ This directory contains example GitHub Actions workflows for integrating CKB int ## Workflows -### pr-analysis.yml +### pr-review.yml (Recommended) + +Runs the unified `ckb review` engine on pull requests — 14 quality checks in one command: +- Breaking API changes, secret detection, test coverage +- Complexity delta, code health scoring, coupling gaps +- Hotspot overlap, risk scoring, critical-path checks +- Traceability, reviewer independence, PR split suggestion +- Posts markdown PR comment, emits GHA annotations, uploads SARIF +- CI mode with configurable fail level (error/warning/none) + +**Usage:** +1. Copy to `.github/workflows/pr-review.yml` +2. The workflow runs automatically on PR open/update +3. Customize checks, fail level, and critical paths in the workflow env + +### pr-analysis.yml (Legacy) + +Uses the HTTP API to analyze PRs. Superseded by `pr-review.yml` which uses the CLI directly. -Analyzes pull requests and posts a comment with: - Summary of changed files and lines - Risk assessment (low/medium/high) - Hotspots touched diff --git a/examples/github-actions/pr-review.yml b/examples/github-actions/pr-review.yml new file mode 100644 index 00000000..14b7958d --- /dev/null +++ b/examples/github-actions/pr-review.yml @@ -0,0 +1,166 @@ +# CKB PR Review Workflow +# Runs the unified review engine on pull requests with quality gates. +# Posts a markdown summary as a PR comment and emits GitHub Actions annotations. +# +# Available checks (17 total): +# breaking, secrets, tests, complexity, health, coupling, +# hotspots, risk, critical, traceability, independence, +# generated, classify, split, dead-code, test-gaps, blast-radius +# +# Usage: Copy to .github/workflows/pr-review.yml + +name: CKB PR Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + security-events: write # Required for SARIF upload + +jobs: + review: + name: Code Review + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history needed for coupling, churn, blame + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install CKB + run: npm install -g @tastehub/ckb + + - name: Restore CKB cache + uses: actions/cache@v4 + with: + path: .ckb/ + key: ckb-${{ runner.os }}-${{ hashFiles('**/*.go', '**/*.ts', '**/*.py') }} + restore-keys: | + ckb-${{ runner.os }}- + + - name: Initialize and index + run: | + ckb init + ckb index 2>/dev/null || echo "Indexing skipped (no supported indexer)" + + # --- Option A: Using the composite action (recommended) --- + # Uncomment this and remove Option B if you have the action available. + # + # - name: Run CKB Review + # uses: ./.github/actions/ckb-review # or your-org/ckb-review-action@v1 + # with: + # fail-on: 'error' # or 'warning' / 'none' + # comment: 'true' + # sarif: 'true' + # critical-paths: 'drivers/**,protocol/**' + # # checks: 'breaking,secrets,health' # subset only + # # require-trace: 'true' + # # trace-patterns: 'JIRA-\d+' + # # require-independent: 'true' + # # max-fanout: '20' # blast-radius threshold + + # --- Option B: Direct CLI usage --- + - name: Run review (JSON) + id: review + shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set +e + ckb review --ci --base="${BASE_REF}" --format=json > review.json 2>&1 + EXIT_CODE=$? + set -e + + echo "verdict=$(jq -r '.verdict // "unknown"' review.json)" >> "$GITHUB_OUTPUT" + echo "score=$(jq -r '.score // 0' review.json)" >> "$GITHUB_OUTPUT" + echo "findings=$(jq -r '.findings | length // 0' review.json)" >> "$GITHUB_OUTPUT" + echo "exit_code=${EXIT_CODE}" >> "$GITHUB_OUTPUT" + + - name: Emit GitHub Actions annotations + shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: ckb review --base="${BASE_REF}" --format=github-actions 2>/dev/null || true + + - name: Generate markdown report + shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: ckb review --base="${BASE_REF}" --format=markdown > review-markdown.txt 2>/dev/null || true + + - name: Post PR comment + if: github.event_name == 'pull_request' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + MARKDOWN=$(cat review-markdown.txt 2>/dev/null || echo "CKB review failed to generate output.") + MARKER="" + + # Upsert: update existing comment or create new one + COMMENT_ID=$(gh api \ + "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + 2>/dev/null | head -1) + + if [ -n "${COMMENT_ID}" ]; then + gh api \ + "repos/${GH_REPO}/issues/comments/${COMMENT_ID}" \ + -X PATCH \ + -f body="${MARKDOWN}" + else + gh api \ + "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ + -f body="${MARKDOWN}" + fi + + - name: Upload SARIF (optional) + if: always() + continue-on-error: true + shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: ckb review --base="${BASE_REF}" --format=sarif > results.sarif 2>/dev/null + + - name: Upload SARIF to GitHub Code Scanning + if: always() && hashFiles('results.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + + - name: Summary + shell: bash + env: + VERDICT: ${{ steps.review.outputs.verdict }} + SCORE: ${{ steps.review.outputs.score }} + FINDINGS: ${{ steps.review.outputs.findings }} + run: | + echo "### CKB Review Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Verdict | ${VERDICT} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Findings | ${FINDINGS} |" >> "$GITHUB_STEP_SUMMARY" + + - name: Fail on review verdict + shell: bash + env: + REVIEW_EXIT_CODE: ${{ steps.review.outputs.exit_code }} + run: | + if [ "${REVIEW_EXIT_CODE}" != "0" ]; then + exit "${REVIEW_EXIT_CODE}" + fi diff --git a/go.mod b/go.mod index 0f19955b..0078354c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/SimplyLiz/CodeMCP -go 1.26.0 +go 1.26.1 require ( github.com/BurntSushi/toml v1.6.0 diff --git a/internal/api/handlers_review.go b/internal/api/handlers_review.go new file mode 100644 index 00000000..74691290 --- /dev/null +++ b/internal/api/handlers_review.go @@ -0,0 +1,129 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +// handleReviewPR handles GET/POST /review/pr - unified PR review with quality gates. +func (s *Server) handleReviewPR(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ctx := context.Background() + + policy := query.DefaultReviewPolicy() + opts := query.ReviewPROptions{ + BaseBranch: "main", + Policy: policy, + } + + if r.Method == http.MethodGet { + if base := r.URL.Query().Get("baseBranch"); base != "" { + opts.BaseBranch = base + } + if head := r.URL.Query().Get("headBranch"); head != "" { + opts.HeadBranch = head + } + if failOn := r.URL.Query().Get("failOnLevel"); failOn != "" { + opts.Policy.FailOnLevel = failOn + } + // checks as comma-separated + if checks := r.URL.Query().Get("checks"); checks != "" { + for _, c := range parseCommaSeparated(checks) { + if c != "" { + opts.Checks = append(opts.Checks, c) + } + } + } + // criticalPaths as comma-separated + if paths := r.URL.Query().Get("criticalPaths"); paths != "" { + for _, p := range parseCommaSeparated(paths) { + if p != "" { + opts.Policy.CriticalPaths = append(opts.Policy.CriticalPaths, p) + } + } + } + } else { + var req struct { + BaseBranch string `json:"baseBranch"` + HeadBranch string `json:"headBranch"` + Checks []string `json:"checks"` + FailOnLevel string `json:"failOnLevel"` + CriticalPaths []string `json:"criticalPaths"` + // Policy overrides + BlockBreakingChanges *bool `json:"blockBreakingChanges"` + BlockSecrets *bool `json:"blockSecrets"` + RequireTests *bool `json:"requireTests"` + MaxRiskScore *float64 `json:"maxRiskScore"` + MaxComplexityDelta *int `json:"maxComplexityDelta"` + MaxFiles *int `json:"maxFiles"` + } + if r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + WriteError(w, err, http.StatusBadRequest) + return + } + } + if req.BaseBranch != "" { + opts.BaseBranch = req.BaseBranch + } + if req.HeadBranch != "" { + opts.HeadBranch = req.HeadBranch + } + if len(req.Checks) > 0 { + opts.Checks = req.Checks + } + if req.FailOnLevel != "" { + opts.Policy.FailOnLevel = req.FailOnLevel + } + if len(req.CriticalPaths) > 0 { + opts.Policy.CriticalPaths = req.CriticalPaths + } + if req.BlockBreakingChanges != nil { + opts.Policy.BlockBreakingChanges = *req.BlockBreakingChanges + } + if req.BlockSecrets != nil { + opts.Policy.BlockSecrets = *req.BlockSecrets + } + if req.RequireTests != nil { + opts.Policy.RequireTests = *req.RequireTests + } + if req.MaxRiskScore != nil { + opts.Policy.MaxRiskScore = *req.MaxRiskScore + } + if req.MaxComplexityDelta != nil { + opts.Policy.MaxComplexityDelta = *req.MaxComplexityDelta + } + if req.MaxFiles != nil { + opts.Policy.MaxFiles = *req.MaxFiles + } + } + + resp, err := s.engine.ReviewPR(ctx, opts) + if err != nil { + WriteError(w, err, http.StatusInternalServerError) + return + } + + WriteJSON(w, resp, http.StatusOK) +} + +// parseCommaSeparated splits a comma-separated string and trims whitespace. +func parseCommaSeparated(s string) []string { + var result []string + for _, part := range strings.Split(s, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/internal/api/handlers_review_test.go b/internal/api/handlers_review_test.go new file mode 100644 index 00000000..587ac124 --- /dev/null +++ b/internal/api/handlers_review_test.go @@ -0,0 +1,159 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +func TestHandleReviewPR_GET(t *testing.T) { + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/review/pr?baseBranch=main", nil) + w := httptest.NewRecorder() + + srv.handleReviewPR(w, req) + + // Engine will fail because no git repo, but the handler should return + // a proper error response, not panic. + if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", w.Code) + } + + // If it returned 500, verify it's a JSON error response + if w.Code == http.StatusInternalServerError { + var errResp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { + t.Fatalf("error response not valid JSON: %v", err) + } + if _, ok := errResp["error"]; !ok { + t.Error("error response missing 'error' field") + } + } +} + +func TestHandleReviewPR_POST(t *testing.T) { + srv := newTestServer(t) + + body := `{"baseBranch":"main","checks":["breaking","secrets"],"failOnLevel":"none"}` + req := httptest.NewRequest(http.MethodPost, "/review/pr", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.handleReviewPR(w, req) + + if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", w.Code) + } +} + +func TestHandleReviewPR_POST_PolicyOverrides(t *testing.T) { + srv := newTestServer(t) + + blockFalse := false + maxRisk := 0.5 + body := `{"baseBranch":"main","blockBreakingChanges":false,"maxRiskScore":0.5}` + _ = blockFalse + _ = maxRisk + + req := httptest.NewRequest(http.MethodPost, "/review/pr", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.handleReviewPR(w, req) + + if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", w.Code) + } +} + +func TestHandleReviewPR_MethodNotAllowed(t *testing.T) { + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodDelete, "/review/pr", nil) + w := httptest.NewRecorder() + + srv.handleReviewPR(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } +} + +func TestHandleReviewPR_POST_EmptyBody(t *testing.T) { + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/review/pr", nil) + w := httptest.NewRecorder() + + srv.handleReviewPR(w, req) + + // Should not panic on nil body — falls through to engine with defaults + if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", w.Code) + } +} + +func TestHandleReviewPR_POST_InvalidJSON(t *testing.T) { + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/review/pr", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.handleReviewPR(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleReviewPR_GET_WithChecksAndCriticalPaths(t *testing.T) { + srv := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/review/pr?checks=breaking,secrets&criticalPaths=cmd/**,internal/**", nil) + w := httptest.NewRecorder() + + srv.handleReviewPR(w, req) + + if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", w.Code) + } +} + +func TestParseCommaSeparated(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"", 0}, + {"a", 1}, + {"a,b,c", 3}, + {" a , b , c ", 3}, + {"a,,b", 2}, // empty segments filtered + {",,,", 0}, + } + for _, tt := range tests { + got := parseCommaSeparated(tt.input) + if len(got) != tt.want { + t.Errorf("parseCommaSeparated(%q) = %d items, want %d", tt.input, len(got), tt.want) + } + } +} + +func TestDefaultReviewPolicy(t *testing.T) { + p := query.DefaultReviewPolicy() + if p.FailOnLevel != "error" { + t.Errorf("default FailOnLevel = %q, want 'error'", p.FailOnLevel) + } + if !p.BlockBreakingChanges { + t.Error("default BlockBreakingChanges should be true") + } + if !p.BlockSecrets { + t.Error("default BlockSecrets should be true") + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index cd402f07..973de122 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -50,6 +50,9 @@ func (s *Server) registerRoutes() { s.router.HandleFunc("/audit", s.handleAudit) // GET /audit?minScore=...&limit=... s.router.HandleFunc("/diff/summary", s.handleDiffSummary) // POST /diff/summary + // v8.2 Unified PR Review + s.router.HandleFunc("/review/pr", s.handleReviewPR) // GET/POST + // v6.2 Federation endpoints s.router.HandleFunc("/federations", s.handleListFederations) // GET s.router.HandleFunc("/federations/", s.handleFederationRoutes) // /federations/:name/* @@ -135,6 +138,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "POST /coupling - Check for missing tightly-coupled files in a change set", "GET /audit?minScore=...&limit=...&factor=... - Multi-factor risk audit", "POST /diff/summary - Summarize changes between git refs", + "GET/POST /review/pr - Unified PR review with quality gates", "GET /federations - List all federations", "GET /federations/:name/status - Federation status", "GET /federations/:name/repos - List repos in federation", diff --git a/internal/backends/git/adapter.go b/internal/backends/git/adapter.go index 2e084677..52db0822 100644 --- a/internal/backends/git/adapter.go +++ b/internal/backends/git/adapter.go @@ -119,6 +119,15 @@ func (g *GitAdapter) Capabilities() []string { } } +// GetHeadAuthorEmail returns the author email of the HEAD commit. +func (g *GitAdapter) GetHeadAuthorEmail() (string, error) { + output, err := g.executeGitCommand("log", "-1", "--format=%ae", "HEAD") + if err != nil { + return "", err + } + return strings.TrimSpace(output), nil +} + // executeGitCommand runs a git command with timeout and returns the output func (g *GitAdapter) executeGitCommand(args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), g.queryTimeout) diff --git a/internal/backends/git/diff.go b/internal/backends/git/diff.go index 0bb935d6..24584080 100644 --- a/internal/backends/git/diff.go +++ b/internal/backends/git/diff.go @@ -443,6 +443,43 @@ func (g *GitAdapter) GetCommitsSinceDate(since string, limit int) ([]CommitInfo, return commits, nil } +// GetCommitRange returns commits between base and head refs. +func (g *GitAdapter) GetCommitRange(base, head string) ([]CommitInfo, error) { + if base == "" { + base = "main" + } + if head == "" { + head = "HEAD" + } + + args := []string{ + "log", + "--format=%H|%an|%aI|%s", + base + ".." + head, + } + + lines, err := g.executeGitCommandLines(args...) + if err != nil { + return nil, err + } + + commits := make([]CommitInfo, 0, len(lines)) + for _, line := range lines { + parts := strings.SplitN(line, "|", 4) + if len(parts) != 4 { + continue + } + commits = append(commits, CommitInfo{ + Hash: parts[0], + Author: parts[1], + Timestamp: parts[2], + Message: parts[3], + }) + } + + return commits, nil +} + // GetFileDiffContent returns the actual diff content for a commit range func (g *GitAdapter) GetFileDiffContent(base, head, filePath string) (string, error) { args := []string{"diff", base, head, "--", filePath} diff --git a/internal/config/config.go b/internal/config/config.go index 2e78dcc3..e80092a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,6 +56,9 @@ type Config struct { // v8.1 Change Impact Analysis Coverage CoverageConfig `json:"coverage" mapstructure:"coverage"` + + // v8.2 Unified PR Review + Review ReviewConfig `json:"review" mapstructure:"review"` } // CoverageConfig contains coverage file configuration (v8.1) @@ -65,6 +68,41 @@ type CoverageConfig struct { MaxAge string `json:"maxAge" mapstructure:"maxAge"` // Max age before marking as stale (default: "168h" = 7 days) } +// ReviewConfig contains PR review policy defaults (v8.2) +type ReviewConfig struct { + // Policy defaults (can be overridden per-invocation) + BlockBreakingChanges bool `json:"blockBreakingChanges" mapstructure:"blockBreakingChanges"` // Fail on breaking API changes + BlockSecrets bool `json:"blockSecrets" mapstructure:"blockSecrets"` // Fail on detected secrets + RequireTests bool `json:"requireTests" mapstructure:"requireTests"` // Warn if no tests cover changes + MaxRiskScore float64 `json:"maxRiskScore" mapstructure:"maxRiskScore"` // Maximum risk score (0 = disabled) + MaxComplexityDelta int `json:"maxComplexityDelta" mapstructure:"maxComplexityDelta"` // Maximum complexity delta (0 = disabled) + MaxFiles int `json:"maxFiles" mapstructure:"maxFiles"` // Maximum file count (0 = disabled) + FailOnLevel string `json:"failOnLevel" mapstructure:"failOnLevel"` // error, warning, none + + // Generated file detection + GeneratedPatterns []string `json:"generatedPatterns" mapstructure:"generatedPatterns"` // Glob patterns for generated files + GeneratedMarkers []string `json:"generatedMarkers" mapstructure:"generatedMarkers"` // Comment markers (e.g., "DO NOT EDIT") + + // Safety-critical paths + CriticalPaths []string `json:"criticalPaths" mapstructure:"criticalPaths"` // Glob patterns requiring extra scrutiny + + // Traceability (commit-to-ticket linkage) + TraceabilityPatterns []string `json:"traceabilityPatterns" mapstructure:"traceabilityPatterns"` // Regex: ["JIRA-\\d+", "#\\d+"] + TraceabilitySources []string `json:"traceabilitySources" mapstructure:"traceabilitySources"` // Where to look: commit-message, branch-name + RequireTraceability bool `json:"requireTraceability" mapstructure:"requireTraceability"` // Enforce ticket references + RequireTraceForCriticalPaths bool `json:"requireTraceForCriticalPaths" mapstructure:"requireTraceForCriticalPaths"` // Enforce for critical paths only + + // Reviewer independence + RequireIndependentReview bool `json:"requireIndependentReview" mapstructure:"requireIndependentReview"` // Author != reviewer + MinReviewers int `json:"minReviewers" mapstructure:"minReviewers"` // Minimum reviewer count + + // Analyzer thresholds (v8.3) + MaxBlastRadiusDelta int `json:"maxBlastRadiusDelta" mapstructure:"maxBlastRadiusDelta"` // 0 = disabled + MaxFanOut int `json:"maxFanOut" mapstructure:"maxFanOut"` // 0 = disabled + DeadCodeMinConfidence float64 `json:"deadCodeMinConfidence" mapstructure:"deadCodeMinConfidence"` // default 0.8 + TestGapMinLines int `json:"testGapMinLines" mapstructure:"testGapMinLines"` // default 5 +} + // BackendsConfig contains backend-specific configuration type BackendsConfig struct { Scip ScipConfig `json:"scip" mapstructure:"scip"` @@ -392,6 +430,18 @@ func DefaultConfig() *Config { AutoDetect: true, MaxAge: "168h", // 7 days }, + Review: ReviewConfig{ + BlockBreakingChanges: true, + BlockSecrets: true, + RequireTests: false, + MaxRiskScore: 0.7, + MaxComplexityDelta: 0, // disabled by default + MaxFiles: 0, // disabled by default + FailOnLevel: "error", + GeneratedPatterns: []string{}, + GeneratedMarkers: []string{}, + CriticalPaths: []string{}, + }, Telemetry: TelemetryConfig{ Enabled: false, // Explicit opt-in required ServiceMap: map[string]string{}, diff --git a/internal/mcp/presets.go b/internal/mcp/presets.go index 5dc0d296..5266945d 100644 --- a/internal/mcp/presets.go +++ b/internal/mcp/presets.go @@ -85,6 +85,7 @@ var Presets = map[string][]string{ "getOwnershipDrift", "recentlyRelevant", "scanSecrets", // v8.0: Secret detection for PR reviews + "reviewPR", // v8.2: Unified PR review with quality gates }, // Refactor: core + refactoring analysis tools diff --git a/internal/mcp/presets_test.go b/internal/mcp/presets_test.go index 49025562..c1965761 100644 --- a/internal/mcp/presets_test.go +++ b/internal/mcp/presets_test.go @@ -42,9 +42,9 @@ func TestPresetFiltering(t *testing.T) { t.Fatalf("failed to set full preset: %v", err) } fullTools := server.GetFilteredTools() - // v8.1: Full now includes switchProject + analyzeTestGaps + planRefactor + findCycles + suggestRefactorings (92 = 88 + 4) - if len(fullTools) != 92 { - t.Errorf("expected 92 full tools (v8.1 includes analyzeTestGaps + planRefactor + findCycles + suggestRefactorings), got %d", len(fullTools)) + // v8.2: Full now includes reviewPR (93 = 92 + 1) + if len(fullTools) != 93 { + t.Errorf("expected 93 full tools (v8.2 includes reviewPR), got %d", len(fullTools)) } // Full preset should still have core tools first diff --git a/internal/mcp/token_budget_test.go b/internal/mcp/token_budget_test.go index 74225817..bd1adbc4 100644 --- a/internal/mcp/token_budget_test.go +++ b/internal/mcp/token_budget_test.go @@ -15,7 +15,7 @@ const ( // v8.0: Increased budgets for compound tools (explore, understand, prepareChange, batchGet, batchSearch) maxCorePresetBytes = 60000 // ~15k tokens - v8.0: core now includes 5 compound tools maxReviewPresetBytes = 80000 // ~20k tokens - review adds a few tools - maxFullPresetBytes = 280000 // ~70k tokens - all 92 tools (v8.1: +findCycles, +suggestRefactorings) + maxFullPresetBytes = 285000 // ~71k tokens - all 93 tools (v8.2: +reviewPR) // Per-tool schema budget (bytes) - catches bloated schemas maxToolSchemaBytes = 6000 // ~1500 tokens per tool @@ -34,8 +34,8 @@ func TestToolsListTokenBudget(t *testing.T) { maxTools int }{ {PresetCore, maxCorePresetBytes, 17, 21}, // v8.0: 19 tools (14 + 5 compound) - {PresetReview, maxReviewPresetBytes, 22, 27}, // v8.0: 24 tools (19 + 5 review-specific) - {PresetFull, maxFullPresetBytes, 80, 92}, // v8.1: 92 tools (+findCycles, +suggestRefactorings) + {PresetReview, maxReviewPresetBytes, 22, 28}, // v8.2: 28 tools (27 + reviewPR) + {PresetFull, maxFullPresetBytes, 80, 93}, // v8.2: 93 tools (+reviewPR) } for _, tt := range tests { diff --git a/internal/mcp/tool_impls_review.go b/internal/mcp/tool_impls_review.go new file mode 100644 index 00000000..743fc1d3 --- /dev/null +++ b/internal/mcp/tool_impls_review.go @@ -0,0 +1,80 @@ +package mcp + +import ( + "context" + + "github.com/SimplyLiz/CodeMCP/internal/envelope" + "github.com/SimplyLiz/CodeMCP/internal/errors" + "github.com/SimplyLiz/CodeMCP/internal/query" +) + +// toolReviewPR runs a comprehensive PR review with quality gates. +func (s *MCPServer) toolReviewPR(params map[string]interface{}) (*envelope.Response, error) { + ctx := context.Background() + + // Parse baseBranch + baseBranch := "main" + if v, ok := params["baseBranch"].(string); ok && v != "" { + baseBranch = v + } + + // Parse headBranch + headBranch := "" + if v, ok := params["headBranch"].(string); ok { + headBranch = v + } + + // Parse checks filter + var checks []string + if v, ok := params["checks"].([]interface{}); ok { + for _, c := range v { + if cs, ok := c.(string); ok { + checks = append(checks, cs) + } + } + } + + // Parse failOnLevel + failOnLevel := "" + if v, ok := params["failOnLevel"].(string); ok { + failOnLevel = v + } + + // Parse critical paths + var criticalPaths []string + if v, ok := params["criticalPaths"].([]interface{}); ok { + for _, p := range v { + if ps, ok := p.(string); ok { + criticalPaths = append(criticalPaths, ps) + } + } + } + + policy := query.DefaultReviewPolicy() + if failOnLevel != "" { + policy.FailOnLevel = failOnLevel + } + if len(criticalPaths) > 0 { + policy.CriticalPaths = criticalPaths + } + + s.logger.Debug("Executing reviewPR", + "baseBranch", baseBranch, + "headBranch", headBranch, + "checks", checks, + ) + + result, err := s.engine().ReviewPR(ctx, query.ReviewPROptions{ + BaseBranch: baseBranch, + HeadBranch: headBranch, + Policy: policy, + Checks: checks, + }) + if err != nil { + return nil, errors.NewOperationError("review PR", err) + } + + return NewToolResponse(). + Data(result). + Build(), nil +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index dacb707c..93ef8486 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -1847,6 +1847,40 @@ func (s *MCPServer) GetToolDefinitions() []Tool { }, }, }, + // v8.2 Unified PR Review + { + Name: "reviewPR", + Description: "Run a comprehensive PR review with quality gates. Orchestrates 14 checks (breaking, secrets, tests, complexity, health, coupling, hotspots, risk, critical-path, traceability, independence, generated, classify, split) concurrently where safe. Returns verdict (pass/warn/fail), score, findings, and suggested reviewers.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "baseBranch": map[string]interface{}{ + "type": "string", + "default": "main", + "description": "Base branch to compare against", + }, + "headBranch": map[string]interface{}{ + "type": "string", + "description": "Head branch (default: current branch)", + }, + "checks": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Limit to specific checks: breaking, secrets, tests, complexity, coupling, hotspots, risk, critical, generated, classify, split, health, traceability, independence", + }, + "failOnLevel": map[string]interface{}{ + "type": "string", + "enum": []string{"error", "warning", "none"}, + "description": "Override when to fail: error (default), warning, or none", + }, + "criticalPaths": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Glob patterns for safety-critical paths (e.g., drivers/**, protocol/**)", + }, + }, + }, + }, // v7.3 Doc-Symbol Linking tools { Name: "getDocsForSymbol", @@ -2334,6 +2368,8 @@ func (s *MCPServer) RegisterTools() { s.tools["auditRisk"] = s.toolAuditRisk // v8.0 Secret Detection s.tools["scanSecrets"] = s.toolScanSecrets + // v8.2 Unified Review + s.tools["reviewPR"] = s.toolReviewPR // v7.3 Doc-Symbol Linking tools s.tools["getDocsForSymbol"] = s.toolGetDocsForSymbol s.tools["getSymbolsInDoc"] = s.toolGetSymbolsInDoc diff --git a/internal/query/engine.go b/internal/query/engine.go index 17ecedb6..e4b38192 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -55,6 +55,10 @@ type Engine struct { // Tier detector for capability gating tierDetector *tier.Detector + // Tree-sitter mutex — go-tree-sitter uses cgo and is NOT safe for + // concurrent use. All tree-sitter calls must hold this lock. + tsMu sync.Mutex + // Cached repo state repoStateMu sync.RWMutex cachedState *RepoState diff --git a/internal/query/navigation.go b/internal/query/navigation.go index b5ca6f44..c30b2c1f 100644 --- a/internal/query/navigation.go +++ b/internal/query/navigation.go @@ -2433,9 +2433,10 @@ func computeDiffConfidence(basis []ConfidenceBasisItem, limitations []string) fl // GetHotspotsOptions controls getHotspots behavior. type GetHotspotsOptions struct { - TimeWindow *TimeWindowSelector `json:"timeWindow,omitempty"` - Scope string `json:"scope,omitempty"` // Module to focus on - Limit int `json:"limit,omitempty"` // Max results (default 20) + TimeWindow *TimeWindowSelector `json:"timeWindow,omitempty"` + Scope string `json:"scope,omitempty"` // Module to focus on + Limit int `json:"limit,omitempty"` // Max results (default 20) + SkipComplexity bool `json:"skipComplexity,omitempty"` // Skip tree-sitter enrichment (faster) } // GetHotspotsResponse provides ranked hotspot files. @@ -2611,7 +2612,7 @@ func (e *Engine) GetHotspots(ctx context.Context, opts GetHotspotsOptions) (*Get } // Add complexity data via tree-sitter (v6.2.2) - if e.complexityAnalyzer != nil { + if e.complexityAnalyzer != nil && !opts.SkipComplexity { for i := range hotspots { fc, err := e.complexityAnalyzer.GetFileComplexityFull(ctx, filepath.Join(e.repoRoot, hotspots[i].FilePath)) if err == nil && fc.Error == "" && fc.FunctionCount > 0 { diff --git a/internal/query/pr.go b/internal/query/pr.go index 8d56e6f0..b1fc42a7 100644 --- a/internal/query/pr.go +++ b/internal/query/pr.go @@ -3,6 +3,7 @@ package query import ( "context" "fmt" + "path/filepath" "sort" "strings" "time" @@ -72,10 +73,13 @@ type PRRiskAssessment struct { // SuggestedReview represents a suggested reviewer. type SuggestedReview struct { - Owner string `json:"owner"` - Reason string `json:"reason"` - Coverage float64 `json:"coverage"` // % of changed files they own - Confidence float64 `json:"confidence"` + Owner string `json:"owner"` + Reason string `json:"reason"` + Coverage float64 `json:"coverage"` // % of changed files they own + Confidence float64 `json:"confidence"` + ExpertiseArea string `json:"expertiseArea,omitempty"` // Top module/directory they own + LastActiveAt string `json:"lastActiveAt,omitempty"` // RFC3339 of last commit + IsAuthor bool `json:"isAuthor,omitempty"` // True if this person is the PR author } // SummarizePR generates a summary of changes between branches. @@ -117,6 +121,9 @@ func (e *Engine) SummarizePR(ctx context.Context, opts SummarizePROptions) (*Sum totalDeletions := 0 hotspotCount := 0 + // Fetch hotspots once and build a lookup map (instead of per-file). + hotspotScores := e.getHotspotScoreMap(ctx) + for _, df := range diffStats { // Determine status from DiffStats flags status := "modified" @@ -151,10 +158,9 @@ func (e *Engine) SummarizePR(ctx context.Context, opts SummarizePROptions) (*Sum } // Check if file is a hotspot - hotspotScore := e.getFileHotspotScore(ctx, df.FilePath) - if hotspotScore > 0.5 { + if score, ok := hotspotScores[df.FilePath]; ok && score > 0.5 { change.IsHotspot = true - change.HotspotScore = hotspotScore + change.HotspotScore = score hotspotCount++ } @@ -251,55 +257,103 @@ func (e *Engine) resolveFileModule(filePath string) string { return "" } -// getFileHotspotScore returns the hotspot score for a file (0-1). -func (e *Engine) getFileHotspotScore(ctx context.Context, filePath string) float64 { - // Try to get hotspot data from cache or compute - opts := GetHotspotsOptions{Limit: 100} - resp, err := e.GetHotspots(ctx, opts) +// getHotspotScoreMap fetches hotspots once and returns a file→score map. +func (e *Engine) getHotspotScoreMap(ctx context.Context) map[string]float64 { + resp, err := e.GetHotspots(ctx, GetHotspotsOptions{Limit: 100}) if err != nil { - return 0 + return nil } - + scores := make(map[string]float64, len(resp.Hotspots)) for _, h := range resp.Hotspots { - if h.FilePath == filePath && h.Ranking != nil { - return h.Ranking.Score + if h.Ranking != nil { + scores[h.FilePath] = h.Ranking.Score } } - - return 0 + return scores } // getSuggestedReviewers identifies potential reviewers based on ownership. func (e *Engine) getSuggestedReviewers(ctx context.Context, files []PRFileChange) []SuggestedReview { - ownerCounts := make(map[string]int) + type ownerStats struct { + fileCount int + dirs map[string]int // directory → file count (for expertise area) + } + ownerMap := make(map[string]*ownerStats) totalFiles := len(files) - for _, f := range files { - opts := GetOwnershipOptions{Path: f.Path} + // Cap ownership lookups to avoid N×git-blame calls on large PRs. + // Only run blame for the first 10 files (most expensive), CODEOWNERS-only + // for the next 20, and skip the rest — the top owners still surface. + const maxOwnershipLookups = 30 + for i, f := range files { + if i >= maxOwnershipLookups { + break + } + opts := GetOwnershipOptions{Path: f.Path, IncludeBlame: i < 10} resp, err := e.GetOwnership(ctx, opts) if err != nil || resp == nil { continue } + dir := filepath.Dir(f.Path) for _, owner := range resp.Owners { - ownerCounts[owner.ID]++ + stats, ok := ownerMap[owner.ID] + if !ok { + stats = &ownerStats{dirs: make(map[string]int)} + ownerMap[owner.ID] = stats + } + stats.fileCount++ + stats.dirs[dir]++ + } + } + + // Detect PR author from HEAD commit + prAuthor := "" + if e.gitAdapter != nil { + if author, err := e.gitAdapter.GetHeadAuthorEmail(); err == nil { + prAuthor = author } } - // Convert to suggestions + // Convert to suggestions with expertise area var suggestions []SuggestedReview - for owner, count := range ownerCounts { - coverage := float64(count) / float64(totalFiles) + for owner, stats := range ownerMap { + coverage := float64(stats.fileCount) / float64(totalFiles) + + // Find top directory for expertise area + topDir := "" + topCount := 0 + for dir, count := range stats.dirs { + if count > topCount { + topDir = dir + topCount = count + } + } + + isAuthor := owner == prAuthor + reason := fmt.Sprintf("Owns %d of %d changed files", stats.fileCount, totalFiles) + if topDir != "" && topDir != "." { + reason += fmt.Sprintf(" (expert: %s)", topDir) + } + if isAuthor { + reason += " [author — needs independent reviewer]" + } + suggestions = append(suggestions, SuggestedReview{ - Owner: owner, - Reason: fmt.Sprintf("Owns %d of %d changed files", count, totalFiles), - Coverage: coverage, - Confidence: coverage, + Owner: owner, + Reason: reason, + Coverage: coverage, + Confidence: coverage, + ExpertiseArea: topDir, + IsAuthor: isAuthor, }) } - // Sort by coverage - sort.Slice(suggestions, func(i, j int) bool { + // Sort: non-authors first, then by coverage + sort.SliceStable(suggestions, func(i, j int) bool { + if suggestions[i].IsAuthor != suggestions[j].IsAuthor { + return !suggestions[i].IsAuthor // non-authors first + } return suggestions[i].Coverage > suggestions[j].Coverage }) @@ -354,6 +408,11 @@ func calculatePRRisk(fileCount, totalChanges, hotspotCount, moduleCount int) PRR suggestions = append(suggestions, "Consider module-specific reviewers") } + // Clamp score to [0, 1] + if score > 1.0 { + score = 1.0 + } + // Determine level level := "low" if score > 0.6 { diff --git a/internal/query/review.go b/internal/query/review.go new file mode 100644 index 00000000..dd820edf --- /dev/null +++ b/internal/query/review.go @@ -0,0 +1,1415 @@ +package query + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/SimplyLiz/CodeMCP/internal/backends/git" + "github.com/SimplyLiz/CodeMCP/internal/config" + "github.com/SimplyLiz/CodeMCP/internal/secrets" + "github.com/SimplyLiz/CodeMCP/internal/version" +) + +// ReviewPROptions configures the unified PR review. +type ReviewPROptions struct { + BaseBranch string `json:"baseBranch"` // default: "main" + HeadBranch string `json:"headBranch"` // default: HEAD + Policy *ReviewPolicy `json:"policy"` // Quality gates (or from .ckb/review.json) + Checks []string `json:"checks"` // Filter which checks to run (default: all) + MaxInline int `json:"maxInline"` // Max inline suggestions (default: 10) + Staged bool `json:"staged"` // Review staged changes instead of branch diff + Scope string `json:"scope"` // Filter to path prefix or symbol name +} + +// ReviewPolicy defines quality gates and behavior. +type ReviewPolicy struct { + // Gates + BlockBreakingChanges bool `json:"blockBreakingChanges"` // default: true + BlockSecrets bool `json:"blockSecrets"` // default: true + RequireTests bool `json:"requireTests"` // default: false + MaxRiskScore float64 `json:"maxRiskScore"` // default: 0.7 (0 = disabled) + MaxComplexityDelta int `json:"maxComplexityDelta"` // default: 0 (disabled) + MaxFiles int `json:"maxFiles"` // default: 0 (disabled) + + // Behavior + FailOnLevel string `json:"failOnLevel"` // "error" (default), "warning", "none" + HoldTheLine bool `json:"holdTheLine"` // Only flag issues on changed lines (default: true) + + // Large PR handling + SplitThreshold int `json:"splitThreshold"` // Suggest split above N files (default: 50) + + // Generated file detection + GeneratedPatterns []string `json:"generatedPatterns"` // Glob patterns + GeneratedMarkers []string `json:"generatedMarkers"` // Comment markers in first 10 lines + + // Safety-critical paths + CriticalPaths []string `json:"criticalPaths"` // Glob patterns + CriticalSeverity string `json:"criticalSeverity"` // default: "error" + + // Traceability (commit-to-ticket linkage) + TraceabilityPatterns []string `json:"traceabilityPatterns"` // Regex patterns for ticket IDs + TraceabilitySources []string `json:"traceabilitySources"` // Where to look: "commit-message", "branch-name" + RequireTraceability bool `json:"requireTraceability"` // Enforce ticket references + RequireTraceForCriticalPaths bool `json:"requireTraceForCriticalPaths"` // Only enforce for critical paths + + // Reviewer independence (regulated industry) + RequireIndependentReview bool `json:"requireIndependentReview"` // Author != reviewer + MinReviewers int `json:"minReviewers"` // Minimum independent reviewers (default: 1) + + // Analyzer thresholds (v8.3) + MaxBlastRadiusDelta int `json:"maxBlastRadiusDelta"` // 0 = disabled + MaxFanOut int `json:"maxFanOut"` // 0 = disabled + DeadCodeMinConfidence float64 `json:"deadCodeMinConfidence"` // default 0.8 + TestGapMinLines int `json:"testGapMinLines"` // default 5 +} + +// ReviewPRResponse is the unified review result. +type ReviewPRResponse struct { + CkbVersion string `json:"ckbVersion"` + SchemaVersion string `json:"schemaVersion"` + Tool string `json:"tool"` + Verdict string `json:"verdict"` // "pass", "warn", "fail" + Score int `json:"score"` // 0-100 + Summary ReviewSummary `json:"summary"` + Checks []ReviewCheck `json:"checks"` + Findings []ReviewFinding `json:"findings"` + Reviewers []SuggestedReview `json:"reviewers,omitempty"` + Generated []GeneratedFileInfo `json:"generated,omitempty"` + // Batch 3: Large PR Intelligence + SplitSuggestion *PRSplitSuggestion `json:"splitSuggestion,omitempty"` + ChangeBreakdown *ChangeBreakdown `json:"changeBreakdown,omitempty"` + ReviewEffort *ReviewEffort `json:"reviewEffort,omitempty"` + ClusterReviewers []ClusterReviewerAssignment `json:"clusterReviewers,omitempty"` + // Batch 4: Code Health & Baseline + HealthReport *CodeHealthReport `json:"healthReport,omitempty"` + Provenance *Provenance `json:"provenance,omitempty"` + // Narrative & adaptive output + Narrative string `json:"narrative,omitempty"` // 2-3 sentence review summary + PRTier string `json:"prTier"` // "small", "medium", "large" +} + +// ReviewSummary provides a high-level overview. +type ReviewSummary struct { + TotalFiles int `json:"totalFiles"` + TotalChanges int `json:"totalChanges"` + GeneratedFiles int `json:"generatedFiles"` + ReviewableFiles int `json:"reviewableFiles"` + CriticalFiles int `json:"criticalFiles"` + ChecksPassed int `json:"checksPassed"` + ChecksWarned int `json:"checksWarned"` + ChecksFailed int `json:"checksFailed"` + ChecksSkipped int `json:"checksSkipped"` + TopRisks []string `json:"topRisks"` + Languages []string `json:"languages"` + ModulesChanged int `json:"modulesChanged"` +} + +// ReviewCheck represents a single check result. +type ReviewCheck struct { + Name string `json:"name"` + Status string `json:"status"` // "pass", "warn", "fail", "skip" + Severity string `json:"severity"` // "error", "warning", "info" + Summary string `json:"summary"` + Details interface{} `json:"details,omitempty"` + Duration int64 `json:"durationMs"` +} + +// ReviewFinding is a single actionable finding. +type ReviewFinding struct { + Check string `json:"check"` + Severity string `json:"severity"` // "error", "warning", "info" + File string `json:"file"` + StartLine int `json:"startLine,omitempty"` + EndLine int `json:"endLine,omitempty"` + Message string `json:"message"` + Detail string `json:"detail,omitempty"` + Suggestion string `json:"suggestion,omitempty"` + Category string `json:"category"` + RuleID string `json:"ruleId,omitempty"` + Hint string `json:"hint,omitempty"` // e.g., "→ ckb explain " + Tier int `json:"tier"` // 1=blocking, 2=important, 3=informational +} + +// findingTier maps a check name to its tier. +// Tier 1: breaking changes, secrets, safety-critical — must fix. +// Tier 2: coupling, complexity, risk, health — should fix. +// Tier 3: hotspots, tests, generated, traceability, independence — nice to know. +func findingTier(check string) int { + switch check { + case "breaking", "secrets", "critical": + return 1 + case "coupling", "complexity", "risk", "health", "dead-code", "blast-radius": + return 2 + case "test-gaps": + return 3 + default: + return 3 + } +} + +// GeneratedFileInfo tracks a detected generated file. +type GeneratedFileInfo struct { + File string `json:"file"` + Reason string `json:"reason"` + SourceFile string `json:"sourceFile,omitempty"` +} + +// DefaultReviewPolicy returns sensible defaults. +func DefaultReviewPolicy() *ReviewPolicy { + return &ReviewPolicy{ + BlockBreakingChanges: true, + BlockSecrets: true, + FailOnLevel: "error", + HoldTheLine: true, + SplitThreshold: 50, + GeneratedPatterns: []string{"*.generated.*", "*.pb.go", "*.pb.cc", "parser.tab.c", "lex.yy.c"}, + GeneratedMarkers: []string{"DO NOT EDIT", "Generated by", "AUTO-GENERATED", "This file is generated"}, + CriticalSeverity: "error", + DeadCodeMinConfidence: 0.8, + TestGapMinLines: 5, + } +} + +// ReviewPR performs a comprehensive PR review by orchestrating multiple checks in parallel. +func (e *Engine) ReviewPR(ctx context.Context, opts ReviewPROptions) (*ReviewPRResponse, error) { + startTime := time.Now() + + // Apply defaults + if opts.BaseBranch == "" { + opts.BaseBranch = "main" + } + if opts.HeadBranch == "" { + opts.HeadBranch = "HEAD" + } + if opts.Policy == nil { + opts.Policy = DefaultReviewPolicy() + } + // Merge config defaults into policy (config provides repo-level defaults, + // callers can override per-invocation) + if e.config != nil { + rc := e.config.Review + mergeReviewConfig(opts.Policy, &rc) + } + if opts.MaxInline <= 0 { + opts.MaxInline = 10 + } + + if e.gitAdapter == nil { + return nil, fmt.Errorf("git adapter not available") + } + + // Get changed files + var diffStats []git.DiffStats + var err error + if opts.Staged { + diffStats, err = e.gitAdapter.GetStagedDiff() + } else { + diffStats, err = e.gitAdapter.GetCommitRangeDiff(opts.BaseBranch, opts.HeadBranch) + } + if err != nil { + return nil, fmt.Errorf("failed to get diff: %w", err) + } + + // Apply scope filter + if opts.Scope != "" { + diffStats = e.filterDiffByScope(ctx, diffStats, opts.Scope) + } + + if len(diffStats) == 0 { + return &ReviewPRResponse{ + CkbVersion: version.Version, + SchemaVersion: "8.2", + Tool: "reviewPR", + Verdict: "pass", + Score: 100, + Summary: ReviewSummary{}, + Checks: []ReviewCheck{}, + Findings: []ReviewFinding{}, + }, nil + } + + // Build file list and basic stats + changedFiles := make([]string, 0, len(diffStats)) + languages := make(map[string]bool) + modules := make(map[string]bool) + totalAdditions := 0 + totalDeletions := 0 + + for _, df := range diffStats { + changedFiles = append(changedFiles, df.FilePath) + totalAdditions += df.Additions + totalDeletions += df.Deletions + if lang := detectLanguage(df.FilePath); lang != "" { + languages[lang] = true + } + if mod := e.resolveFileModule(df.FilePath); mod != "" { + modules[mod] = true + } + } + + // Detect generated files + generatedSet := make(map[string]bool) + var generatedFiles []GeneratedFileInfo + for _, df := range diffStats { + if info, ok := detectGeneratedFile(df.FilePath, opts.Policy); ok { + generatedSet[df.FilePath] = true + generatedFiles = append(generatedFiles, info) + } + } + + // Build reviewable file list (excluding generated) + reviewableFiles := make([]string, 0, len(changedFiles)) + for _, f := range changedFiles { + if !generatedSet[f] { + reviewableFiles = append(reviewableFiles, f) + } + } + + // Run checks in parallel + checkEnabled := func(name string) bool { + if len(opts.Checks) == 0 { + return true + } + for _, c := range opts.Checks { + if c == name { + return true + } + } + return false + } + + var mu sync.Mutex + var checks []ReviewCheck + var findings []ReviewFinding + + addCheck := func(c ReviewCheck) { + mu.Lock() + checks = append(checks, c) + mu.Unlock() + } + addFindings := func(ff []ReviewFinding) { + mu.Lock() + findings = append(findings, ff...) + mu.Unlock() + } + + var wg sync.WaitGroup + + // Check: Breaking Changes + if checkEnabled("breaking") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkBreakingChanges(ctx, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Secrets + if checkEnabled("secrets") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkSecrets(ctx, reviewableFiles) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Affected Tests + if checkEnabled("tests") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkAffectedTests(ctx, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Pre-compute hotspot score map once (no tree-sitter — uses SkipComplexity). + // Shared by checkHotspots and checkRiskScore to avoid duplicate GetHotspots calls. + var hotspotScores map[string]float64 + if checkEnabled("hotspots") || checkEnabled("risk") { + hotspotScores = e.getHotspotScoreMapFast(ctx) + } + + // Tree-sitter checks — go-tree-sitter cgo is NOT thread-safe. Each check + // runs in its own goroutine but acquires e.tsMu around tree-sitter calls. + // Non-tree-sitter work (git subprocesses, scoring) runs without the lock, + // so checks overlap their I/O with each other. + var healthReport *CodeHealthReport + + if checkEnabled("complexity") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkComplexityDelta(ctx, reviewableFiles, opts) + addCheck(c) + addFindings(ff) + }() + } + + if checkEnabled("health") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff, report := e.checkCodeHealth(ctx, reviewableFiles, opts) + addCheck(c) + addFindings(ff) + mu.Lock() + healthReport = report + mu.Unlock() + }() + } + + // Hotspots — uses pre-computed scores, no tree-sitter needed. + if checkEnabled("hotspots") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkHotspotsWithScores(ctx, reviewableFiles, hotspotScores) + addCheck(c) + addFindings(ff) + }() + } + + // Risk — uses pre-computed data, no tree-sitter or SummarizePR needed. + if checkEnabled("risk") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkRiskScoreFast(ctx, diffStats, reviewableFiles, modules, hotspotScores, opts) + addCheck(c) + addFindings(ff) + }() + } + + if checkEnabled("test-gaps") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkTestGaps(ctx, reviewableFiles, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Coupling Gaps + if checkEnabled("coupling") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkCouplingGaps(ctx, reviewableFiles) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Dead Code (SCIP-only, parallel safe) + if checkEnabled("dead-code") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkDeadCode(ctx, changedFiles, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Blast Radius (SCIP-only, parallel safe) + if checkEnabled("blast-radius") { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkBlastRadius(ctx, changedFiles, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Critical Paths + if checkEnabled("critical") && len(opts.Policy.CriticalPaths) > 0 { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkCriticalPaths(ctx, reviewableFiles, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Traceability (commit-to-ticket linkage) + if checkEnabled("traceability") && (opts.Policy.RequireTraceability || opts.Policy.RequireTraceForCriticalPaths) { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkTraceability(ctx, reviewableFiles, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Reviewer Independence + if checkEnabled("independence") && opts.Policy.RequireIndependentReview { + wg.Add(1) + go func() { + defer wg.Done() + c, ff := e.checkReviewerIndependence(ctx, opts) + addCheck(c) + addFindings(ff) + }() + } + + // Check: Generated files (info only) + if checkEnabled("generated") && len(generatedFiles) > 0 { + addCheck(ReviewCheck{ + Name: "generated", + Status: "info", + Severity: "info", + Summary: fmt.Sprintf("%d generated files detected and excluded", len(generatedFiles)), + }) + } + + wg.Wait() + + // Sort checks by severity (fail first, then warn, then pass) + sortChecks(checks) + + // Sort findings by severity and assign tiers + sortFindings(findings) + for i := range findings { + findings[i].Tier = findingTier(findings[i].Check) + } + + // Calculate summary + summary := ReviewSummary{ + TotalFiles: len(changedFiles), + TotalChanges: totalAdditions + totalDeletions, + GeneratedFiles: len(generatedFiles), + ReviewableFiles: len(reviewableFiles), + ModulesChanged: len(modules), + } + + for lang := range languages { + summary.Languages = append(summary.Languages, lang) + } + sort.Strings(summary.Languages) + + for _, c := range checks { + switch c.Status { + case "pass": + summary.ChecksPassed++ + case "warn": + summary.ChecksWarned++ + case "fail": + summary.ChecksFailed++ + case "skip", "info": + summary.ChecksSkipped++ + } + } + + // Build top risks from failed/warned checks + for _, c := range checks { + if (c.Status == "fail" || c.Status == "warn") && len(summary.TopRisks) < 3 { + summary.TopRisks = append(summary.TopRisks, c.Summary) + } + } + + // Calculate score + score := calculateReviewScore(checks, findings) + + // Determine verdict + verdict := determineVerdict(checks, opts.Policy) + + // Count critical files + for _, f := range findings { + if f.Category == "critical" { + summary.CriticalFiles++ + } + } + + // Get suggested reviewers + prFiles := make([]PRFileChange, 0, len(reviewableFiles)) + for _, df := range diffStats { + if !generatedSet[df.FilePath] { + prFiles = append(prFiles, PRFileChange{Path: df.FilePath}) + } + } + reviewers := e.getSuggestedReviewers(ctx, prFiles) + + // --- Batch 3: Large PR Intelligence --- + + // Change classification + var breakdown *ChangeBreakdown + if checkEnabled("classify") || len(diffStats) >= 10 { + breakdown = e.classifyChanges(ctx, diffStats, generatedSet, opts) + } + + // PR split suggestion (when above threshold) + var splitSuggestion *PRSplitSuggestion + var clusterReviewers []ClusterReviewerAssignment + if checkEnabled("split") || len(diffStats) >= opts.Policy.SplitThreshold { + splitSuggestion = e.suggestPRSplit(ctx, diffStats, opts.Policy) + if splitSuggestion != nil && splitSuggestion.ShouldSplit { + clusterReviewers = e.assignClusterReviewers(ctx, splitSuggestion.Clusters) + + // Add split check + addCheck(ReviewCheck{ + Name: "split", + Status: "warn", + Severity: "warning", + Summary: splitSuggestion.Reason, + Details: splitSuggestion, + }) + } + } + + // Review effort estimation + effort := estimateReviewEffort(diffStats, breakdown, summary.CriticalFiles, len(modules)) + + // Re-sort after adding split check + sortChecks(checks) + + // Get repo state + repoState, err := e.GetRepoState(ctx, "head") + if err != nil { + repoState = &RepoState{RepoStateId: "unknown"} + } + + return &ReviewPRResponse{ + CkbVersion: version.Version, + SchemaVersion: "8.2", + Tool: "reviewPR", + Verdict: verdict, + Score: score, + Summary: summary, + Checks: checks, + Findings: findings, + Reviewers: reviewers, + Generated: generatedFiles, + SplitSuggestion: splitSuggestion, + ChangeBreakdown: breakdown, + ReviewEffort: effort, + ClusterReviewers: clusterReviewers, + HealthReport: healthReport, + Narrative: generateNarrative(summary, checks, findings, splitSuggestion), + PRTier: determinePRTier(summary.TotalChanges), + Provenance: &Provenance{ + RepoStateId: repoState.RepoStateId, + RepoStateDirty: repoState.Dirty, + QueryDurationMs: time.Since(startTime).Milliseconds(), + }, + }, nil +} + +// determinePRTier classifies a PR by total line changes. +func determinePRTier(totalChanges int) string { + switch { + case totalChanges < 100: + return "small" + case totalChanges <= 600: + return "medium" + default: + return "large" + } +} + +// generateNarrative produces a deterministic 2-3 sentence review summary. +func generateNarrative(summary ReviewSummary, checks []ReviewCheck, findings []ReviewFinding, split *PRSplitSuggestion) string { + var parts []string + + // Sentence 1: What changed + langStr := "" + if len(summary.Languages) > 0 { + langStr = " (" + strings.Join(summary.Languages, ", ") + ")" + } + parts = append(parts, fmt.Sprintf("Changes %d files across %d modules%s.", + summary.TotalFiles, summary.ModulesChanged, langStr)) + + // Sentence 2: What's risky — pick the most important signal + tier1Count := 0 + for _, f := range findings { + if f.Tier == 1 { + tier1Count++ + } + } + if tier1Count > 0 { + // Summarize tier 1 issues + riskParts := []string{} + for _, c := range checks { + if c.Status == "fail" { + riskParts = append(riskParts, c.Summary) + } + } + if len(riskParts) > 0 { + parts = append(parts, strings.Join(riskParts, "; ")+".") + } + } else if summary.ChecksWarned > 0 { + warnParts := []string{} + for _, c := range checks { + if c.Status == "warn" && len(warnParts) < 2 { + warnParts = append(warnParts, c.Summary) + } + } + if len(warnParts) > 0 { + parts = append(parts, strings.Join(warnParts, "; ")+".") + } + } else { + parts = append(parts, "No blocking issues found.") + } + + // Sentence 3: Where to focus or split recommendation + if split != nil && split.ShouldSplit { + parts = append(parts, fmt.Sprintf("Consider splitting into %d smaller PRs.", + len(split.Clusters))) + } else if summary.CriticalFiles > 0 { + parts = append(parts, fmt.Sprintf("%d safety-critical files need focused review.", + summary.CriticalFiles)) + } + + return strings.Join(parts, " ") +} + +// --- Individual check implementations --- + +func (e *Engine) checkBreakingChanges(ctx context.Context, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + resp, err := e.CompareAPI(ctx, CompareAPIOptions{ + BaseRef: opts.BaseBranch, + TargetRef: opts.HeadBranch, + IgnorePrivate: true, + }) + + if err != nil { + return ReviewCheck{ + Name: "breaking", + Status: "skip", + Severity: "error", + Summary: fmt.Sprintf("Could not analyze: %v", err), + Duration: time.Since(start).Milliseconds(), + }, nil + } + + var findings []ReviewFinding + breakingCount := 0 + if resp.Summary != nil { + breakingCount = resp.Summary.BreakingChanges + } + + for _, change := range resp.Changes { + if change.Severity == "breaking" || change.Severity == "error" { + findings = append(findings, ReviewFinding{ + Check: "breaking", + Severity: "error", + File: change.FilePath, + Message: change.Description, + Category: "breaking", + RuleID: fmt.Sprintf("ckb/breaking/%s", change.Kind), + }) + } + } + + status := "pass" + severity := "error" + summary := "No breaking API changes" + if breakingCount > 0 { + status = "fail" + summary = fmt.Sprintf("%d breaking API change(s) detected", breakingCount) + } + + return ReviewCheck{ + Name: "breaking", + Status: status, + Severity: severity, + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +func (e *Engine) checkSecrets(ctx context.Context, files []string) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + scanner := secrets.NewScanner(e.repoRoot, e.logger) + result, err := scanner.Scan(ctx, secrets.ScanOptions{ + RepoRoot: e.repoRoot, + Scope: secrets.ScopeWorkdir, + Paths: files, + ApplyAllowlist: true, + MinEntropy: 3.5, + }) + + if err != nil { + return ReviewCheck{ + Name: "secrets", + Status: "skip", + Severity: "error", + Summary: fmt.Sprintf("Could not scan: %v", err), + Duration: time.Since(start).Milliseconds(), + }, nil + } + + var findings []ReviewFinding + for _, f := range result.Findings { + if f.Suppressed { + continue + } + sev := "warning" + if f.Severity == secrets.SeverityCritical || f.Severity == secrets.SeverityHigh { + sev = "error" + } + findings = append(findings, ReviewFinding{ + Check: "secrets", + Severity: sev, + File: f.File, + StartLine: f.Line, + Message: fmt.Sprintf("Potential %s detected", f.Type), + Category: "security", + RuleID: fmt.Sprintf("ckb/secrets/%s", f.Type), + }) + } + + status := "pass" + summary := "No secrets detected" + count := len(findings) + if count > 0 { + status = "fail" + summary = fmt.Sprintf("%d potential secret(s) found", count) + } + + return ReviewCheck{ + Name: "secrets", + Status: status, + Severity: "error", + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +func (e *Engine) checkAffectedTests(ctx context.Context, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + resp, err := e.GetAffectedTests(ctx, GetAffectedTestsOptions{ + BaseBranch: opts.BaseBranch, + }) + + if err != nil { + return ReviewCheck{ + Name: "tests", + Status: "skip", + Severity: "warning", + Summary: fmt.Sprintf("Could not analyze: %v", err), + Duration: time.Since(start).Milliseconds(), + }, nil + } + + testCount := len(resp.Tests) + status := "pass" + summary := fmt.Sprintf("%d test(s) cover the changes", testCount) + + var findings []ReviewFinding + if testCount == 0 && opts.Policy.RequireTests { + status = "warn" + summary = "No tests found for changed code" + findings = append(findings, ReviewFinding{ + Check: "tests", + Severity: "warning", + File: "", + Message: "No tests were found that cover the changed code", + Suggestion: "Consider adding tests for the changed functionality", + Category: "testing", + RuleID: "ckb/tests/no-coverage", + }) + } + + return ReviewCheck{ + Name: "tests", + Status: status, + Severity: "warning", + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +func (e *Engine) checkHotspots(ctx context.Context, files []string) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + resp, err := e.GetHotspots(ctx, GetHotspotsOptions{Limit: 100}) + if err != nil { + return ReviewCheck{ + Name: "hotspots", + Status: "skip", + Severity: "info", + Summary: fmt.Sprintf("Could not analyze: %v", err), + Duration: time.Since(start).Milliseconds(), + }, nil + } + + // Build hotspot set + hotspotScores := make(map[string]float64) + for _, h := range resp.Hotspots { + if h.Ranking != nil && h.Ranking.Score > 0.5 { + hotspotScores[h.FilePath] = h.Ranking.Score + } + } + + // Find overlaps + var findings []ReviewFinding + hotspotCount := 0 + for _, f := range files { + if score, ok := hotspotScores[f]; ok { + hotspotCount++ + findings = append(findings, ReviewFinding{ + Check: "hotspots", + Severity: "info", + File: f, + Message: fmt.Sprintf("Hotspot file (score: %.2f) — extra review attention recommended", score), + Category: "risk", + RuleID: "ckb/hotspots/volatile-file", + }) + } + } + + status := "pass" + summary := "No volatile files touched" + if hotspotCount > 0 { + status = "info" + summary = fmt.Sprintf("%d hotspot file(s) touched", hotspotCount) + } + + return ReviewCheck{ + Name: "hotspots", + Status: status, + Severity: "info", + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +func (e *Engine) checkRiskScore(ctx context.Context, diffStats interface{}, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + // Use existing PR summary for risk calculation + resp, err := e.SummarizePR(ctx, SummarizePROptions{ + BaseBranch: opts.BaseBranch, + HeadBranch: opts.HeadBranch, + IncludeOwnership: false, // Skip ownership to save time, we do it separately + }) + + if err != nil { + return ReviewCheck{ + Name: "risk", + Status: "skip", + Severity: "warning", + Summary: fmt.Sprintf("Could not analyze: %v", err), + Duration: time.Since(start).Milliseconds(), + }, nil + } + + score := resp.RiskAssessment.Score + level := resp.RiskAssessment.Level + + status := "pass" + severity := "warning" + summary := fmt.Sprintf("Risk score: %.2f (%s)", score, level) + + var findings []ReviewFinding + if opts.Policy.MaxRiskScore > 0 && score > opts.Policy.MaxRiskScore { + status = "warn" + for _, factor := range resp.RiskAssessment.Factors { + findings = append(findings, ReviewFinding{ + Check: "risk", + Severity: "warning", + Message: factor, + Category: "risk", + RuleID: "ckb/risk/high-score", + }) + } + } + + return ReviewCheck{ + Name: "risk", + Status: status, + Severity: severity, + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +func (e *Engine) checkCriticalPaths(ctx context.Context, files []string, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + var findings []ReviewFinding + critSeverity := opts.Policy.CriticalSeverity + if critSeverity == "" { + critSeverity = "error" + } + + for _, file := range files { + for _, pattern := range opts.Policy.CriticalPaths { + matched, _ := matchGlob(pattern, file) + if matched { + findings = append(findings, ReviewFinding{ + Check: "critical", + Severity: critSeverity, + File: file, + Message: fmt.Sprintf("Safety-critical path changed (pattern: %s)", pattern), + Suggestion: "Requires sign-off from safety team", + Category: "critical", + RuleID: "ckb/critical/safety-path", + }) + break // Don't double-match same file + } + } + } + + status := "pass" + summary := "No safety-critical files touched" + if len(findings) > 0 { + status = "fail" + summary = fmt.Sprintf("%d safety-critical file(s) changed", len(findings)) + } + + return ReviewCheck{ + Name: "critical", + Status: status, + Severity: critSeverity, + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +// --- Helpers --- + +func sortChecks(checks []ReviewCheck) { + order := map[string]int{"fail": 0, "warn": 1, "info": 2, "pass": 3, "skip": 4} + sort.Slice(checks, func(i, j int) bool { + return order[checks[i].Status] < order[checks[j].Status] + }) +} + +func sortFindings(findings []ReviewFinding) { + sevOrder := map[string]int{"error": 0, "warning": 1, "info": 2} + sort.SliceStable(findings, func(i, j int) bool { + // Primary: tier (1=blocking first) + if findings[i].Tier != findings[j].Tier { + return findings[i].Tier < findings[j].Tier + } + // Secondary: severity within tier + si, sj := sevOrder[findings[i].Severity], sevOrder[findings[j].Severity] + if si != sj { + return si < sj + } + // Tertiary: file path for determinism + return findings[i].File < findings[j].File + }) +} + +func calculateReviewScore(checks []ReviewCheck, findings []ReviewFinding) int { + score := 100 + + // Cap per-check deductions so noisy checks (e.g., coupling with many + // co-change warnings) don't overwhelm the score on their own. + checkDeductions := make(map[string]int) + const maxPerCheck = 20 + // Total deduction cap — prevents the score from becoming meaningless + // on large PRs where many checks each hit their per-check cap. + const maxTotalDeduction = 80 + totalDeducted := 0 + + for _, f := range findings { + if totalDeducted >= maxTotalDeduction { + break + } + penalty := 0 + switch f.Severity { + case "error": + penalty = 10 + case "warning": + penalty = 3 + case "info": + penalty = 1 + } + if penalty > 0 { + current := checkDeductions[f.Check] + if current < maxPerCheck { + apply := penalty + if current+apply > maxPerCheck { + apply = maxPerCheck - current + } + if totalDeducted+apply > maxTotalDeduction { + apply = maxTotalDeduction - totalDeducted + } + score -= apply + checkDeductions[f.Check] = current + apply + totalDeducted += apply + } + } + } + + if score < 0 { + score = 0 + } + return score +} + +func determineVerdict(checks []ReviewCheck, policy *ReviewPolicy) string { + failLevel := policy.FailOnLevel + if failLevel == "" { + failLevel = "error" + } + + hasFail := false + hasWarn := false + for _, c := range checks { + if c.Status == "fail" { + hasFail = true + } + if c.Status == "warn" { + hasWarn = true + } + } + + switch failLevel { + case "none": + return "pass" + case "warning": + if hasFail || hasWarn { + return "fail" + } + default: // "error" + if hasFail { + return "fail" + } + if hasWarn { + return "warn" + } + } + + return "pass" +} + +// detectGeneratedFile checks if a file is generated based on policy patterns and markers. +func detectGeneratedFile(filePath string, policy *ReviewPolicy) (GeneratedFileInfo, bool) { + // Check glob patterns + for _, pattern := range policy.GeneratedPatterns { + matched, _ := matchGlob(pattern, filePath) + if matched { + return GeneratedFileInfo{ + File: filePath, + Reason: fmt.Sprintf("Matches pattern %s", pattern), + }, true + } + } + + // Check flex/yacc source mappings + base := strings.TrimSuffix(filePath, ".tab.c") + if base != filePath { + return GeneratedFileInfo{ + File: filePath, + Reason: "flex/yacc generated output", + SourceFile: base + ".y", + }, true + } + base = strings.TrimSuffix(filePath, ".yy.c") + if base != filePath { + return GeneratedFileInfo{ + File: filePath, + Reason: "flex/yacc generated output", + SourceFile: base + ".l", + }, true + } + + return GeneratedFileInfo{}, false +} + +// matchGlob performs simple glob matching (supports ** and *). +func matchGlob(pattern, path string) (bool, error) { + // Use filepath.Match for patterns without ** + if !strings.Contains(pattern, "**") { + return matchSimpleGlob(pattern, path), nil + } + + // Split on first ** occurrence only + idx := strings.Index(pattern, "**") + prefix := pattern[:idx] + suffix := pattern[idx+2:] + suffix = strings.TrimPrefix(suffix, "/") + + if prefix != "" && !strings.HasPrefix(path, prefix) { + return false, nil + } + if suffix == "" { + return true, nil + } + + // For the remaining suffix, strip the prefix from the path and check + // if any trailing segment matches the suffix (which may itself contain **) + remaining := path + if prefix != "" { + remaining = strings.TrimPrefix(path, prefix) + } + + // If the suffix contains another **, recurse + if strings.Contains(suffix, "**") { + // Try matching suffix against every possible substring of remaining path + parts := strings.Split(remaining, "/") + for i := range parts { + candidate := strings.Join(parts[i:], "/") + if matched, _ := matchGlob(suffix, candidate); matched { + return true, nil + } + } + return false, nil + } + + // Simple suffix: check if it matches the file name or path tail + return matchSimpleGlob(suffix, filepath.Base(path)), nil +} + +// matchSimpleGlob matches a pattern with * wildcards against a string. +func matchSimpleGlob(pattern, str string) bool { + if pattern == "*" { + return true + } + if !strings.Contains(pattern, "*") { + return pattern == str + } + + parts := strings.Split(pattern, "*") + if len(parts) == 2 { + return strings.HasPrefix(str, parts[0]) && strings.HasSuffix(str, parts[1]) + } + // Fallback: check if all parts appear in order + remaining := str + for _, part := range parts { + if part == "" { + continue + } + idx := strings.Index(remaining, part) + if idx < 0 { + return false + } + remaining = remaining[idx+len(part):] + } + return true +} + +// mergeReviewConfig applies config-level defaults to a review policy. +// Config values fill in gaps — explicit caller overrides take priority. +func mergeReviewConfig(policy *ReviewPolicy, rc *config.ReviewConfig) { + // Only merge generated patterns/markers if policy has none (caller didn't override) + if len(policy.GeneratedPatterns) == 0 && len(rc.GeneratedPatterns) > 0 { + policy.GeneratedPatterns = rc.GeneratedPatterns + } else if len(rc.GeneratedPatterns) > 0 { + // Append config patterns to defaults + policy.GeneratedPatterns = append(policy.GeneratedPatterns, rc.GeneratedPatterns...) + } + + if len(policy.GeneratedMarkers) == 0 && len(rc.GeneratedMarkers) > 0 { + policy.GeneratedMarkers = rc.GeneratedMarkers + } else if len(rc.GeneratedMarkers) > 0 { + policy.GeneratedMarkers = append(policy.GeneratedMarkers, rc.GeneratedMarkers...) + } + + // Critical paths: append config to any caller-provided ones + if len(rc.CriticalPaths) > 0 { + policy.CriticalPaths = append(policy.CriticalPaths, rc.CriticalPaths...) + } + + // Numeric thresholds: use config if caller left at zero/default + if policy.MaxRiskScore == 0 && rc.MaxRiskScore > 0 { + policy.MaxRiskScore = rc.MaxRiskScore + } + if policy.MaxComplexityDelta == 0 && rc.MaxComplexityDelta > 0 { + policy.MaxComplexityDelta = rc.MaxComplexityDelta + } + if policy.MaxFiles == 0 && rc.MaxFiles > 0 { + policy.MaxFiles = rc.MaxFiles + } + + // Traceability + if len(policy.TraceabilityPatterns) == 0 && len(rc.TraceabilityPatterns) > 0 { + policy.TraceabilityPatterns = rc.TraceabilityPatterns + } + if len(policy.TraceabilitySources) == 0 && len(rc.TraceabilitySources) > 0 { + policy.TraceabilitySources = rc.TraceabilitySources + } + if !policy.RequireTraceability && rc.RequireTraceability { + policy.RequireTraceability = true + } + if !policy.RequireTraceForCriticalPaths && rc.RequireTraceForCriticalPaths { + policy.RequireTraceForCriticalPaths = true + } + + // Reviewer independence + if !policy.RequireIndependentReview && rc.RequireIndependentReview { + policy.RequireIndependentReview = true + } + if policy.MinReviewers == 0 && rc.MinReviewers > 0 { + policy.MinReviewers = rc.MinReviewers + } + + // Analyzer thresholds + if policy.MaxBlastRadiusDelta == 0 && rc.MaxBlastRadiusDelta > 0 { + policy.MaxBlastRadiusDelta = rc.MaxBlastRadiusDelta + } + if policy.MaxFanOut == 0 && rc.MaxFanOut > 0 { + policy.MaxFanOut = rc.MaxFanOut + } + if policy.DeadCodeMinConfidence == 0 && rc.DeadCodeMinConfidence > 0 { + policy.DeadCodeMinConfidence = rc.DeadCodeMinConfidence + } + if policy.TestGapMinLines == 0 && rc.TestGapMinLines > 0 { + policy.TestGapMinLines = rc.TestGapMinLines + } +} + +// getHotspotScoreMapFast returns a file→score map without tree-sitter enrichment. +func (e *Engine) getHotspotScoreMapFast(ctx context.Context) map[string]float64 { + resp, err := e.GetHotspots(ctx, GetHotspotsOptions{Limit: 100, SkipComplexity: true}) + if err != nil { + return nil + } + scores := make(map[string]float64, len(resp.Hotspots)) + for _, h := range resp.Hotspots { + if h.Ranking != nil { + scores[h.FilePath] = h.Ranking.Score + } + } + return scores +} + +// checkHotspotsWithScores checks hotspot overlap using a pre-computed score map. +func (e *Engine) checkHotspotsWithScores(ctx context.Context, files []string, hotspotScores map[string]float64) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + var findings []ReviewFinding + hotspotCount := 0 + for _, f := range files { + if score, ok := hotspotScores[f]; ok && score > 0.5 { + hotspotCount++ + findings = append(findings, ReviewFinding{ + Check: "hotspots", + Severity: "info", + File: f, + Message: fmt.Sprintf("Hotspot file (score: %.2f) — extra review attention recommended", score), + Category: "risk", + RuleID: "ckb/hotspots/volatile-file", + }) + } + } + + status := "pass" + summary := "No volatile files touched" + if hotspotCount > 0 { + status = "info" + summary = fmt.Sprintf("%d hotspot file(s) touched", hotspotCount) + } + + return ReviewCheck{ + Name: "hotspots", + Status: status, + Severity: "info", + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +// checkRiskScoreFast computes risk score from already-available data instead +// of calling SummarizePR (which re-does the diff and hotspot analysis). +func (e *Engine) checkRiskScoreFast(ctx context.Context, diffStats []git.DiffStats, files []string, modules map[string]bool, hotspotScores map[string]float64, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + totalChanges := 0 + for _, ds := range diffStats { + totalChanges += ds.Additions + ds.Deletions + } + hotspotCount := 0 + for _, f := range files { + if score, ok := hotspotScores[f]; ok && score > 0.5 { + hotspotCount++ + } + } + + risk := calculatePRRisk(len(diffStats), totalChanges, hotspotCount, len(modules)) + + score := risk.Score + level := risk.Level + + status := "pass" + severity := "warning" + summary := fmt.Sprintf("Risk score: %.2f (%s)", score, level) + + var findings []ReviewFinding + if opts.Policy.MaxRiskScore > 0 && score > opts.Policy.MaxRiskScore { + status = "warn" + for _, factor := range risk.Factors { + findings = append(findings, ReviewFinding{ + Check: "risk", + Severity: "warning", + Message: factor, + Category: "risk", + RuleID: "ckb/risk/high-score", + }) + } + } + + return ReviewCheck{ + Name: "risk", + Status: status, + Severity: severity, + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +// filterDiffByScope filters diff stats by scope. If scope contains / or . +// it's treated as a path prefix; otherwise it's treated as a symbol name +// resolved via SearchSymbols. +func (e *Engine) filterDiffByScope(ctx context.Context, diffStats []git.DiffStats, scope string) []git.DiffStats { + if strings.Contains(scope, "/") || strings.Contains(scope, ".") { + // Path prefix filter + var filtered []git.DiffStats + for _, ds := range diffStats { + if strings.HasPrefix(ds.FilePath, scope) { + filtered = append(filtered, ds) + } + } + return filtered + } + + // Symbol name — resolve to file paths + resp, err := e.SearchSymbols(ctx, SearchSymbolsOptions{ + Query: scope, + Limit: 20, + }) + if err != nil || resp == nil || len(resp.Symbols) == 0 { + return diffStats // no match → return unfiltered + } + + fileSet := make(map[string]bool) + for _, sym := range resp.Symbols { + if sym.Location != nil { + fileSet[sym.Location.FileId] = true + } + } + + var filtered []git.DiffStats + for _, ds := range diffStats { + if fileSet[ds.FilePath] { + filtered = append(filtered, ds) + } + } + if len(filtered) == 0 { + return diffStats // symbol found but no file overlap → return unfiltered + } + return filtered +} diff --git a/internal/query/review_baseline.go b/internal/query/review_baseline.go new file mode 100644 index 00000000..23111d14 --- /dev/null +++ b/internal/query/review_baseline.go @@ -0,0 +1,240 @@ +package query + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "time" +) + +// validBaselineTag matches safe baseline tag names (alphanumeric, dash, underscore, dot). +var validBaselineTag = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) + +// ReviewBaseline stores a snapshot of findings for comparison. +type ReviewBaseline struct { + Tag string `json:"tag"` + CreatedAt time.Time `json:"createdAt"` + BaseBranch string `json:"baseBranch"` + HeadBranch string `json:"headBranch"` + FindingCount int `json:"findingCount"` + Fingerprints map[string]BaselineFinding `json:"fingerprints"` // fingerprint → finding +} + +// BaselineFinding stores a finding with its fingerprint for matching. +type BaselineFinding struct { + Fingerprint string `json:"fingerprint"` + RuleID string `json:"ruleId"` + File string `json:"file"` + Message string `json:"message"` + Severity string `json:"severity"` + FirstSeen string `json:"firstSeen"` // ISO8601 +} + +// FindingLifecycle classifies a finding relative to a baseline. +type FindingLifecycle struct { + Status string `json:"status"` // "new", "unchanged", "resolved" + BaselineTag string `json:"baselineTag"` // Which baseline it's compared against + FirstSeen string `json:"firstSeen"` // When this finding was first detected +} + +// BaselineInfo provides metadata about a stored baseline. +type BaselineInfo struct { + Tag string `json:"tag"` + CreatedAt time.Time `json:"createdAt"` + FindingCount int `json:"findingCount"` + Path string `json:"path"` +} + +// baselineDir returns the directory for baseline storage. +func baselineDir(repoRoot string) string { + return filepath.Join(repoRoot, ".ckb", "baselines") +} + +// SaveBaseline saves the current findings as a baseline snapshot. +func (e *Engine) SaveBaseline(findings []ReviewFinding, tag string, baseBranch, headBranch string) error { + dir := baselineDir(e.repoRoot) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create baseline dir: %w", err) + } + + if tag == "" { + tag = time.Now().Format("20060102-150405") + } + if !validBaselineTag.MatchString(tag) { + return fmt.Errorf("invalid baseline tag %q: must be alphanumeric with dashes, underscores, or dots", tag) + } + + baseline := ReviewBaseline{ + Tag: tag, + CreatedAt: time.Now(), + BaseBranch: baseBranch, + HeadBranch: headBranch, + FindingCount: len(findings), + Fingerprints: make(map[string]BaselineFinding), + } + + now := time.Now().Format(time.RFC3339) + for _, f := range findings { + fp := fingerprintFinding(f) + baseline.Fingerprints[fp] = BaselineFinding{ + Fingerprint: fp, + RuleID: f.RuleID, + File: f.File, + Message: f.Message, + Severity: f.Severity, + FirstSeen: now, + } + } + + data, err := json.MarshalIndent(baseline, "", " ") + if err != nil { + return fmt.Errorf("marshal baseline: %w", err) + } + + path := filepath.Join(dir, tag+".json") + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("write baseline: %w", err) + } + + // Update "latest" copy for quick access + latestPath := filepath.Join(dir, "latest.json") + _ = os.Remove(latestPath) // ignore error if doesn't exist + if err := os.WriteFile(latestPath, data, 0644); err != nil { + return fmt.Errorf("write latest baseline: %w", err) + } + + return nil +} + +// LoadBaseline loads a baseline by tag (or "latest"). +func (e *Engine) LoadBaseline(tag string) (*ReviewBaseline, error) { + if !validBaselineTag.MatchString(tag) { + return nil, fmt.Errorf("invalid baseline tag %q: must be alphanumeric with dashes, underscores, or dots", tag) + } + dir := baselineDir(e.repoRoot) + path := filepath.Join(dir, tag+".json") + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read baseline %q: %w", tag, err) + } + + var baseline ReviewBaseline + if err := json.Unmarshal(data, &baseline); err != nil { + return nil, fmt.Errorf("parse baseline: %w", err) + } + + return &baseline, nil +} + +// ListBaselines returns available baselines sorted by creation time. +func (e *Engine) ListBaselines() ([]BaselineInfo, error) { + dir := baselineDir(e.repoRoot) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("list baselines: %w", err) + } + + var infos []BaselineInfo + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + name := entry.Name() + if name == "latest.json" { + continue + } + tag := name[:len(name)-5] // strip .json + + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var baseline ReviewBaseline + if err := json.Unmarshal(data, &baseline); err != nil { + continue + } + + infos = append(infos, BaselineInfo{ + Tag: tag, + CreatedAt: baseline.CreatedAt, + FindingCount: baseline.FindingCount, + Path: path, + }) + } + + sort.Slice(infos, func(i, j int) bool { + return infos[i].CreatedAt.After(infos[j].CreatedAt) + }) + + return infos, nil +} + +// CompareWithBaseline classifies current findings against a baseline. +func CompareWithBaseline(current []ReviewFinding, baseline *ReviewBaseline) (newFindings, unchanged, resolved []ReviewFinding) { + currentFPs := make(map[string]ReviewFinding) + for _, f := range current { + fp := fingerprintFinding(f) + currentFPs[fp] = f + } + + // Check which baseline findings are still present + for fp, bf := range baseline.Fingerprints { + if _, exists := currentFPs[fp]; exists { + unchanged = append(unchanged, currentFPs[fp]) + delete(currentFPs, fp) + } else { + // Finding was resolved + resolved = append(resolved, ReviewFinding{ + Check: bf.RuleID, + Severity: bf.Severity, + File: bf.File, + Message: bf.Message, + RuleID: bf.RuleID, + }) + } + } + + // Remaining current findings are new + for _, f := range currentFPs { + newFindings = append(newFindings, f) + } + + sortFindingSlice := func(s []ReviewFinding) { + sort.Slice(s, func(i, j int) bool { + if s[i].File != s[j].File { + return s[i].File < s[j].File + } + if s[i].RuleID != s[j].RuleID { + return s[i].RuleID < s[j].RuleID + } + return s[i].Message < s[j].Message + }) + } + sortFindingSlice(newFindings) + sortFindingSlice(unchanged) + sortFindingSlice(resolved) + + return newFindings, unchanged, resolved +} + +// fingerprintFinding creates a stable fingerprint for a finding. +// Uses ruleId + file + message hash to survive line shifts. +func fingerprintFinding(f ReviewFinding) string { + h := sha256.New() + h.Write([]byte(f.RuleID)) + h.Write([]byte{0}) + h.Write([]byte(f.File)) + h.Write([]byte{0}) + h.Write([]byte(f.Message)) + return hex.EncodeToString(h.Sum(nil))[:16] +} diff --git a/internal/query/review_batch3_test.go b/internal/query/review_batch3_test.go new file mode 100644 index 00000000..e527de71 --- /dev/null +++ b/internal/query/review_batch3_test.go @@ -0,0 +1,378 @@ +package query + +import ( + "context" + "fmt" + "testing" + + "github.com/SimplyLiz/CodeMCP/internal/backends/git" +) + +func TestClassifyChanges_NewFile(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + diffStats := []git.DiffStats{ + {FilePath: "pkg/new.go", Additions: 100, IsNew: true}, + } + + breakdown := engine.classifyChanges(ctx, diffStats, map[string]bool{}, ReviewPROptions{}) + if len(breakdown.Classifications) != 1 { + t.Fatalf("expected 1 classification, got %d", len(breakdown.Classifications)) + } + + c := breakdown.Classifications[0] + if c.Category != CategoryNew { + t.Errorf("expected category %q, got %q", CategoryNew, c.Category) + } + if c.ReviewPriority != "high" { + t.Errorf("expected priority 'high', got %q", c.ReviewPriority) + } +} + +func TestClassifyChanges_RenamedFile(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + diffStats := []git.DiffStats{ + {FilePath: "pkg/new_name.go", IsRenamed: true, OldPath: "pkg/old_name.go", Additions: 1, Deletions: 1}, + } + + breakdown := engine.classifyChanges(ctx, diffStats, map[string]bool{}, ReviewPROptions{}) + c := breakdown.Classifications[0] + if c.Category != CategoryMoved { + t.Errorf("expected category %q, got %q", CategoryMoved, c.Category) + } + if c.ReviewPriority != "low" { + t.Errorf("expected priority 'low' for pure rename, got %q", c.ReviewPriority) + } +} + +func TestClassifyChanges_TestFile(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + diffStats := []git.DiffStats{ + {FilePath: "pkg/handler_test.go", Additions: 20, Deletions: 5}, + } + + breakdown := engine.classifyChanges(ctx, diffStats, map[string]bool{}, ReviewPROptions{}) + c := breakdown.Classifications[0] + if c.Category != CategoryTest { + t.Errorf("expected category %q, got %q", CategoryTest, c.Category) + } +} + +func TestClassifyChanges_ConfigFile(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + diffStats := []git.DiffStats{ + {FilePath: "go.mod", Additions: 3, Deletions: 1}, + {FilePath: "Dockerfile", Additions: 5, Deletions: 2}, + } + + breakdown := engine.classifyChanges(ctx, diffStats, map[string]bool{}, ReviewPROptions{}) + for _, c := range breakdown.Classifications { + if c.Category != CategoryConfig { + t.Errorf("expected %q to be classified as config, got %q", c.File, c.Category) + } + } +} + +func TestClassifyChanges_GeneratedFile(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + diffStats := []git.DiffStats{ + {FilePath: "types.pb.go", Additions: 500, Deletions: 300}, + } + generatedSet := map[string]bool{"types.pb.go": true} + + breakdown := engine.classifyChanges(ctx, diffStats, generatedSet, ReviewPROptions{}) + c := breakdown.Classifications[0] + if c.Category != CategoryGenerated { + t.Errorf("expected category %q, got %q", CategoryGenerated, c.Category) + } + if c.ReviewPriority != "skip" { + t.Errorf("expected priority 'skip', got %q", c.ReviewPriority) + } +} + +func TestClassifyChanges_Summary(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + diffStats := []git.DiffStats{ + {FilePath: "new.go", Additions: 100, IsNew: true}, + {FilePath: "test_util.go", Additions: 20, IsNew: true}, // new, not test (no _test.go) + {FilePath: "handler_test.go", Additions: 50, Deletions: 10}, + {FilePath: "go.mod", Additions: 2, Deletions: 1}, + } + + breakdown := engine.classifyChanges(ctx, diffStats, map[string]bool{}, ReviewPROptions{}) + if breakdown.Summary[CategoryNew] < 1 { + t.Errorf("expected at least 1 new file in summary") + } + if breakdown.Summary[CategoryTest] < 1 { + t.Errorf("expected at least 1 test file in summary") + } +} + +func TestEstimateReviewEffort_Empty(t *testing.T) { + t.Parallel() + + effort := estimateReviewEffort(nil, nil, 0, 0) + if effort.EstimatedMinutes != 0 { + t.Errorf("expected 0 minutes for empty PR, got %d", effort.EstimatedMinutes) + } + if effort.Complexity != "trivial" { + t.Errorf("expected complexity 'trivial', got %q", effort.Complexity) + } +} + +func TestEstimateReviewEffort_SmallPR(t *testing.T) { + t.Parallel() + + diffStats := []git.DiffStats{ + {FilePath: "main.go", Additions: 10, Deletions: 5}, + } + + effort := estimateReviewEffort(diffStats, nil, 0, 1) + if effort.EstimatedMinutes < 5 { + t.Errorf("expected at least 5 minutes, got %d", effort.EstimatedMinutes) + } + if effort.Complexity == "very-complex" { + t.Error("small PR should not be very-complex") + } +} + +func TestEstimateReviewEffort_LargePR(t *testing.T) { + t.Parallel() + + // 50 files, ~2000 LOC, 5 modules, 3 critical + diffStats := make([]git.DiffStats, 50) + for i := range diffStats { + diffStats[i] = git.DiffStats{ + FilePath: fmt.Sprintf("mod%d/file%d.go", i%5, i), + Additions: 30, + Deletions: 10, + } + } + + effort := estimateReviewEffort(diffStats, nil, 3, 5) + if effort.EstimatedMinutes < 60 { + t.Errorf("expected large PR to take > 60 min, got %d", effort.EstimatedMinutes) + } + if effort.Complexity != "complex" && effort.Complexity != "very-complex" { + t.Errorf("expected complexity 'complex' or 'very-complex', got %q", effort.Complexity) + } + if len(effort.Factors) == 0 { + t.Error("expected factors to be populated") + } +} + +func TestEstimateReviewEffort_WithClassification(t *testing.T) { + t.Parallel() + + diffStats := []git.DiffStats{ + {FilePath: "new.go", Additions: 200, IsNew: true}, + {FilePath: "types.pb.go", Additions: 1000}, + } + breakdown := &ChangeBreakdown{ + Classifications: []ChangeClassification{ + {File: "new.go", Category: CategoryNew}, + {File: "types.pb.go", Category: CategoryGenerated}, + }, + } + + effort := estimateReviewEffort(diffStats, breakdown, 0, 1) + // Generated files should be excluded from LOC calculation + // So the effort should be driven mainly by 200 LOC of new code + if effort.EstimatedMinutes > 120 { + t.Errorf("generated files inflating estimate too much: %d min", effort.EstimatedMinutes) + } +} + +func TestSuggestPRSplit_BelowThreshold(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + policy := DefaultReviewPolicy() + policy.SplitThreshold = 50 + + // Only 5 files — below threshold + diffStats := make([]git.DiffStats, 5) + for i := range diffStats { + diffStats[i] = git.DiffStats{FilePath: fmt.Sprintf("pkg/file%d.go", i)} + } + + result := engine.suggestPRSplit(ctx, diffStats, policy) + if result != nil { + t.Error("expected nil split suggestion below threshold") + } +} + +func TestSuggestPRSplit_MultiModule(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + policy := DefaultReviewPolicy() + policy.SplitThreshold = 3 // Low threshold for testing + + // Files in two distinct modules with no coupling + diffStats := []git.DiffStats{ + {FilePath: "frontend/components/app.tsx", Additions: 50}, + {FilePath: "frontend/components/nav.tsx", Additions: 30}, + {FilePath: "backend/api/handler.go", Additions: 40}, + {FilePath: "backend/api/routes.go", Additions: 20}, + } + + result := engine.suggestPRSplit(ctx, diffStats, policy) + if result == nil { + t.Fatal("expected split suggestion for multi-module PR") + } + if !result.ShouldSplit { + t.Error("expected ShouldSplit=true for files in different modules") + } + if len(result.Clusters) < 2 { + t.Errorf("expected at least 2 clusters, got %d", len(result.Clusters)) + } +} + +func TestSuggestPRSplit_SingleModule(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + ctx := context.Background() + policy := DefaultReviewPolicy() + policy.SplitThreshold = 3 + + // All files in the same module + diffStats := []git.DiffStats{ + {FilePath: "pkg/api/handler.go", Additions: 50}, + {FilePath: "pkg/api/routes.go", Additions: 30}, + {FilePath: "pkg/api/middleware.go", Additions: 40}, + } + + result := engine.suggestPRSplit(ctx, diffStats, policy) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.ShouldSplit { + t.Error("expected ShouldSplit=false for single-module PR") + } +} + +func TestBFS(t *testing.T) { + t.Parallel() + + adj := map[string]map[string]bool{ + "a": {"b": true}, + "b": {"a": true, "c": true}, + "c": {"b": true}, + "d": {}, // isolated + } + visited := make(map[string]bool) + + component := bfs("a", adj, visited) + if len(component) != 3 { + t.Errorf("expected component of 3, got %d: %v", len(component), component) + } + + // d should not be visited + if visited["d"] { + t.Error("d should not be visited from a") + } + + // d forms its own component + component2 := bfs("d", adj, visited) + if len(component2) != 1 { + t.Errorf("expected isolated component of 1, got %d", len(component2)) + } +} + +func TestIsConfigFile(t *testing.T) { + t.Parallel() + + tests := []struct { + path string + expected bool + }{ + {"go.mod", true}, + {"go.sum", true}, + {"Dockerfile", true}, + {"Makefile", true}, + {"package.json", true}, + {".github/workflows/ci.yml", true}, + {"main.go", false}, + {"src/app.ts", false}, + {"README.md", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := isConfigFile(tt.path) + if got != tt.expected { + t.Errorf("isConfigFile(%q) = %v, want %v", tt.path, got, tt.expected) + } + }) + } +} + +func TestReviewPR_IncludesEffort(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "pkg/main.go": "package main\n\nfunc main() {}\n", + "pkg/util.go": "package main\n\nfunc helper() {}\n", + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + if resp.ReviewEffort == nil { + t.Fatal("expected ReviewEffort to be populated") + } + if resp.ReviewEffort.EstimatedMinutes < 5 { + t.Errorf("expected at least 5 minutes, got %d", resp.ReviewEffort.EstimatedMinutes) + } + if resp.ReviewEffort.Complexity == "" { + t.Error("expected complexity to be set") + } +} diff --git a/internal/query/review_batch4_test.go b/internal/query/review_batch4_test.go new file mode 100644 index 00000000..3c0355f8 --- /dev/null +++ b/internal/query/review_batch4_test.go @@ -0,0 +1,392 @@ +package query + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// --- Code Health Tests --- + +func TestHealthGrade(t *testing.T) { + tests := []struct { + score int + want string + }{ + {95, "A"}, + {90, "A"}, + {89, "B"}, + {70, "B"}, + {69, "C"}, + {50, "C"}, + {49, "D"}, + {30, "D"}, + {29, "F"}, + {0, "F"}, + } + + for _, tt := range tests { + got := healthGrade(tt.score) + if got != tt.want { + t.Errorf("healthGrade(%d) = %q, want %q", tt.score, got, tt.want) + } + } +} + +func TestComplexityToScore(t *testing.T) { + tests := []struct { + complexity int + want float64 + }{ + {3, 100}, + {5, 100}, + {7, 85}, + {10, 85}, + {15, 65}, + {25, 40}, + {35, 20}, + } + + for _, tt := range tests { + got := complexityToScore(tt.complexity) + if got != tt.want { + t.Errorf("complexityToScore(%d) = %.0f, want %.0f", tt.complexity, got, tt.want) + } + } +} + +func TestFileSizeToScore(t *testing.T) { + tests := []struct { + loc int + want float64 + }{ + {50, 100}, + {100, 100}, + {200, 85}, + {400, 70}, + {700, 50}, + {1500, 30}, + } + + for _, tt := range tests { + got := fileSizeToScore(tt.loc) + if got != tt.want { + t.Errorf("fileSizeToScore(%d) = %.0f, want %.0f", tt.loc, got, tt.want) + } + } +} + +func TestCountLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.go") + content := "line1\nline2\nline3\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got := countLines(path) + if got != 3 { + t.Errorf("countLines() = %d, want 3", got) + } +} + +func TestCountLines_Missing(t *testing.T) { + got := countLines("/nonexistent/path") + if got != 0 { + t.Errorf("countLines(missing) = %d, want 0", got) + } +} + +func TestCodeHealthReport_Fields(t *testing.T) { + report := &CodeHealthReport{ + Deltas: []CodeHealthDelta{ + {File: "a.go", HealthBefore: 80, HealthAfter: 70, Delta: -10, Grade: "B", GradeBefore: "B"}, + {File: "b.go", HealthBefore: 60, HealthAfter: 65, Delta: 5, Grade: "C", GradeBefore: "C"}, + {File: "c.go", HealthBefore: 90, HealthAfter: 90, Delta: 0, Grade: "A", GradeBefore: "A"}, + }, + } + + // Count degraded/improved + for _, d := range report.Deltas { + if d.Delta < 0 { + report.Degraded++ + } + if d.Delta > 0 { + report.Improved++ + } + } + + if report.Degraded != 1 { + t.Errorf("Degraded = %d, want 1", report.Degraded) + } + if report.Improved != 1 { + t.Errorf("Improved = %d, want 1", report.Improved) + } +} + +func TestCheckCodeHealth_NoFiles(t *testing.T) { + e := &Engine{repoRoot: t.TempDir()} + ctx := context.Background() + + check, findings, report := e.checkCodeHealth(ctx, nil, ReviewPROptions{}) + + if check.Name != "health" { + t.Errorf("check.Name = %q, want %q", check.Name, "health") + } + if check.Status != "pass" { + t.Errorf("check.Status = %q, want %q", check.Status, "pass") + } + if len(findings) != 0 { + t.Errorf("len(findings) = %d, want 0", len(findings)) + } + if len(report.Deltas) != 0 { + t.Errorf("len(report.Deltas) = %d, want 0", len(report.Deltas)) + } +} + +// --- Baseline Tests --- + +func TestFingerprintFinding(t *testing.T) { + f1 := ReviewFinding{RuleID: "ckb/secrets/api-key", File: "config.go", Message: "API key detected"} + f2 := ReviewFinding{RuleID: "ckb/secrets/api-key", File: "config.go", Message: "API key detected"} + f3 := ReviewFinding{RuleID: "ckb/secrets/api-key", File: "other.go", Message: "API key detected"} + + fp1 := fingerprintFinding(f1) + fp2 := fingerprintFinding(f2) + fp3 := fingerprintFinding(f3) + + if fp1 != fp2 { + t.Errorf("identical findings should have same fingerprint: %s != %s", fp1, fp2) + } + if fp1 == fp3 { + t.Error("different files should have different fingerprints") + } + if len(fp1) != 16 { + t.Errorf("fingerprint length = %d, want 16", len(fp1)) + } +} + +func TestSaveAndLoadBaseline(t *testing.T) { + dir := t.TempDir() + e := &Engine{repoRoot: dir} + + findings := []ReviewFinding{ + {RuleID: "rule1", File: "a.go", Message: "msg1", Severity: "error"}, + {RuleID: "rule2", File: "b.go", Message: "msg2", Severity: "warning"}, + } + + err := e.SaveBaseline(findings, "test-tag", "main", "feature") + if err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + + // Verify file exists + path := filepath.Join(dir, ".ckb", "baselines", "test-tag.json") + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatal("baseline file not created") + } + + // Load it back + baseline, err := e.LoadBaseline("test-tag") + if err != nil { + t.Fatalf("LoadBaseline: %v", err) + } + + if baseline.Tag != "test-tag" { + t.Errorf("Tag = %q, want %q", baseline.Tag, "test-tag") + } + if baseline.FindingCount != 2 { + t.Errorf("FindingCount = %d, want 2", baseline.FindingCount) + } + if baseline.BaseBranch != "main" { + t.Errorf("BaseBranch = %q, want %q", baseline.BaseBranch, "main") + } + if len(baseline.Fingerprints) != 2 { + t.Errorf("len(Fingerprints) = %d, want 2", len(baseline.Fingerprints)) + } +} + +func TestSaveBaseline_AutoTag(t *testing.T) { + dir := t.TempDir() + e := &Engine{repoRoot: dir} + + err := e.SaveBaseline(nil, "", "main", "HEAD") + if err != nil { + t.Fatalf("SaveBaseline with auto-tag: %v", err) + } + + // Should create a file with timestamp-based name + baselines, err := e.ListBaselines() + if err != nil { + t.Fatalf("ListBaselines: %v", err) + } + if len(baselines) != 1 { + t.Fatalf("expected 1 baseline, got %d", len(baselines)) + } +} + +func TestSaveBaseline_LatestCopy(t *testing.T) { + dir := t.TempDir() + e := &Engine{repoRoot: dir} + + err := e.SaveBaseline(nil, "v1", "main", "HEAD") + if err != nil { + t.Fatalf("SaveBaseline: %v", err) + } + + // latest.json should also exist + latest, err := e.LoadBaseline("latest") + if err != nil { + t.Fatalf("LoadBaseline(latest): %v", err) + } + if latest.Tag != "v1" { + t.Errorf("latest.Tag = %q, want %q", latest.Tag, "v1") + } +} + +func TestListBaselines_Empty(t *testing.T) { + dir := t.TempDir() + e := &Engine{repoRoot: dir} + + baselines, err := e.ListBaselines() + if err != nil { + t.Fatalf("ListBaselines: %v", err) + } + if baselines != nil { + t.Errorf("expected nil, got %v", baselines) + } +} + +func TestListBaselines_Sorted(t *testing.T) { + dir := t.TempDir() + e := &Engine{repoRoot: dir} + + // Save two baselines with some time gap + _ = e.SaveBaseline(nil, "older", "main", "HEAD") + time.Sleep(10 * time.Millisecond) + _ = e.SaveBaseline([]ReviewFinding{{RuleID: "r1", File: "a.go", Message: "m"}}, "newer", "main", "HEAD") + + baselines, err := e.ListBaselines() + if err != nil { + t.Fatalf("ListBaselines: %v", err) + } + if len(baselines) != 2 { + t.Fatalf("expected 2, got %d", len(baselines)) + } + // Should be sorted newest first + if baselines[0].Tag != "newer" { + t.Errorf("first baseline tag = %q, want %q", baselines[0].Tag, "newer") + } +} + +func TestLoadBaseline_NotFound(t *testing.T) { + dir := t.TempDir() + e := &Engine{repoRoot: dir} + + _, err := e.LoadBaseline("nonexistent") + if err == nil { + t.Error("expected error for missing baseline") + } +} + +func TestCompareWithBaseline(t *testing.T) { + // Create baseline with 3 findings + baseline := &ReviewBaseline{ + Tag: "test", + FindingCount: 3, + Fingerprints: make(map[string]BaselineFinding), + } + + baselineFindings := []ReviewFinding{ + {RuleID: "rule1", File: "a.go", Message: "issue A", Severity: "error"}, + {RuleID: "rule2", File: "b.go", Message: "issue B", Severity: "warning"}, + {RuleID: "rule3", File: "c.go", Message: "issue C", Severity: "info"}, + } + + for _, f := range baselineFindings { + fp := fingerprintFinding(f) + baseline.Fingerprints[fp] = BaselineFinding{ + Fingerprint: fp, + RuleID: f.RuleID, + File: f.File, + Message: f.Message, + Severity: f.Severity, + } + } + + // Current: keep A, remove B, add D + current := []ReviewFinding{ + {RuleID: "rule1", File: "a.go", Message: "issue A", Severity: "error"}, // unchanged + {RuleID: "rule4", File: "d.go", Message: "issue D", Severity: "warning"}, // new + } + + newF, unchanged, resolved := CompareWithBaseline(current, baseline) + + if len(newF) != 1 { + t.Errorf("new findings = %d, want 1", len(newF)) + } + if len(unchanged) != 1 { + t.Errorf("unchanged findings = %d, want 1", len(unchanged)) + } + if len(resolved) != 2 { + t.Errorf("resolved findings = %d, want 2", len(resolved)) + } + + // Verify the new finding is D + if len(newF) > 0 && newF[0].RuleID != "rule4" { + t.Errorf("new finding ruleID = %q, want %q", newF[0].RuleID, "rule4") + } +} + +func TestCompareWithBaseline_EmptyBaseline(t *testing.T) { + baseline := &ReviewBaseline{ + Fingerprints: make(map[string]BaselineFinding), + } + + current := []ReviewFinding{ + {RuleID: "rule1", File: "a.go", Message: "issue"}, + } + + newF, unchanged, resolved := CompareWithBaseline(current, baseline) + + if len(newF) != 1 { + t.Errorf("new = %d, want 1", len(newF)) + } + if len(unchanged) != 0 { + t.Errorf("unchanged = %d, want 0", len(unchanged)) + } + if len(resolved) != 0 { + t.Errorf("resolved = %d, want 0", len(resolved)) + } +} + +func TestCompareWithBaseline_AllResolved(t *testing.T) { + baseline := &ReviewBaseline{ + FindingCount: 2, + Fingerprints: make(map[string]BaselineFinding), + } + + for _, f := range []ReviewFinding{ + {RuleID: "rule1", File: "a.go", Message: "issue A"}, + {RuleID: "rule2", File: "b.go", Message: "issue B"}, + } { + fp := fingerprintFinding(f) + baseline.Fingerprints[fp] = BaselineFinding{ + Fingerprint: fp, RuleID: f.RuleID, File: f.File, Message: f.Message, + } + } + + newF, unchanged, resolved := CompareWithBaseline(nil, baseline) + + if len(newF) != 0 { + t.Errorf("new = %d, want 0", len(newF)) + } + if len(unchanged) != 0 { + t.Errorf("unchanged = %d, want 0", len(unchanged)) + } + if len(resolved) != 2 { + t.Errorf("resolved = %d, want 2", len(resolved)) + } +} diff --git a/internal/query/review_batch5_test.go b/internal/query/review_batch5_test.go new file mode 100644 index 00000000..455d8bae --- /dev/null +++ b/internal/query/review_batch5_test.go @@ -0,0 +1,333 @@ +package query + +import ( + "context" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/SimplyLiz/CodeMCP/internal/config" + "github.com/SimplyLiz/CodeMCP/internal/storage" +) + +// newTestEngineWithGit creates a full engine with git adapter for a given repo dir. +func newTestEngineWithGit(t *testing.T, dir string) *Engine { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + ckbDir := filepath.Join(dir, ".ckb") + if err := os.MkdirAll(ckbDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + db, err := storage.Open(dir, logger) + if err != nil { + t.Fatalf("storage.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + + cfg := config.DefaultConfig() + cfg.RepoRoot = dir + + engine, err := NewEngine(dir, db, logger, cfg) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return engine +} + +// --- Traceability Tests --- + +func TestCheckTraceability_NoPatterns(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + e := &Engine{repoRoot: t.TempDir(), logger: logger} + ctx := context.Background() + + opts := ReviewPROptions{ + Policy: &ReviewPolicy{ + RequireTraceability: true, + }, + } + + check, _ := e.checkTraceability(ctx, nil, opts) + if check.Status != "skip" { + t.Errorf("check.Status = %q, want %q", check.Status, "skip") + } +} + +func TestCheckTraceability_WithPatterns_NoMatch(t *testing.T) { + dir := setupGitRepoForTraceability(t, "feature/no-ticket", "no ticket here") + e := newTestEngineWithGit(t, dir) + ctx := context.Background() + + opts := ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/no-ticket", + Policy: &ReviewPolicy{ + RequireTraceability: true, + TraceabilityPatterns: []string{`JIRA-\d+`}, + TraceabilitySources: []string{"commit-message", "branch-name"}, + }, + } + + check, findings := e.checkTraceability(ctx, nil, opts) + if check.Status != "warn" { + t.Errorf("check.Status = %q, want %q", check.Status, "warn") + } + if len(findings) == 0 { + t.Error("expected findings for missing traceability") + } +} + +func TestCheckTraceability_MatchInCommit(t *testing.T) { + dir := setupGitRepoForTraceability(t, "feature/stuff", "JIRA-1234 fix the bug") + e := newTestEngineWithGit(t, dir) + ctx := context.Background() + + opts := ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/stuff", + Policy: &ReviewPolicy{ + RequireTraceability: true, + TraceabilityPatterns: []string{`JIRA-\d+`}, + TraceabilitySources: []string{"commit-message"}, + }, + } + + check, findings := e.checkTraceability(ctx, nil, opts) + if check.Status != "pass" { + t.Errorf("check.Status = %q, want %q (summary: %s)", check.Status, "pass", check.Summary) + } + warnCount := 0 + for _, f := range findings { + if f.Severity == "warning" || f.Severity == "error" { + warnCount++ + } + } + if warnCount > 0 { + t.Errorf("expected 0 warn/error findings, got %d", warnCount) + } +} + +func TestCheckTraceability_MatchInBranch(t *testing.T) { + dir := setupGitRepoForTraceability(t, "feature/JIRA-5678-fix", "some commit") + e := newTestEngineWithGit(t, dir) + ctx := context.Background() + + opts := ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/JIRA-5678-fix", + Policy: &ReviewPolicy{ + RequireTraceability: true, + TraceabilityPatterns: []string{`JIRA-\d+`}, + TraceabilitySources: []string{"branch-name"}, + }, + } + + check, _ := e.checkTraceability(ctx, nil, opts) + if check.Status != "pass" { + t.Errorf("check.Status = %q, want %q (summary: %s)", check.Status, "pass", check.Summary) + } +} + +func TestCheckTraceability_CriticalOrphan(t *testing.T) { + dir := setupGitRepoForTraceability(t, "feature/no-ticket", "no ticket here") + e := newTestEngineWithGit(t, dir) + ctx := context.Background() + + files := []string{"drivers/hw/plc.go"} + + opts := ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/no-ticket", + Policy: &ReviewPolicy{ + RequireTraceForCriticalPaths: true, + TraceabilityPatterns: []string{`JIRA-\d+`}, + TraceabilitySources: []string{"commit-message", "branch-name"}, + CriticalPaths: []string{"drivers/**"}, + }, + } + + check, findings := e.checkTraceability(ctx, files, opts) + if check.Status != "fail" { + t.Errorf("check.Status = %q, want %q", check.Status, "fail") + } + + hasOrphan := false + for _, f := range findings { + if f.RuleID == "ckb/traceability/critical-orphan" { + hasOrphan = true + } + } + if !hasOrphan { + t.Error("expected critical-orphan finding") + } +} + +func TestCheckTraceability_MultiplePatterns(t *testing.T) { + dir := setupGitRepoForTraceability(t, "feature/stuff", "REQ-42 implement feature") + e := newTestEngineWithGit(t, dir) + ctx := context.Background() + + opts := ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/stuff", + Policy: &ReviewPolicy{ + RequireTraceability: true, + TraceabilityPatterns: []string{`JIRA-\d+`, `REQ-\d+`, `#\d+`}, + TraceabilitySources: []string{"commit-message"}, + }, + } + + check, _ := e.checkTraceability(ctx, nil, opts) + if check.Status != "pass" { + t.Errorf("check.Status = %q, want %q", check.Status, "pass") + } +} + +// --- Independence Tests --- + +func TestCheckIndependence_NoGitAdapter(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + e := &Engine{repoRoot: t.TempDir(), logger: logger} + ctx := context.Background() + + opts := ReviewPROptions{ + Policy: &ReviewPolicy{RequireIndependentReview: true}, + } + + check, _ := e.checkReviewerIndependence(ctx, opts) + if check.Status != "skip" { + t.Errorf("check.Status = %q, want %q", check.Status, "skip") + } +} + +func TestCheckIndependence_WithCommits(t *testing.T) { + dir := setupGitRepoForTraceability(t, "feature/stuff", "fix something") + e := newTestEngineWithGit(t, dir) + ctx := context.Background() + + opts := ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/stuff", + Policy: &ReviewPolicy{ + RequireIndependentReview: true, + MinReviewers: 1, + }, + } + + check, findings := e.checkReviewerIndependence(ctx, opts) + if check.Status != "warn" { + t.Errorf("check.Status = %q, want %q", check.Status, "warn") + } + if len(findings) == 0 { + t.Error("expected findings for independence requirement") + } + + hasIndepFinding := false + for _, f := range findings { + if f.RuleID == "ckb/independence/require-independent-reviewer" { + hasIndepFinding = true + } + } + if !hasIndepFinding { + t.Error("expected require-independent-reviewer finding") + } +} + +func TestCheckIndependence_WithCriticalPaths(t *testing.T) { + dir := setupGitRepoForTraceability(t, "feature/critical", "change driver") + + // Create a file that matches the critical path + driversDir := filepath.Join(dir, "drivers", "hw") + if err := os.MkdirAll(driversDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(driversDir, "plc.go"), []byte("package hw\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runGit(t, dir, "add", "drivers/hw/plc.go") + runGit(t, dir, "commit", "-m", "add driver") + + e := newTestEngineWithGit(t, dir) + ctx := context.Background() + + opts := ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/critical", + Policy: &ReviewPolicy{ + RequireIndependentReview: true, + CriticalPaths: []string{"drivers/**"}, + }, + } + + check, findings := e.checkReviewerIndependence(ctx, opts) + if check.Status != "fail" { + t.Errorf("check.Status = %q, want %q", check.Status, "fail") + } + + hasCritical := false + for _, f := range findings { + if f.RuleID == "ckb/independence/critical-path-review" { + hasCritical = true + } + } + if !hasCritical { + t.Error("expected critical-path-review finding") + } +} + +// --- Helpers --- + +func TestContainsSource(t *testing.T) { + if !containsSource([]string{"commit-message", "branch-name"}, "branch-name") { + t.Error("expected true for branch-name") + } + if containsSource([]string{"commit-message"}, "branch-name") { + t.Error("expected false for branch-name") + } +} + +// setupGitRepoForTraceability creates a git repo with main branch and a feature branch. +func setupGitRepoForTraceability(t *testing.T, branchName, commitMsg string) string { + t.Helper() + dir := t.TempDir() + + runGit(t, dir, "init") + runGit(t, dir, "checkout", "-b", "main") + + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runGit(t, dir, "add", "README.md") + runGit(t, dir, "commit", "-m", "initial") + + runGit(t, dir, "checkout", "-b", branchName) + + if err := os.WriteFile(filepath.Join(dir, "change.go"), []byte("package main\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runGit(t, dir, "add", "change.go") + runGit(t, dir, "commit", "-m", commitMsg) + + return dir +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test", + "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=Test", + "GIT_COMMITTER_EMAIL=test@test.com", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) + } +} diff --git a/internal/query/review_blastradius.go b/internal/query/review_blastradius.go new file mode 100644 index 00000000..870e57df --- /dev/null +++ b/internal/query/review_blastradius.go @@ -0,0 +1,104 @@ +package query + +import ( + "context" + "fmt" + "time" +) + +// checkBlastRadius checks if changed symbols have high fan-out (many callers). +func (e *Engine) checkBlastRadius(ctx context.Context, changedFiles []string, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + maxFanOut := opts.Policy.MaxFanOut + if maxFanOut <= 0 { + // If MaxFanOut is not set, skip this check (it's opt-in) + return ReviewCheck{ + Name: "blast-radius", + Status: "skip", + Severity: "warning", + Summary: "Skipped (maxFanOut not configured)", + Duration: time.Since(start).Milliseconds(), + }, nil + } + + // Collect symbols from changed files, cap at 30 total + type symbolRef struct { + stableId string + name string + file string + } + var symbols []symbolRef + + for _, file := range changedFiles { + if ctx.Err() != nil { + break + } + if len(symbols) >= 30 { + break + } + resp, err := e.SearchSymbols(ctx, SearchSymbolsOptions{ + Scope: file, + Limit: 30 - len(symbols), + }) + if err != nil || resp == nil { + continue + } + for _, sym := range resp.Symbols { + symbols = append(symbols, symbolRef{ + stableId: sym.StableId, + name: sym.Name, + file: file, + }) + if len(symbols) >= 30 { + break + } + } + } + + var findings []ReviewFinding + for _, sym := range symbols { + if ctx.Err() != nil { + break + } + impactResp, err := e.AnalyzeImpact(ctx, AnalyzeImpactOptions{ + SymbolId: sym.stableId, + Depth: 1, + }) + if err != nil || impactResp == nil || impactResp.BlastRadius == nil { + continue + } + + callerCount := impactResp.BlastRadius.UniqueCallerCount + if callerCount > maxFanOut { + hint := "" + if sym.name != "" { + hint = fmt.Sprintf("→ ckb explain %s", sym.name) + } + findings = append(findings, ReviewFinding{ + Check: "blast-radius", + Severity: "warning", + File: sym.file, + Message: fmt.Sprintf("High fan-out: %s has %d callers (threshold: %d)", sym.name, callerCount, maxFanOut), + Category: "risk", + RuleID: "ckb/blast-radius/high-fanout", + Hint: hint, + }) + } + } + + status := "pass" + summary := "No high fan-out symbols in changes" + if len(findings) > 0 { + status = "warn" + summary = fmt.Sprintf("%d symbol(s) exceed fan-out threshold of %d", len(findings), maxFanOut) + } + + return ReviewCheck{ + Name: "blast-radius", + Status: status, + Severity: "warning", + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} diff --git a/internal/query/review_classify.go b/internal/query/review_classify.go new file mode 100644 index 00000000..689dda9c --- /dev/null +++ b/internal/query/review_classify.go @@ -0,0 +1,221 @@ +package query + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/SimplyLiz/CodeMCP/internal/backends/git" +) + +// ChangeCategory classifies the type of change for a file. +const ( + CategoryNew = "new" + CategoryRefactor = "refactoring" + CategoryMoved = "moved" + CategoryChurn = "churn" + CategoryConfig = "config" + CategoryTest = "test" + CategoryGenerated = "generated" + CategoryModified = "modified" +) + +// ChangeClassification categorizes a file change for review prioritization. +type ChangeClassification struct { + File string `json:"file"` + Category string `json:"category"` // One of the Category* constants + Confidence float64 `json:"confidence"` // 0-1 + Detail string `json:"detail"` // Human-readable explanation + ReviewPriority string `json:"reviewPriority"` // "high", "medium", "low", "skip" +} + +// ChangeBreakdown summarizes classifications across the entire PR. +type ChangeBreakdown struct { + Classifications []ChangeClassification `json:"classifications"` + Summary map[string]int `json:"summary"` // category → file count +} + +// classifyChanges categorizes each changed file by the type of change. +func (e *Engine) classifyChanges(ctx context.Context, diffStats []git.DiffStats, generatedSet map[string]bool, opts ReviewPROptions) *ChangeBreakdown { + classifications := make([]ChangeClassification, 0, len(diffStats)) + summary := make(map[string]int) + + for _, ds := range diffStats { + c := e.classifyFile(ctx, ds, generatedSet, opts) + classifications = append(classifications, c) + summary[c.Category]++ + } + + return &ChangeBreakdown{ + Classifications: classifications, + Summary: summary, + } +} + +func (e *Engine) classifyFile(ctx context.Context, ds git.DiffStats, generatedSet map[string]bool, opts ReviewPROptions) ChangeClassification { + file := ds.FilePath + + // Generated files + if generatedSet[file] { + return ChangeClassification{ + File: file, + Category: CategoryGenerated, + Confidence: 1.0, + Detail: "Generated file — review source instead", + ReviewPriority: "skip", + } + } + + // Moved/renamed files + if ds.IsRenamed { + similarity := estimateRenameSimilarity(ds) + if similarity > 0.8 { + return ChangeClassification{ + File: file, + Category: CategoryMoved, + Confidence: similarity, + Detail: fmt.Sprintf("Renamed from %s (%.0f%% similar)", ds.OldPath, similarity*100), + ReviewPriority: "low", + } + } + return ChangeClassification{ + File: file, + Category: CategoryRefactor, + Confidence: 0.7, + Detail: fmt.Sprintf("Renamed from %s with significant changes", ds.OldPath), + ReviewPriority: "medium", + } + } + + // New files + if ds.IsNew { + return ChangeClassification{ + File: file, + Category: CategoryNew, + Confidence: 1.0, + Detail: fmt.Sprintf("New file (+%d lines)", ds.Additions), + ReviewPriority: "high", + } + } + + // Test files + if isTestFilePath(file) { + return ChangeClassification{ + File: file, + Category: CategoryTest, + Confidence: 1.0, + Detail: "Test file update", + ReviewPriority: "medium", + } + } + + // Config/build files + if isConfigFile(file) { + return ChangeClassification{ + File: file, + Category: CategoryConfig, + Confidence: 1.0, + Detail: "Configuration/build file", + ReviewPriority: "low", + } + } + + // Churn detection: file changed frequently in recent history + if e.isChurning(ctx, file) { + return ChangeClassification{ + File: file, + Category: CategoryChurn, + Confidence: 0.8, + Detail: "File changed frequently in the last 30 days — stability concern", + ReviewPriority: "high", + } + } + + // Default: modified + return ChangeClassification{ + File: file, + Category: CategoryModified, + Confidence: 1.0, + Detail: fmt.Sprintf("+%d −%d", ds.Additions, ds.Deletions), + ReviewPriority: "medium", + } +} + +// estimateRenameSimilarity estimates how similar a renamed file is to its original. +// Uses the ratio of unchanged lines to total lines. +func estimateRenameSimilarity(ds git.DiffStats) float64 { + total := ds.Additions + ds.Deletions + if total == 0 { + return 1.0 // Pure rename, no content change + } + // Smaller diffs → more similar + maxChange := ds.Additions + if ds.Deletions > maxChange { + maxChange = ds.Deletions + } + if maxChange < 5 { + return 0.95 + } + if maxChange < 20 { + return 0.85 + } + return 0.5 +} + +// isConfigFile returns true for common config/build file patterns. +func isConfigFile(path string) bool { + base := filepath.Base(path) + + configFiles := map[string]bool{ + "Makefile": true, "CMakeLists.txt": true, "Dockerfile": true, + "docker-compose.yml": true, "docker-compose.yaml": true, + ".gitignore": true, ".eslintrc": true, ".prettierrc": true, ".editorconfig": true, + "tsconfig.json": true, "package.json": true, "package-lock.json": true, + "go.mod": true, "go.sum": true, "Cargo.toml": true, "Cargo.lock": true, + "pyproject.toml": true, "setup.py": true, "setup.cfg": true, + "pom.xml": true, "build.gradle": true, + "Jenkinsfile": true, + } + if configFiles[base] { + return true + } + + ext := filepath.Ext(base) + if ext == ".yml" || ext == ".yaml" { + dir := filepath.Dir(path) + if strings.Contains(dir, ".github") || strings.Contains(dir, "ci/") || + strings.Contains(dir, ".ci/") || strings.Contains(dir, ".circleci") { + return true + } + } + + return false +} + +// isChurning checks if a file was changed frequently in the last 30 days. +func (e *Engine) isChurning(_ context.Context, file string) bool { + if e.gitAdapter == nil { + return false + } + + history, err := e.gitAdapter.GetFileHistory(file, 10) + if err != nil || history.CommitCount < 3 { + return false + } + + since := time.Now().AddDate(0, 0, -30) + recentCount := 0 + for _, c := range history.Commits { + ts, err := time.Parse(time.RFC3339, c.Timestamp) + if err != nil { + continue + } + if ts.After(since) { + recentCount++ + } + } + + return recentCount >= 3 +} diff --git a/internal/query/review_complexity.go b/internal/query/review_complexity.go new file mode 100644 index 00000000..ca2eec23 --- /dev/null +++ b/internal/query/review_complexity.go @@ -0,0 +1,160 @@ +package query + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/SimplyLiz/CodeMCP/internal/complexity" +) + +// ComplexityDelta represents complexity change for a single file. +type ComplexityDelta struct { + File string `json:"file"` + CyclomaticBefore int `json:"cyclomaticBefore"` + CyclomaticAfter int `json:"cyclomaticAfter"` + CyclomaticDelta int `json:"cyclomaticDelta"` + CognitiveBefore int `json:"cognitiveBefore"` + CognitiveAfter int `json:"cognitiveAfter"` + CognitiveDelta int `json:"cognitiveDelta"` + HottestFunction string `json:"hottestFunction,omitempty"` +} + +// checkComplexityDelta compares complexity before and after for changed files. +func (e *Engine) checkComplexityDelta(ctx context.Context, files []string, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + if !complexity.IsAvailable() { + return ReviewCheck{ + Name: "complexity", + Status: "skip", + Severity: "warning", + Summary: "Complexity analysis not available (tree-sitter not built)", + Duration: time.Since(start).Milliseconds(), + }, nil + } + + analyzer := complexity.NewAnalyzer() + var deltas []ComplexityDelta + var findings []ReviewFinding + + maxDelta := opts.Policy.MaxComplexityDelta + + for _, file := range files { + if ctx.Err() != nil { + break + } + absPath := filepath.Join(e.repoRoot, file) + + // Analyze current version (tree-sitter — requires lock) + e.tsMu.Lock() + afterResult, err := analyzer.AnalyzeFile(ctx, absPath) + e.tsMu.Unlock() + if err != nil || afterResult.Error != "" { + continue + } + + // Analyze base version — git show runs without lock, tree-sitter with lock + beforeResult := e.getBaseComplexityLocked(ctx, analyzer, file, opts.BaseBranch) + if beforeResult == nil { + continue // New file, no before + } + + delta := ComplexityDelta{ + File: file, + CyclomaticBefore: beforeResult.TotalCyclomatic, + CyclomaticAfter: afterResult.TotalCyclomatic, + CyclomaticDelta: afterResult.TotalCyclomatic - beforeResult.TotalCyclomatic, + CognitiveBefore: beforeResult.TotalCognitive, + CognitiveAfter: afterResult.TotalCognitive, + CognitiveDelta: afterResult.TotalCognitive - beforeResult.TotalCognitive, + } + + // Find the function with highest complexity increase + if afterResult.MaxCyclomatic > 0 { + for _, fn := range afterResult.Functions { + if fn.Cyclomatic == afterResult.MaxCyclomatic { + delta.HottestFunction = fn.Name + break + } + } + } + + // Only report if complexity increased + if delta.CyclomaticDelta > 0 || delta.CognitiveDelta > 0 { + deltas = append(deltas, delta) + + sev := "info" + if maxDelta > 0 && delta.CyclomaticDelta > maxDelta { + sev = "warning" + } + + msg := fmt.Sprintf("Complexity %d→%d (+%d cyclomatic)", + delta.CyclomaticBefore, delta.CyclomaticAfter, delta.CyclomaticDelta) + if delta.HottestFunction != "" { + msg += fmt.Sprintf(" in %s()", delta.HottestFunction) + } + + findings = append(findings, ReviewFinding{ + Check: "complexity", + Severity: sev, + File: file, + Message: msg, + Category: "complexity", + RuleID: "ckb/complexity/increase", + }) + } + } + + status := "pass" + summary := "No significant complexity increase" + totalDelta := 0 + for _, d := range deltas { + totalDelta += d.CyclomaticDelta + } + if totalDelta > 0 { + summary = fmt.Sprintf("+%d cyclomatic complexity across %d file(s)", totalDelta, len(deltas)) + if maxDelta > 0 && totalDelta > maxDelta { + status = "warn" + } + } + + return ReviewCheck{ + Name: "complexity", + Status: status, + Severity: "warning", + Summary: summary, + Details: deltas, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +// getBaseComplexityLocked gets complexity of a file at a given git ref, +// acquiring tsMu only for the tree-sitter AnalyzeSource call. +func (e *Engine) getBaseComplexityLocked(ctx context.Context, analyzer *complexity.Analyzer, file, ref string) *complexity.FileComplexity { + // git show runs without the tree-sitter lock + cmd := exec.CommandContext(ctx, "git", "show", ref+":"+file) + cmd.Dir = e.repoRoot + output, err := cmd.Output() + if err != nil { + return nil // File doesn't exist in base (new file) + } + + ext := strings.ToLower(filepath.Ext(file)) + lang, ok := complexity.LanguageFromExtension(ext) + if !ok { + return nil + } + + e.tsMu.Lock() + result, err := analyzer.AnalyzeSource(ctx, file, output, lang) + e.tsMu.Unlock() + if err != nil || result.Error != "" { + return nil + } + + return result +} diff --git a/internal/query/review_coupling.go b/internal/query/review_coupling.go new file mode 100644 index 00000000..b53e4674 --- /dev/null +++ b/internal/query/review_coupling.go @@ -0,0 +1,134 @@ +package query + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/SimplyLiz/CodeMCP/internal/coupling" +) + +// CouplingGap represents a missing co-changed file. +type CouplingGap struct { + ChangedFile string `json:"changedFile"` + MissingFile string `json:"missingFile"` + CoChangeRate float64 `json:"coChangeRate"` + LastCoChange string `json:"lastCoChange,omitempty"` +} + +// checkCouplingGaps checks if commonly co-changed files are missing from the changeset. +func (e *Engine) checkCouplingGaps(ctx context.Context, changedFiles []string) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + changedSet := make(map[string]bool) + for _, f := range changedFiles { + changedSet[f] = true + } + + analyzer := coupling.NewAnalyzer(e.repoRoot, e.logger) + minCorrelation := 0.7 + + var gaps []CouplingGap + + // For each changed file, check if its highly-coupled partners are also in the changeset. + // Skip config/CI paths — they always co-change and produce noise, not signal. + // Limit to first 20 source files to avoid excessive git log calls. + var filesToCheck []string + for _, f := range changedFiles { + if isCouplingNoiseFile(f) { + continue + } + filesToCheck = append(filesToCheck, f) + if len(filesToCheck) >= 20 { + break + } + } + + for _, file := range filesToCheck { + if ctx.Err() != nil { + break + } + result, err := analyzer.Analyze(ctx, coupling.AnalyzeOptions{ + Target: file, + MinCorrelation: minCorrelation, + WindowDays: 365, + Limit: 5, + }) + if err != nil { + continue + } + + for _, corr := range result.Correlations { + missing := corr.FilePath + if missing == "" { + missing = corr.File + } + if corr.Correlation >= minCorrelation && !changedSet[missing] && !isCouplingNoiseFile(missing) { + gaps = append(gaps, CouplingGap{ + ChangedFile: file, + MissingFile: missing, + CoChangeRate: corr.Correlation, + }) + } + } + } + + var findings []ReviewFinding + for _, gap := range gaps { + findings = append(findings, ReviewFinding{ + Check: "coupling", + Severity: "warning", + File: gap.ChangedFile, + Message: fmt.Sprintf("Missing co-change: %s (%.0f%% co-change rate)", gap.MissingFile, gap.CoChangeRate*100), + Suggestion: fmt.Sprintf("Consider also changing %s — it historically changes together with %s", gap.MissingFile, gap.ChangedFile), + Category: "coupling", + RuleID: "ckb/coupling/missing-cochange", + }) + } + + status := "pass" + summary := "No missing co-change files" + if len(gaps) > 0 { + status = "warn" + summary = fmt.Sprintf("%d commonly co-changed file(s) missing from changeset", len(gaps)) + } + + return ReviewCheck{ + Name: "coupling", + Status: status, + Severity: "warning", + Summary: summary, + Details: gaps, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +// isCouplingNoiseFile returns true for paths where co-change analysis produces +// noise rather than signal (CI workflows, config dirs, generated files). +func isCouplingNoiseFile(path string) bool { + noisePrefixes := []string{ + ".github/", + ".gitlab-ci", + "ci/", + ".circleci/", + ".buildkite/", + } + for _, prefix := range noisePrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + noiseSuffixes := []string{ + ".yml", + ".yaml", + ".lock", + ".sum", + } + for _, suffix := range noiseSuffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} diff --git a/internal/query/review_deadcode.go b/internal/query/review_deadcode.go new file mode 100644 index 00000000..ca808f2c --- /dev/null +++ b/internal/query/review_deadcode.go @@ -0,0 +1,86 @@ +package query + +import ( + "context" + "fmt" + "path/filepath" + "time" +) + +// checkDeadCode finds dead code within the changed files using the SCIP index. +func (e *Engine) checkDeadCode(ctx context.Context, changedFiles []string, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + // Build scope from changed file directories + dirSet := make(map[string]bool) + for _, f := range changedFiles { + dirSet[filepath.Dir(f)] = true + } + dirs := make([]string, 0, len(dirSet)) + for d := range dirSet { + dirs = append(dirs, d) + } + + minConf := opts.Policy.DeadCodeMinConfidence + if minConf <= 0 { + minConf = 0.8 + } + + resp, err := e.FindDeadCode(ctx, FindDeadCodeOptions{ + Scope: dirs, + MinConfidence: minConf, + IncludeExported: true, + Limit: 50, + }) + if err != nil { + return ReviewCheck{ + Name: "dead-code", + Status: "skip", + Severity: "warning", + Summary: fmt.Sprintf("Could not analyze: %v", err), + Duration: time.Since(start).Milliseconds(), + }, nil + } + + // Filter to only items in the changed files + changedSet := make(map[string]bool) + for _, f := range changedFiles { + changedSet[f] = true + } + + var findings []ReviewFinding + for _, item := range resp.DeadCode { + if !changedSet[item.FilePath] { + continue + } + hint := "" + if item.SymbolName != "" { + hint = fmt.Sprintf("→ ckb explain %s", item.SymbolName) + } + findings = append(findings, ReviewFinding{ + Check: "dead-code", + Severity: "warning", + File: item.FilePath, + StartLine: item.LineNumber, + Message: fmt.Sprintf("Dead code: %s (%s) — %s", item.SymbolName, item.Kind, item.Reason), + Category: "dead-code", + RuleID: fmt.Sprintf("ckb/dead-code/%s", item.Category), + Hint: hint, + }) + } + + status := "pass" + summary := "No dead code in changed files" + if len(findings) > 0 { + status = "warn" + summary = fmt.Sprintf("%d dead code item(s) found in changed files", len(findings)) + } + + return ReviewCheck{ + Name: "dead-code", + Status: status, + Severity: "warning", + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} diff --git a/internal/query/review_effort.go b/internal/query/review_effort.go new file mode 100644 index 00000000..326af7fc --- /dev/null +++ b/internal/query/review_effort.go @@ -0,0 +1,130 @@ +package query + +import ( + "fmt" + "math" + + "github.com/SimplyLiz/CodeMCP/internal/backends/git" +) + +// ReviewEffort estimates the time needed to review a PR. +type ReviewEffort struct { + EstimatedMinutes int `json:"estimatedMinutes"` // Total estimated review time + EstimatedHours float64 `json:"estimatedHours"` // Same as minutes but as hours + Factors []string `json:"factors"` // What drives the estimate + Complexity string `json:"complexity"` // "trivial", "moderate", "complex", "very-complex" +} + +// estimateReviewEffort calculates estimated review time based on PR metrics. +// +// Based on research (Microsoft, Google code review studies): +// - ~200 LOC/hour for new code +// - ~300 LOC/hour for refactored/modified code +// - ~500 LOC/hour for moved/test/config code (quick scan) +// - Cognitive overhead per file switch: ~2 min +// - Cross-module context switch: ~5 min +// - Critical path files: +10 min each +func estimateReviewEffort(diffStats []git.DiffStats, breakdown *ChangeBreakdown, criticalFiles int, modules int) *ReviewEffort { + if len(diffStats) == 0 { + return &ReviewEffort{ + EstimatedMinutes: 0, + Complexity: "trivial", + } + } + + var factors []string + totalMinutes := 0.0 + + // Base time from lines of code (weighted by classification) + locMinutes := 0.0 + if breakdown != nil { + for _, c := range breakdown.Classifications { + ds := findDiffStat(diffStats, c.File) + if ds == nil { + continue + } + lines := ds.Additions + ds.Deletions + switch c.Category { + case CategoryNew: + locMinutes += float64(lines) / 200.0 * 60 // 200 LOC/hr + case CategoryRefactor, CategoryModified, CategoryChurn: + locMinutes += float64(lines) / 300.0 * 60 // 300 LOC/hr + case CategoryMoved, CategoryTest, CategoryConfig: + locMinutes += float64(lines) / 500.0 * 60 // 500 LOC/hr (quick scan) + case CategoryGenerated: + // Skip — not reviewed + } + } + } else { + // Fallback without classification + for _, ds := range diffStats { + lines := ds.Additions + ds.Deletions + locMinutes += float64(lines) / 250.0 * 60 // 250 LOC/hr average + } + } + totalMinutes += locMinutes + if locMinutes > 0 { + factors = append(factors, fmt.Sprintf("%.0f min from %d LOC", locMinutes, totalLOC(diffStats))) + } + + // File switch overhead: ~2 min per file + fileSwitchMinutes := float64(len(diffStats)) * 2.0 + totalMinutes += fileSwitchMinutes + if len(diffStats) > 5 { + factors = append(factors, fmt.Sprintf("%.0f min from %d file switches", fileSwitchMinutes, len(diffStats))) + } + + // Module context switches: ~5 min per module beyond the first + if modules > 1 { + moduleMinutes := float64(modules-1) * 5.0 + totalMinutes += moduleMinutes + factors = append(factors, fmt.Sprintf("%.0f min from %d module context switches", moduleMinutes, modules-1)) + } + + // Critical files: add 50% overhead per critical file + if criticalFiles > 0 { + criticalMinutes := float64(criticalFiles) * 10.0 + totalMinutes += criticalMinutes + factors = append(factors, fmt.Sprintf("%.0f min for %d critical files", criticalMinutes, criticalFiles)) + } + + // Floor at 5 minutes + minutes := int(math.Ceil(totalMinutes)) + if minutes < 5 && len(diffStats) > 0 { + minutes = 5 + } + + complexity := "trivial" + switch { + case minutes > 240: + complexity = "very-complex" + case minutes > 60: + complexity = "complex" + case minutes > 20: + complexity = "moderate" + } + + return &ReviewEffort{ + EstimatedMinutes: minutes, + EstimatedHours: math.Round(float64(minutes)/60.0*10) / 10, // 1 decimal + Factors: factors, + Complexity: complexity, + } +} + +func findDiffStat(diffStats []git.DiffStats, file string) *git.DiffStats { + for i := range diffStats { + if diffStats[i].FilePath == file { + return &diffStats[i] + } + } + return nil +} + +func totalLOC(diffStats []git.DiffStats) int { + total := 0 + for _, ds := range diffStats { + total += ds.Additions + ds.Deletions + } + return total +} diff --git a/internal/query/review_health.go b/internal/query/review_health.go new file mode 100644 index 00000000..192cca8c --- /dev/null +++ b/internal/query/review_health.go @@ -0,0 +1,646 @@ +package query + +import ( + "bufio" + "context" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/SimplyLiz/CodeMCP/internal/complexity" + "github.com/SimplyLiz/CodeMCP/internal/ownership" +) + +// CodeHealthDelta represents the health change for a single file. +type CodeHealthDelta struct { + File string `json:"file"` + HealthBefore int `json:"healthBefore"` // 0-100 + HealthAfter int `json:"healthAfter"` // 0-100 + Delta int `json:"delta"` // negative = degradation + Grade string `json:"grade"` // A/B/C/D/F + GradeBefore string `json:"gradeBefore"` + TopFactor string `json:"topFactor"` // What drives the score most + NewFile bool `json:"newFile,omitempty"` +} + +// CodeHealthReport aggregates health deltas across the PR. +type CodeHealthReport struct { + Deltas []CodeHealthDelta `json:"deltas"` + AverageDelta float64 `json:"averageDelta"` + WorstFile string `json:"worstFile,omitempty"` + WorstGrade string `json:"worstGrade,omitempty"` + Degraded int `json:"degraded"` // Files that got worse + Improved int `json:"improved"` // Files that got better +} + +// Health score weights — must sum to 1.0. +// Coverage was removed because no coverage data source is available yet. +// When coverage is added, reduce churn and cyclomatic by 0.05 each. +const ( + weightCyclomatic = 0.25 + weightCognitive = 0.15 + weightFileSize = 0.10 + weightChurn = 0.15 + weightCoupling = 0.10 + weightBusFactor = 0.10 + weightAge = 0.15 + + // Maximum files to compute health for. Beyond this, the check + // reports results for the first N files only. + maxHealthFiles = 30 +) + +// repoMetrics caches branch-independent per-file metrics (churn, coupling, +// bus factor, age) so they're computed once, not twice (before + after). +type repoMetrics struct { + churn float64 + coupling float64 + bus float64 + age float64 +} + +// checkCodeHealth calculates health score deltas for changed files. +func (e *Engine) checkCodeHealth(ctx context.Context, files []string, opts ReviewPROptions) (ReviewCheck, []ReviewFinding, *CodeHealthReport) { + start := time.Now() + + var deltas []CodeHealthDelta + var findings []ReviewFinding + + // Create a single complexity analyzer to reuse across all files. + // Each call to NewAnalyzer allocates a cgo tree-sitter Parser; + // reusing one avoids 60+ unnecessary alloc/free cycles. + var analyzer *complexity.Analyzer + if complexity.IsAvailable() { + analyzer = complexity.NewAnalyzer() + } + + // Cap file count to avoid excessive subprocess calls + capped := files + if len(capped) > maxHealthFiles { + capped = capped[:maxHealthFiles] + } + + // Filter to existing files + var existingFiles []string + for _, file := range capped { + absPath := filepath.Join(e.repoRoot, file) + if _, err := os.Stat(absPath); !os.IsNotExist(err) { + existingFiles = append(existingFiles, file) + } + } + + // Batch compute repo-level metrics (churn, coupling, bus factor, age) + // in 3 git calls + parallel blame instead of 4 × N sequential calls. + metricsMap := e.batchRepoMetrics(ctx, existingFiles) + + for _, file := range existingFiles { + if ctx.Err() != nil { + break + } + + rm := metricsMap[file] + + e.tsMu.Lock() + after := e.calculateFileHealth(ctx, file, rm, analyzer) + e.tsMu.Unlock() + + before, isNew := e.calculateBaseFileHealthLocked(ctx, file, opts.BaseBranch, rm, analyzer) + + delta := after - before + grade := healthGrade(after) + gradeBefore := healthGrade(before) + + topFactor := "unchanged" + if isNew { + topFactor = "new file" + } else if delta < -10 { + topFactor = "significant health degradation" + } else if delta < 0 { + topFactor = "minor health decrease" + } else if delta > 10 { + topFactor = "health improvement" + } + + d := CodeHealthDelta{ + File: file, + HealthBefore: before, + HealthAfter: after, + Delta: delta, + Grade: grade, + GradeBefore: gradeBefore, + TopFactor: topFactor, + NewFile: isNew, + } + deltas = append(deltas, d) + + // Generate findings for significant degradation (skip new files — + // they don't have a prior state to degrade from) + if !isNew && delta < -10 { + sev := "warning" + if after < 30 { + sev = "error" + } + findings = append(findings, ReviewFinding{ + Check: "health", + Severity: sev, + File: file, + Message: fmt.Sprintf("Health %s→%s (%d→%d, %+d points)", gradeBefore, grade, before, after, delta), + Category: "health", + RuleID: "ckb/health/degradation", + }) + } + } + + // Build report + report := &CodeHealthReport{ + Deltas: deltas, + } + if len(deltas) > 0 { + totalDelta := 0 + existingCount := 0 + worstScore := 101 + for _, d := range deltas { + if !d.NewFile { + totalDelta += d.Delta + existingCount++ + if d.Delta < 0 { + report.Degraded++ + } + if d.Delta > 0 { + report.Improved++ + } + } + if d.HealthAfter < worstScore { + worstScore = d.HealthAfter + report.WorstFile = d.File + report.WorstGrade = d.Grade + } + } + if existingCount > 0 { + report.AverageDelta = float64(totalDelta) / float64(existingCount) + } + } + + status := "pass" + summary := "No significant health changes" + if report.Degraded > 0 { + summary = fmt.Sprintf("%d file(s) degraded, %d improved (avg %+.1f)", + report.Degraded, report.Improved, report.AverageDelta) + if report.AverageDelta < -5 { + status = "warn" + } + } else if report.Degraded == 0 && len(deltas) > 0 { + // All changes are new files or unchanged — not a health concern + newCount := 0 + for _, d := range deltas { + if d.NewFile { + newCount++ + } + } + if newCount > 0 { + summary = fmt.Sprintf("%d new file(s), %d unchanged", newCount, len(deltas)-newCount) + } + } + + return ReviewCheck{ + Name: "health", + Status: status, + Severity: "warning", + Summary: summary, + Details: report, + Duration: time.Since(start).Milliseconds(), + }, findings, report +} + +// batchRepoMetrics computes repo-level metrics for all files using batched +// git operations instead of 4 × N individual subprocess calls. +// +// Before: 30 files × (git log + git blame + coupling analyze + git log) = ~120+ calls +// After: 1 git log --name-only + parallel git blame = ~12 calls +func (e *Engine) batchRepoMetrics(ctx context.Context, files []string) map[string]repoMetrics { + result := make(map[string]repoMetrics, len(files)) + defaultMetrics := repoMetrics{churn: 75, coupling: 75, bus: 75, age: 75} + for _, f := range files { + result[f] = defaultMetrics + } + + if e.gitAdapter == nil || !e.gitAdapter.IsAvailable() { + if e.logger != nil { + e.logger.Warn("git unavailable, health scores use default metrics (75) and may not reflect actual quality") + } + return result + } + + // --- Batch 1: Single git log for churn + age + coupling --- + // One command replaces per-file GetFileHistory + coupling.Analyze calls. + sinceDate := time.Now().AddDate(0, 0, -365).Format("2006-01-02") + cmd := exec.CommandContext(ctx, "git", "log", + "--format=COMMIT:%aI", "--name-only", + "--since="+sinceDate) + cmd.Dir = e.repoRoot + logOutput, err := cmd.Output() + if err == nil { + churnAge, cochangeMatrix := parseGitLogBatch(string(logOutput)) + + // Build file set for fast lookup + fileSet := make(map[string]bool, len(files)) + for _, f := range files { + fileSet[f] = true + } + + for _, f := range files { + rm := result[f] + + // Churn score — commit count in last 30 days + if ca, ok := churnAge[f]; ok { + rm.churn = churnCountToScore(ca.commitCount30d) + rm.age = ageDaysToScore(ca.daysSinceLastCommit) + } + + // Coupling score — count of highly correlated files + if commits, ok := cochangeMatrix[f]; ok && len(commits) > 0 { + coupled := countCoupledFiles(f, commits, cochangeMatrix, fileSet) + rm.coupling = coupledCountToScore(coupled) + } + + result[f] = rm + } + } + + // --- Batch 2: Parallel git blame for bus factor --- + // Run up to 5 concurrent blame calls instead of 30 sequential. + const maxBlameWorkers = 5 + blameCh := make(chan string, len(files)) + for _, f := range files { + blameCh <- f + } + close(blameCh) + + var blameMu sync.Mutex + var blameWg sync.WaitGroup + workers := maxBlameWorkers + if len(files) < workers { + workers = len(files) + } + for i := 0; i < workers; i++ { + blameWg.Add(1) + go func() { + defer blameWg.Done() + for file := range blameCh { + if ctx.Err() != nil { + return + } + busScore := e.busFactorToScore(file) + blameMu.Lock() + rm := result[file] + rm.bus = busScore + result[file] = rm + blameMu.Unlock() + } + }() + } + blameWg.Wait() + + return result +} + +// churnAgeInfo holds per-file data extracted from a single git log scan. +type churnAgeInfo struct { + commitCount30d int + daysSinceLastCommit float64 +} + +// parseGitLogBatch parses output of `git log --format=COMMIT:%aI --name-only` +// and returns per-file churn/age info plus a co-change matrix (file → list of commit indices). +func parseGitLogBatch(output string) (map[string]churnAgeInfo, map[string][]int) { + churnAge := make(map[string]churnAgeInfo) + cochange := make(map[string][]int) // file → commit indices + + now := time.Now() + thirtyDaysAgo := now.AddDate(0, 0, -30) + + lines := strings.Split(output, "\n") + commitIdx := -1 + var commitTime time.Time + + for _, line := range lines { + if strings.HasPrefix(line, "COMMIT:") { + commitIdx++ + ts := strings.TrimPrefix(line, "COMMIT:") + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(ts)) + if err == nil { + commitTime = parsed + } + continue + } + + file := strings.TrimSpace(line) + if file == "" { + continue + } + + // Track co-change matrix + cochange[file] = append(cochange[file], commitIdx) + + // Track churn + age + ca := churnAge[file] + if !commitTime.IsZero() { + if commitTime.After(thirtyDaysAgo) { + ca.commitCount30d++ + } + daysSince := now.Sub(commitTime).Hours() / 24 + if ca.daysSinceLastCommit == 0 || daysSince < ca.daysSinceLastCommit { + ca.daysSinceLastCommit = daysSince + } + } + churnAge[file] = ca + } + + return churnAge, cochange +} + +// countCoupledFiles counts how many files are correlated (>= 30% co-change rate) +// with the target file, considering only files in the review set. +func countCoupledFiles(target string, targetCommits []int, cochange map[string][]int, fileSet map[string]bool) int { + if len(targetCommits) == 0 { + return 0 + } + + // Build set of target's commit indices + commitSet := make(map[int]bool, len(targetCommits)) + for _, c := range targetCommits { + commitSet[c] = true + } + + coupled := 0 + for file, commits := range cochange { + if file == target { + continue + } + // Count overlapping commits + overlap := 0 + for _, c := range commits { + if commitSet[c] { + overlap++ + } + } + rate := float64(overlap) / float64(len(targetCommits)) + if rate >= 0.3 { + coupled++ + } + } + return coupled +} + +func churnCountToScore(commits int) float64 { + switch { + case commits <= 2: + return 100 + case commits <= 5: + return 80 + case commits <= 10: + return 60 + case commits <= 20: + return 40 + default: + return 20 + } +} + +func ageDaysToScore(days float64) float64 { + switch { + case days <= 30: + return 100 + case days <= 90: + return 85 + case days <= 180: + return 70 + case days <= 365: + return 50 + default: + return 30 + } +} + +func coupledCountToScore(coupled int) float64 { + switch { + case coupled <= 2: + return 100 + case coupled <= 5: + return 80 + case coupled <= 10: + return 60 + default: + return 40 + } +} + +// calculateFileHealth computes a 0-100 health score for a file in its current state. +// analyzer may be nil if tree-sitter is not available. +func (e *Engine) calculateFileHealth(ctx context.Context, file string, rm repoMetrics, analyzer *complexity.Analyzer) int { + absPath := filepath.Join(e.repoRoot, file) + score := 100.0 + + // Cyclomatic complexity (25%) + Cognitive complexity (15%) + complexityApplied := false + if analyzer != nil { + result, err := analyzer.AnalyzeFile(ctx, absPath) + if err == nil && result.Error == "" { + complexityApplied = true + cycScore := complexityToScore(result.MaxCyclomatic) + score -= (100 - cycScore) * weightCyclomatic + + cogScore := complexityToScore(result.MaxCognitive) + score -= (100 - cogScore) * weightCognitive + } + } + if !complexityApplied { + // Tree-sitter couldn't parse this file (binary, unsupported language, etc.). + // Apply a neutral-pessimistic penalty so unparseable files don't get + // artificially high scores. 50 = middle of the scale. + score -= (100 - 50) * weightCyclomatic + score -= (100 - 50) * weightCognitive + } + + // File size (10%) + loc := countLines(absPath) + locScore := fileSizeToScore(loc) + score -= (100 - locScore) * weightFileSize + + // Repo-level metrics (pre-computed, branch-independent) + score -= (100 - rm.churn) * weightChurn + score -= (100 - rm.coupling) * weightCoupling + score -= (100 - rm.bus) * weightBusFactor + score -= (100 - rm.age) * weightAge + + if score < 0 { + score = 0 + } + return int(math.Round(score)) +} + +// calculateBaseFileHealthLocked gets the health of a file at a base branch ref. +// Acquires tsMu only for tree-sitter calls; git show runs unlocked. +func (e *Engine) calculateBaseFileHealthLocked(ctx context.Context, file string, baseBranch string, rm repoMetrics, analyzer *complexity.Analyzer) (int, bool) { + if baseBranch == "" { + e.tsMu.Lock() + score := e.calculateFileHealth(ctx, file, rm, analyzer) + e.tsMu.Unlock() + return score, false + } + + // git show runs without the tree-sitter lock + cmd := exec.CommandContext(ctx, "git", "-C", e.repoRoot, "show", baseBranch+":"+file) + content, err := cmd.Output() + if err != nil { + return 0, true // New file + } + + tmpFile, err := os.CreateTemp("", "ckb-base-*"+filepath.Ext(file)) + if err != nil { + e.tsMu.Lock() + score := e.calculateFileHealth(ctx, file, rm, analyzer) + e.tsMu.Unlock() + return score, false + } + defer func() { + tmpFile.Close() + os.Remove(tmpFile.Name()) + }() + + if _, err := tmpFile.Write(content); err != nil { + e.tsMu.Lock() + score := e.calculateFileHealth(ctx, file, rm, analyzer) + e.tsMu.Unlock() + return score, false + } + tmpFile.Close() + + score := 100.0 + + // Tree-sitter: lock only for AnalyzeFile + complexityApplied := false + if analyzer != nil { + e.tsMu.Lock() + result, err := analyzer.AnalyzeFile(ctx, tmpFile.Name()) + e.tsMu.Unlock() + if err == nil && result.Error == "" { + complexityApplied = true + cycScore := complexityToScore(result.MaxCyclomatic) + score -= (100 - cycScore) * weightCyclomatic + + cogScore := complexityToScore(result.MaxCognitive) + score -= (100 - cogScore) * weightCognitive + } + } + if !complexityApplied { + score -= (100 - 50) * weightCyclomatic + score -= (100 - 50) * weightCognitive + } + + loc := countLines(tmpFile.Name()) + locScore := fileSizeToScore(loc) + score -= (100 - locScore) * weightFileSize + + score -= (100 - rm.churn) * weightChurn + score -= (100 - rm.coupling) * weightCoupling + score -= (100 - rm.bus) * weightBusFactor + score -= (100 - rm.age) * weightAge + + if score < 0 { + score = 0 + } + return int(math.Round(score)), false +} + +// --- Scoring helper functions --- + +func complexityToScore(maxComplexity int) float64 { + switch { + case maxComplexity <= 5: + return 100 + case maxComplexity <= 10: + return 85 + case maxComplexity <= 20: + return 65 + case maxComplexity <= 30: + return 40 + default: + return 20 + } +} + +func fileSizeToScore(loc int) float64 { + switch { + case loc <= 100: + return 100 + case loc <= 300: + return 85 + case loc <= 500: + return 70 + case loc <= 1000: + return 50 + default: + return 30 + } +} + +func (e *Engine) busFactorToScore(file string) float64 { + result, err := ownership.RunGitBlame(e.repoRoot, file) + if err != nil { + return 75 + } + config := ownership.BlameConfig{ + TimeDecayHalfLife: 365, + } + own := ownership.ComputeBlameOwnership(result, config) + if own == nil { + return 75 + } + contributors := len(own.Contributors) + switch { + case contributors >= 5: + return 100 // Shared knowledge + case contributors >= 3: + return 85 + case contributors >= 2: + return 60 + default: + return 30 // Single author = bus factor 1 + } +} + +func healthGrade(score int) string { + switch { + case score >= 90: + return "A" + case score >= 70: + return "B" + case score >= 50: + return "C" + case score >= 30: + return "D" + default: + return "F" + } +} + +func countLines(path string) int { + f, err := os.Open(path) + if err != nil { + return 0 + } + defer f.Close() + + scanner := bufio.NewScanner(f) + count := 0 + for scanner.Scan() { + count++ + } + return count +} diff --git a/internal/query/review_independence.go b/internal/query/review_independence.go new file mode 100644 index 00000000..45bc48b3 --- /dev/null +++ b/internal/query/review_independence.go @@ -0,0 +1,127 @@ +package query + +import ( + "context" + "fmt" + "strings" + "time" +) + +// IndependenceResult holds the outcome of reviewer independence analysis. +type IndependenceResult struct { + Authors []string `json:"authors"` // PR authors + CriticalFiles []string `json:"criticalFiles"` // Critical-path files in the PR + RequiresSignoff bool `json:"requiresSignoff"` // Whether independent review is required + MinReviewers int `json:"minReviewers"` // Minimum required reviewers +} + +// checkReviewerIndependence verifies that the PR will receive independent review. +// This is a compliance check — it flags the requirement, it doesn't enforce it. +func (e *Engine) checkReviewerIndependence(ctx context.Context, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + if e.gitAdapter == nil { + return ReviewCheck{ + Name: "independence", + Status: "skip", + Severity: "warning", + Summary: "Git adapter not available", + Duration: time.Since(start).Milliseconds(), + }, nil + } + + // Get PR authors from commit range + commits, err := e.gitAdapter.GetCommitRange(opts.BaseBranch, opts.HeadBranch) + if err != nil { + return ReviewCheck{ + Name: "independence", + Status: "skip", + Severity: "warning", + Summary: fmt.Sprintf("Could not analyze: %v", err), + Duration: time.Since(start).Milliseconds(), + }, nil + } + + authorSet := make(map[string]bool) + for _, c := range commits { + authorSet[c.Author] = true + } + + authors := make([]string, 0, len(authorSet)) + for a := range authorSet { + authors = append(authors, a) + } + + minReviewers := opts.Policy.MinReviewers + if minReviewers <= 0 { + minReviewers = 1 + } + + var findings []ReviewFinding + + // Check if critical paths are touched (makes independence more important) + hasCriticalFiles := false + var criticalFilesList []string + if len(opts.Policy.CriticalPaths) > 0 { + diffStats, err := e.gitAdapter.GetCommitRangeDiff(opts.BaseBranch, opts.HeadBranch) + if err == nil { + for _, df := range diffStats { + for _, pattern := range opts.Policy.CriticalPaths { + matched, _ := matchGlob(pattern, df.FilePath) + if matched { + criticalFilesList = append(criticalFilesList, df.FilePath) + hasCriticalFiles = true + break + } + } + } + } + } + + severity := "warning" + if hasCriticalFiles { + severity = "error" + } + + authorList := strings.Join(authors, ", ") + + findings = append(findings, ReviewFinding{ + Check: "independence", + Severity: severity, + Message: fmt.Sprintf("Requires independent review (not by: %s); min %d reviewer(s)", authorList, minReviewers), + Suggestion: "Ensure the reviewer is not the author of the changes", + Category: "compliance", + RuleID: "ckb/independence/require-independent-reviewer", + }) + + if hasCriticalFiles { + findings = append(findings, ReviewFinding{ + Check: "independence", + Severity: "error", + Message: "Safety-critical files changed — independent verification required per IEC 61508 / ISO 26262", + Category: "compliance", + RuleID: "ckb/independence/critical-path-review", + }) + } + + status := "warn" + summary := fmt.Sprintf("Independent review required (authors: %s)", authorList) + if hasCriticalFiles { + status = "fail" + summary = fmt.Sprintf("Critical files — independent review required (authors: %s)", authorList) + } + + return ReviewCheck{ + Name: "independence", + Status: status, + Severity: severity, + Summary: summary, + Details: IndependenceResult{ + Authors: authors, + CriticalFiles: criticalFilesList, + RequiresSignoff: true, + MinReviewers: minReviewers, + }, + Duration: time.Since(start).Milliseconds(), + }, findings +} diff --git a/internal/query/review_new_checks_test.go b/internal/query/review_new_checks_test.go new file mode 100644 index 00000000..431fb6d2 --- /dev/null +++ b/internal/query/review_new_checks_test.go @@ -0,0 +1,304 @@ +package query + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestReviewPR_DeadCodeCheck(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "pkg/used.go": `package pkg + +func UsedFunc() string { + return "hello" +} +`, + "pkg/unused.go": `package pkg + +func UnusedExportedFunc() string { + return "nobody calls me" +} +`, + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Checks: []string{"dead-code"}, + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + // dead-code check should be present (may skip without SCIP index, that's fine) + found := false + for _, c := range resp.Checks { + if c.Name == "dead-code" { + found = true + if c.Status != "pass" && c.Status != "skip" && c.Status != "warn" { + t.Errorf("unexpected dead-code status %q", c.Status) + } + } + } + if !found { + t.Error("expected 'dead-code' check to be present") + } +} + +func TestReviewPR_TestGapsCheck(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "pkg/handler.go": `package pkg + +import "fmt" + +func HandleRequest(input string) string { + result := process(input) + return fmt.Sprintf("handled: %s", result) +} + +func process(s string) string { + return s + " processed" +} +`, + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Checks: []string{"test-gaps"}, + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + found := false + for _, c := range resp.Checks { + if c.Name == "test-gaps" { + found = true + // May be pass (no gaps found), info (gaps found), or skip + validStatuses := map[string]bool{"pass": true, "info": true, "skip": true} + if !validStatuses[c.Status] { + t.Errorf("unexpected test-gaps status %q", c.Status) + } + } + } + if !found { + t.Error("expected 'test-gaps' check to be present") + } +} + +func TestReviewPR_BlastRadiusCheck(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "pkg/core.go": `package pkg + +func CoreFunction() string { + return "core" +} +`, + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + + // With maxFanOut=0 (default), blast-radius should skip + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Checks: []string{"blast-radius"}, + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + found := false + for _, c := range resp.Checks { + if c.Name == "blast-radius" { + found = true + if c.Status != "skip" { + t.Errorf("expected blast-radius to skip with default policy (maxFanOut=0), got %q", c.Status) + } + } + } + if !found { + t.Error("expected 'blast-radius' check to be present") + } + + // With maxFanOut set, it should run (pass or skip due to no SCIP index) + policy := DefaultReviewPolicy() + policy.MaxFanOut = 5 + resp2, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Checks: []string{"blast-radius"}, + Policy: policy, + }) + if err != nil { + t.Fatalf("ReviewPR with maxFanOut failed: %v", err) + } + + for _, c := range resp2.Checks { + if c.Name == "blast-radius" { + validStatuses := map[string]bool{"pass": true, "warn": true, "skip": true} + if !validStatuses[c.Status] { + t.Errorf("unexpected blast-radius status %q", c.Status) + } + } + } +} + +func TestReviewPR_Staged(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + repoRoot := engine.repoRoot + + gitCmd := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@test.com", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + gitCmd("init", "-b", "main") + if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("# Test\n"), 0644); err != nil { + t.Fatal(err) + } + gitCmd("add", ".") + gitCmd("commit", "-m", "initial") + + // Stage a new file without committing + if err := os.WriteFile(filepath.Join(repoRoot, "staged.go"), []byte("package main\n\nfunc Staged() {}\n"), 0644); err != nil { + t.Fatal(err) + } + gitCmd("add", "staged.go") + + reinitEngine(t, engine) + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + Staged: true, + Checks: []string{"secrets"}, // lightweight check + }) + if err != nil { + t.Fatalf("ReviewPR --staged failed: %v", err) + } + + if resp.Summary.TotalFiles != 1 { + t.Errorf("expected 1 staged file, got %d", resp.Summary.TotalFiles) + } +} + +func TestReviewPR_ScopeFilter(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "internal/query/engine.go": "package query\n\nfunc Engine() {}\n", + "cmd/ckb/main.go": "package main\n\nfunc main() {}\n", + "internal/query/review.go": "package query\n\nfunc Review() {}\n", + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Scope: "internal/query/", + Checks: []string{"secrets"}, // lightweight check + }) + if err != nil { + t.Fatalf("ReviewPR with scope failed: %v", err) + } + + // Only internal/query/ files should be in scope + if resp.Summary.TotalFiles != 2 { + t.Errorf("expected 2 files in scope 'internal/query/', got %d", resp.Summary.TotalFiles) + } +} + +func TestReviewPR_HintField(t *testing.T) { + t.Parallel() + + // Verify that the Hint field is properly set on ReviewFinding + f := ReviewFinding{ + Check: "dead-code", + Severity: "warning", + File: "test.go", + Message: "Dead code detected", + Hint: "→ ckb explain MyFunc", + } + + if f.Hint == "" { + t.Error("expected Hint to be set") + } + if f.Hint != "→ ckb explain MyFunc" { + t.Errorf("unexpected Hint value: %q", f.Hint) + } +} + +func TestFindingTier_NewChecks(t *testing.T) { + t.Parallel() + + tests := []struct { + check string + tier int + }{ + {"dead-code", 2}, + {"blast-radius", 2}, + {"test-gaps", 3}, + // existing + {"breaking", 1}, + {"secrets", 1}, + {"coupling", 2}, + } + + for _, tt := range tests { + got := findingTier(tt.check) + if got != tt.tier { + t.Errorf("findingTier(%q) = %d, want %d", tt.check, got, tt.tier) + } + } +} + +func TestDefaultReviewPolicy_NewFields(t *testing.T) { + t.Parallel() + + policy := DefaultReviewPolicy() + + if policy.DeadCodeMinConfidence != 0.8 { + t.Errorf("expected DeadCodeMinConfidence 0.8, got %f", policy.DeadCodeMinConfidence) + } + if policy.TestGapMinLines != 5 { + t.Errorf("expected TestGapMinLines 5, got %d", policy.TestGapMinLines) + } +} diff --git a/internal/query/review_reviewers.go b/internal/query/review_reviewers.go new file mode 100644 index 00000000..6b0ac0a3 --- /dev/null +++ b/internal/query/review_reviewers.go @@ -0,0 +1,40 @@ +package query + +import ( + "context" +) + +// ClusterReviewerAssignment maps cluster-level reviewer suggestions. +type ClusterReviewerAssignment struct { + ClusterName string `json:"clusterName"` + ClusterIdx int `json:"clusterIdx"` + Reviewers []SuggestedReview `json:"reviewers"` +} + +// assignClusterReviewers assigns reviewers to each cluster based on ownership. +// Builds on the existing getSuggestedReviewers logic but scoped per cluster. +func (e *Engine) assignClusterReviewers(ctx context.Context, clusters []PRCluster) []ClusterReviewerAssignment { + assignments := make([]ClusterReviewerAssignment, 0, len(clusters)) + + for i, cluster := range clusters { + files := make([]PRFileChange, 0, len(cluster.Files)) + for _, f := range cluster.Files { + files = append(files, PRFileChange{Path: f}) + } + + reviewers := e.getSuggestedReviewers(ctx, files) + + // Limit to top 3 reviewers per cluster + if len(reviewers) > 3 { + reviewers = reviewers[:3] + } + + assignments = append(assignments, ClusterReviewerAssignment{ + ClusterName: cluster.Name, + ClusterIdx: i, + Reviewers: reviewers, + }) + } + + return assignments +} diff --git a/internal/query/review_split.go b/internal/query/review_split.go new file mode 100644 index 00000000..348dd8e9 --- /dev/null +++ b/internal/query/review_split.go @@ -0,0 +1,222 @@ +package query + +import ( + "context" + "fmt" + "sort" + + "github.com/SimplyLiz/CodeMCP/internal/backends/git" + "github.com/SimplyLiz/CodeMCP/internal/coupling" +) + +// PRSplitSuggestion contains the result of PR split analysis. +type PRSplitSuggestion struct { + ShouldSplit bool `json:"shouldSplit"` + Reason string `json:"reason"` + Clusters []PRCluster `json:"clusters"` + EstimatedSaving string `json:"estimatedSaving,omitempty"` // e.g., "6h → 3×2h" +} + +// PRCluster represents a group of files that belong together. +type PRCluster struct { + Name string `json:"name"` + Files []string `json:"files"` + FileCount int `json:"fileCount"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Independent bool `json:"independent"` // Can be reviewed/merged independently + DependsOn []int `json:"dependsOn,omitempty"` // Indices of clusters this depends on + Languages []string `json:"languages,omitempty"` +} + +// suggestPRSplit analyzes the changeset and groups files into independent clusters. +// Uses module affinity, coupling data, and connected component analysis. +func (e *Engine) suggestPRSplit(ctx context.Context, diffStats []git.DiffStats, policy *ReviewPolicy) *PRSplitSuggestion { + if policy.SplitThreshold <= 0 || len(diffStats) < policy.SplitThreshold { + return nil + } + + files := make([]string, len(diffStats)) + statsMap := make(map[string]git.DiffStats) + for i, ds := range diffStats { + files[i] = ds.FilePath + statsMap[ds.FilePath] = ds + } + + // Build adjacency graph: files are connected if they share a module + // or have high coupling correlation + adj := make(map[string]map[string]bool) + for _, f := range files { + adj[f] = make(map[string]bool) + } + + // Connect files in the same module + fileToModule := make(map[string]string) + moduleFiles := make(map[string][]string) + for _, f := range files { + mod := e.resolveFileModule(f) + fileToModule[f] = mod + if mod != "" { + moduleFiles[mod] = append(moduleFiles[mod], f) + } + } + for _, group := range moduleFiles { + for i := 0; i < len(group); i++ { + for j := i + 1; j < len(group); j++ { + adj[group[i]][group[j]] = true + adj[group[j]][group[i]] = true + } + } + } + + // Connect files with high coupling + e.addCouplingEdges(ctx, files, adj) + + // Find connected components using BFS + visited := make(map[string]bool) + var components [][]string + + for _, f := range files { + if visited[f] { + continue + } + component := bfs(f, adj, visited) + components = append(components, component) + } + + if len(components) <= 1 { + return &PRSplitSuggestion{ + ShouldSplit: false, + Reason: "All files are interconnected — no independent clusters found", + } + } + + // Build clusters with metadata + clusters := make([]PRCluster, 0, len(components)) + for _, comp := range components { + c := buildCluster(comp, statsMap, fileToModule) + clusters = append(clusters, c) + } + + // Sort by file count descending + sort.Slice(clusters, func(i, j int) bool { + return clusters[i].FileCount > clusters[j].FileCount + }) + + // Name unnamed clusters + for i := range clusters { + if clusters[i].Name == "" { + clusters[i].Name = fmt.Sprintf("Cluster %d", i+1) + } + clusters[i].Independent = true // Connected components are independent by definition + } + + return &PRSplitSuggestion{ + ShouldSplit: true, + Reason: fmt.Sprintf("%d files across %d independent clusters — split recommended", len(files), len(clusters)), + Clusters: clusters, + } +} + +// addCouplingEdges enriches the adjacency graph with coupling data. +func (e *Engine) addCouplingEdges(ctx context.Context, files []string, adj map[string]map[string]bool) { + analyzer := coupling.NewAnalyzer(e.repoRoot, e.logger) + + fileSet := make(map[string]bool) + for _, f := range files { + fileSet[f] = true + } + + // Limit coupling lookups for performance + limit := 20 + if len(files) < limit { + limit = len(files) + } + + for _, f := range files[:limit] { + if ctx.Err() != nil { + break + } + result, err := analyzer.Analyze(ctx, coupling.AnalyzeOptions{ + RepoRoot: e.repoRoot, + Target: f, + MinCorrelation: 0.5, // Higher threshold — only strong connections matter for split + Limit: 10, + }) + if err != nil { + continue + } + for _, corr := range result.Correlations { + if fileSet[corr.File] { + adj[f][corr.File] = true + adj[corr.File][f] = true + } + } + } +} + +// bfs performs breadth-first search to find a connected component. +func bfs(start string, adj map[string]map[string]bool, visited map[string]bool) []string { + queue := []string{start} + visited[start] = true + var component []string + + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + component = append(component, node) + + for neighbor := range adj[node] { + if !visited[neighbor] { + visited[neighbor] = true + queue = append(queue, neighbor) + } + } + } + return component +} + +// buildCluster creates a PRCluster from a list of files. +func buildCluster(files []string, statsMap map[string]git.DiffStats, fileToModule map[string]string) PRCluster { + adds, dels := 0, 0 + moduleCounts := make(map[string]int) + langSet := make(map[string]bool) + + for _, f := range files { + if ds, ok := statsMap[f]; ok { + adds += ds.Additions + dels += ds.Deletions + } + if mod := fileToModule[f]; mod != "" { + moduleCounts[mod]++ + } + if lang := detectLanguage(f); lang != "" { + langSet[lang] = true + } + } + + // Name by dominant module + name := "" + maxCount := 0 + for mod, count := range moduleCounts { + if count > maxCount { + maxCount = count + name = mod + } + } + + var langs []string + for l := range langSet { + langs = append(langs, l) + } + sort.Strings(langs) + + return PRCluster{ + Name: name, + Files: files, + FileCount: len(files), + Additions: adds, + Deletions: dels, + Languages: langs, + } +} diff --git a/internal/query/review_test.go b/internal/query/review_test.go new file mode 100644 index 00000000..e502129d --- /dev/null +++ b/internal/query/review_test.go @@ -0,0 +1,647 @@ +package query + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// setupGitRepoWithBranch creates a temp git repo with a base commit on "main" +// and a feature branch with changed files. Returns engine + cleanup. +func setupGitRepoWithBranch(t *testing.T, files map[string]string) (*Engine, func()) { + t.Helper() + + engine, cleanup := testEngine(t) + repoRoot := engine.repoRoot + + // Initialize git repo + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@test.com", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + git("init", "-b", "main") + + // Create initial file on main + initialFile := filepath.Join(repoRoot, "README.md") + if err := os.WriteFile(initialFile, []byte("# Test\n"), 0644); err != nil { + t.Fatal(err) + } + git("add", ".") + git("commit", "-m", "initial commit") + + // Create feature branch and add changed files + git("checkout", "-b", "feature/test") + + for path, content := range files { + absPath := filepath.Join(repoRoot, path) + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + git("add", ".") + git("commit", "-m", "feature changes") + + // Re-initialize git adapter since repo now exists + reinitEngine(t, engine) + + return engine, cleanup +} + +// reinitEngine re-initializes the engine's git adapter after git init. +func reinitEngine(t *testing.T, engine *Engine) { + t.Helper() + if err := engine.initializeBackends(engine.config); err != nil { + t.Fatalf("failed to reinitialize backends: %v", err) + } +} + +func TestReviewPR_EmptyDiff(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + repoRoot := engine.repoRoot + + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@test.com", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + git("init", "-b", "main") + if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("# Test\n"), 0644); err != nil { + t.Fatal(err) + } + git("add", ".") + git("commit", "-m", "initial") + git("checkout", "-b", "feature/empty") + + reinitEngine(t, engine) + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/empty", + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + if resp.Verdict != "pass" { + t.Errorf("expected verdict 'pass', got %q", resp.Verdict) + } + if resp.Score != 100 { + t.Errorf("expected score 100, got %d", resp.Score) + } + if len(resp.Checks) != 0 { + t.Errorf("expected 0 checks for empty diff, got %d", len(resp.Checks)) + } +} + +func TestReviewPR_BasicChanges(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "pkg/main.go": "package main\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n", + "pkg/util.go": "package main\n\nfunc helper() string {\n\treturn \"help\"\n}\n", + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + // Basic response structure + if resp.CkbVersion == "" { + t.Error("expected CkbVersion to be set") + } + if resp.SchemaVersion != "8.2" { + t.Errorf("expected SchemaVersion '8.2', got %q", resp.SchemaVersion) + } + if resp.Tool != "reviewPR" { + t.Errorf("expected Tool 'reviewPR', got %q", resp.Tool) + } + + // Should have files in summary + if resp.Summary.TotalFiles != 2 { + t.Errorf("expected 2 changed files, got %d", resp.Summary.TotalFiles) + } + if resp.Summary.TotalChanges == 0 { + t.Error("expected non-zero total changes") + } + + // Should have checks run + if len(resp.Checks) == 0 { + t.Error("expected at least one check to run") + } + + // Verdict should be one of the valid values + validVerdicts := map[string]bool{"pass": true, "warn": true, "fail": true} + if !validVerdicts[resp.Verdict] { + t.Errorf("unexpected verdict %q", resp.Verdict) + } + + // Score should be in range + if resp.Score < 0 || resp.Score > 100 { + t.Errorf("score %d out of range [0,100]", resp.Score) + } + + // Languages should include Go + foundGo := false + for _, lang := range resp.Summary.Languages { + if lang == "go" { + foundGo = true + } + } + if !foundGo { + t.Errorf("expected Go in languages, got %v", resp.Summary.Languages) + } +} + +func TestReviewPR_ChecksFilter(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "app.go": "package app\n\nfunc Run() {}\n", + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + + // Request only secrets check + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Checks: []string{"secrets"}, + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + // Should only have the secrets check + if len(resp.Checks) != 1 { + t.Errorf("expected 1 check, got %d: %v", len(resp.Checks), checkNames(resp.Checks)) + } + if len(resp.Checks) > 0 && resp.Checks[0].Name != "secrets" { + t.Errorf("expected check 'secrets', got %q", resp.Checks[0].Name) + } +} + +func TestReviewPR_GeneratedFileExclusion(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "real.go": "package main\n\nfunc Real() {}\n", + "types.pb.go": "// Code generated by protoc. DO NOT EDIT.\npackage main\n", + "parser.generated.go": "// AUTO-GENERATED\npackage parser\n", + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + if resp.Summary.TotalFiles != 3 { + t.Errorf("expected 3 total files, got %d", resp.Summary.TotalFiles) + } + if resp.Summary.GeneratedFiles < 2 { + t.Errorf("expected at least 2 generated files, got %d", resp.Summary.GeneratedFiles) + } + if resp.Summary.ReviewableFiles > 1 { + t.Errorf("expected at most 1 reviewable file, got %d", resp.Summary.ReviewableFiles) + } +} + +func TestReviewPR_CriticalPaths(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "drivers/modbus/handler.go": "package modbus\n\nfunc Handle() {}\n", + "ui/page.go": "package ui\n\nfunc Render() {}\n", + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + policy := DefaultReviewPolicy() + policy.CriticalPaths = []string{"drivers/**"} + + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Policy: policy, + Checks: []string{"critical"}, + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + // Should have critical check + found := false + for _, c := range resp.Checks { + if c.Name == "critical" { + found = true + if c.Status == "skip" { + t.Error("critical check should not be skipped when critical paths are configured") + } + } + } + if !found { + t.Error("expected 'critical' check to be present") + } + + // Should flag the driver file + hasCriticalFinding := false + for _, f := range resp.Findings { + if f.Category == "critical" { + hasCriticalFinding = true + } + } + if !hasCriticalFinding { + t.Error("expected at least one critical finding for drivers/** path") + } +} + +func TestReviewPR_SecretsDetection(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "config.go": fmt.Sprintf("package config\n\nvar APIKey = %q\n", "AKIAIOSFODNN7EXAMPLE"), + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Checks: []string{"secrets"}, + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + // Secrets check should be present + var secretsCheck *ReviewCheck + for i := range resp.Checks { + if resp.Checks[i].Name == "secrets" { + secretsCheck = &resp.Checks[i] + } + } + if secretsCheck == nil { + t.Fatal("expected secrets check to be present") + } + + // The AWS key pattern should be detected + if secretsCheck.Status == "pass" && len(resp.Findings) == 0 { + // Secrets detection depends on the scanner implementation — if the builtin + // scanner catches this pattern, we should have findings. If not, the check + // still ran which is the important thing. + t.Log("secrets check passed with no findings — scanner may not catch this pattern") + } +} + +func TestReviewPR_PolicyOverrides(t *testing.T) { + t.Parallel() + + files := map[string]string{ + "app.go": "package app\n\nfunc Run() {}\n", + } + + engine, cleanup := setupGitRepoWithBranch(t, files) + defer cleanup() + + ctx := context.Background() + + // Test with failOnLevel = "none" — should always pass + policy := DefaultReviewPolicy() + policy.FailOnLevel = "none" + + resp, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "feature/test", + Policy: policy, + }) + if err != nil { + t.Fatalf("ReviewPR failed: %v", err) + } + + if resp.Verdict != "pass" { + t.Errorf("expected verdict 'pass' with failOnLevel=none, got %q", resp.Verdict) + } +} + +func TestReviewPR_NoGitAdapter(t *testing.T) { + t.Parallel() + + engine, cleanup := testEngine(t) + defer cleanup() + + // Engine without git init — gitAdapter may be nil or not available + ctx := context.Background() + _, err := engine.ReviewPR(ctx, ReviewPROptions{ + BaseBranch: "main", + HeadBranch: "HEAD", + }) + + // Should error gracefully (either git adapter not available or diff fails) + if err == nil { + t.Log("ReviewPR succeeded without git repo — gitAdapter may still be initialized") + } +} + +func TestDefaultReviewPolicy(t *testing.T) { + t.Parallel() + + policy := DefaultReviewPolicy() + + if !policy.BlockBreakingChanges { + t.Error("expected BlockBreakingChanges to be true by default") + } + if !policy.BlockSecrets { + t.Error("expected BlockSecrets to be true by default") + } + if policy.FailOnLevel != "error" { + t.Errorf("expected FailOnLevel 'error', got %q", policy.FailOnLevel) + } + if !policy.HoldTheLine { + t.Error("expected HoldTheLine to be true by default") + } + if policy.SplitThreshold != 50 { + t.Errorf("expected SplitThreshold 50, got %d", policy.SplitThreshold) + } + if len(policy.GeneratedPatterns) == 0 { + t.Error("expected default generated patterns") + } + if len(policy.GeneratedMarkers) == 0 { + t.Error("expected default generated markers") + } +} + +func TestDetectGeneratedFile(t *testing.T) { + t.Parallel() + + policy := DefaultReviewPolicy() + + tests := []struct { + path string + expected bool + }{ + {"types.pb.go", true}, + {"parser.tab.c", true}, + {"lex.yy.c", true}, + {"widget.generated.dart", true}, + {"main.go", false}, + {"src/app.ts", false}, + {"README.md", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + _, detected := detectGeneratedFile(tt.path, policy) + if detected != tt.expected { + t.Errorf("detectGeneratedFile(%q) = %v, want %v", tt.path, detected, tt.expected) + } + }) + } +} + +func TestMatchGlob(t *testing.T) { + t.Parallel() + + tests := []struct { + pattern string + path string + match bool + }{ + {"drivers/**", "drivers/modbus/handler.go", true}, + {"drivers/**", "ui/page.go", false}, + {"*.pb.go", "types.pb.go", true}, + {"*.pb.go", "main.go", false}, + {"protocol/**", "protocol/v2/packet.go", true}, + {"src/**/*.ts", "src/components/app.ts", true}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%s_%s", tt.pattern, tt.path), func(t *testing.T) { + got, err := matchGlob(tt.pattern, tt.path) + if err != nil { + t.Fatalf("matchGlob error: %v", err) + } + if got != tt.match { + t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.pattern, tt.path, got, tt.match) + } + }) + } +} + +func TestCalculateReviewScore(t *testing.T) { + t.Parallel() + + // No findings → 100 + score := calculateReviewScore(nil, nil) + if score != 100 { + t.Errorf("expected score 100 for no findings, got %d", score) + } + + // Error findings reduce by 10 each + findings := []ReviewFinding{ + {Check: "breaking", Severity: "error", File: "a.go"}, + } + score = calculateReviewScore(nil, findings) + if score != 90 { + t.Errorf("expected score 90 for 1 error finding, got %d", score) + } + + // Warning findings reduce by 3 each + findings = []ReviewFinding{ + {Check: "coupling", Severity: "warning", File: "b.go"}, + } + scoreWarn := calculateReviewScore(nil, findings) + if scoreWarn != 97 { + t.Errorf("expected score 97 for 1 warning finding, got %d", scoreWarn) + } + + // Mixed findings from different checks + findings = []ReviewFinding{ + {Check: "breaking", Severity: "error", File: "a.go"}, + {Check: "coupling", Severity: "warning", File: "b.go"}, + {Check: "hotspots", Severity: "info", File: "c.go"}, + } + score = calculateReviewScore(nil, findings) + // 100 - 10 - 3 - 1 = 86 + if score != 86 { + t.Errorf("expected score 86 for mixed findings, got %d", score) + } + + // Per-check cap: 15 errors from one check are capped at 20 points + manyErrors := make([]ReviewFinding, 15) + for i := range manyErrors { + manyErrors[i] = ReviewFinding{Check: "breaking", Severity: "error"} + } + score = calculateReviewScore(nil, manyErrors) + // 100 - 20 (capped) = 80 + if score != 80 { + t.Errorf("expected score 80 for 15 capped errors, got %d", score) + } + + // Total deduction cap: score floors at 20 (100 - 80 max deduction) + var manyCheckErrors []ReviewFinding + for i := 0; i < 6; i++ { + for j := 0; j < 5; j++ { + manyCheckErrors = append(manyCheckErrors, ReviewFinding{ + Check: fmt.Sprintf("check%d", i), + Severity: "error", + }) + } + } + score = calculateReviewScore(nil, manyCheckErrors) + // 6 checks × 20 per-check cap = 120 potential, but total cap is 80, so score = 20 + if score != 20 { + t.Errorf("expected score 20 for many checks at total cap, got %d", score) + } +} + +func TestDetermineVerdict(t *testing.T) { + t.Parallel() + + policy := DefaultReviewPolicy() + + tests := []struct { + name string + checks []ReviewCheck + verdict string + }{ + { + name: "all pass", + checks: []ReviewCheck{{Status: "pass"}, {Status: "pass"}}, + verdict: "pass", + }, + { + name: "has fail", + checks: []ReviewCheck{{Status: "fail"}, {Status: "pass"}}, + verdict: "fail", + }, + { + name: "has warn", + checks: []ReviewCheck{{Status: "warn"}, {Status: "pass"}}, + verdict: "warn", + }, + { + name: "empty checks", + checks: []ReviewCheck{}, + verdict: "pass", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineVerdict(tt.checks, policy) + if got != tt.verdict { + t.Errorf("determineVerdict() = %q, want %q", got, tt.verdict) + } + }) + } + + // failOnLevel = "none" → always pass + nonePolicy := DefaultReviewPolicy() + nonePolicy.FailOnLevel = "none" + got := determineVerdict([]ReviewCheck{{Status: "fail"}}, nonePolicy) + if got != "pass" { + t.Errorf("expected 'pass' with failOnLevel=none, got %q", got) + } +} + +func TestSortChecks(t *testing.T) { + t.Parallel() + + checks := []ReviewCheck{ + {Name: "a", Status: "pass"}, + {Name: "b", Status: "fail"}, + {Name: "c", Status: "warn"}, + {Name: "d", Status: "skip"}, + } + + sortChecks(checks) + + expected := []string{"fail", "warn", "pass", "skip"} + for i, exp := range expected { + if checks[i].Status != exp { + t.Errorf("sortChecks[%d]: expected status %q, got %q", i, exp, checks[i].Status) + } + } +} + +func TestSortFindings(t *testing.T) { + t.Parallel() + + findings := []ReviewFinding{ + {Severity: "info", File: "c.go"}, + {Severity: "error", File: "a.go"}, + {Severity: "warning", File: "b.go"}, + } + + sortFindings(findings) + + expected := []string{"error", "warning", "info"} + for i, exp := range expected { + if findings[i].Severity != exp { + t.Errorf("sortFindings[%d]: expected severity %q, got %q", i, exp, findings[i].Severity) + } + } +} + +// checkNames is a test helper that extracts check names for error messages. +func checkNames(checks []ReviewCheck) []string { + names := make([]string, len(checks)) + for i, c := range checks { + names[i] = c.Name + } + return names +} diff --git a/internal/query/review_testgaps.go b/internal/query/review_testgaps.go new file mode 100644 index 00000000..806bd6c7 --- /dev/null +++ b/internal/query/review_testgaps.go @@ -0,0 +1,80 @@ +package query + +import ( + "context" + "fmt" + "time" +) + +// checkTestGaps finds untested functions in the changed files. +// Uses tree-sitter internally — acquires e.tsMu around AnalyzeTestGaps calls. +func (e *Engine) checkTestGaps(ctx context.Context, changedFiles []string, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + minLines := opts.Policy.TestGapMinLines + if minLines <= 0 { + minLines = 5 + } + + // Filter to non-test source files, cap at 20 + var sourceFiles []string + for _, f := range changedFiles { + if isTestFilePathEnhanced(f) { + continue + } + sourceFiles = append(sourceFiles, f) + if len(sourceFiles) >= 20 { + break + } + } + + var findings []ReviewFinding + for _, file := range sourceFiles { + if ctx.Err() != nil { + break + } + e.tsMu.Lock() + result, err := e.AnalyzeTestGaps(ctx, AnalyzeTestGapsOptions{ + Target: file, + MinLines: minLines, + Limit: 10, + }) + e.tsMu.Unlock() + if err != nil { + continue + } + + for _, gap := range result.Gaps { + hint := "" + if gap.Function != "" { + hint = fmt.Sprintf("→ ckb explain %s", gap.Function) + } + findings = append(findings, ReviewFinding{ + Check: "test-gaps", + Severity: "info", + File: gap.File, + StartLine: gap.StartLine, + EndLine: gap.EndLine, + Message: fmt.Sprintf("Untested function %s (complexity: %d)", gap.Function, gap.Complexity), + Category: "testing", + RuleID: fmt.Sprintf("ckb/test-gaps/%s", gap.Reason), + Hint: hint, + }) + } + } + + status := "pass" + summary := "All changed functions have tests" + if len(findings) > 0 { + status = "info" + summary = fmt.Sprintf("%d untested function(s) in changed files", len(findings)) + } + + return ReviewCheck{ + Name: "test-gaps", + Status: status, + Severity: "info", + Summary: summary, + Duration: time.Since(start).Milliseconds(), + }, findings +} diff --git a/internal/query/review_traceability.go b/internal/query/review_traceability.go new file mode 100644 index 00000000..cb295346 --- /dev/null +++ b/internal/query/review_traceability.go @@ -0,0 +1,194 @@ +package query + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" +) + +// TraceabilityResult holds the outcome of traceability analysis. +type TraceabilityResult struct { + TicketRefs []TicketReference `json:"ticketRefs"` + Linked bool `json:"linked"` // At least one ticket reference found + OrphanFiles []string `json:"orphanFiles"` // Files with no ticket linkage + CriticalOrphan bool `json:"criticalOrphan"` // Critical-path files without ticket +} + +// TicketReference is a detected ticket/requirement reference. +type TicketReference struct { + ID string `json:"id"` // e.g., "JIRA-1234" + Source string `json:"source"` // "commit-message", "branch-name" + Commit string `json:"commit"` // Commit hash where found +} + +// checkTraceability verifies that changes are linked to tickets/requirements. +func (e *Engine) checkTraceability(ctx context.Context, files []string, opts ReviewPROptions) (ReviewCheck, []ReviewFinding) { + start := time.Now() + + policy := opts.Policy + patterns := policy.TraceabilityPatterns + if len(patterns) == 0 { + return ReviewCheck{ + Name: "traceability", + Status: "skip", + Severity: "info", + Summary: "No traceability patterns configured", + Duration: time.Since(start).Milliseconds(), + }, nil + } + + sources := policy.TraceabilitySources + if len(sources) == 0 { + sources = []string{"commit-message", "branch-name"} + } + + // Compile regex patterns + regexps := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re, err := regexp.Compile(p) + if err != nil { + continue + } + regexps = append(regexps, re) + } + + if len(regexps) == 0 { + return ReviewCheck{ + Name: "traceability", + Status: "skip", + Severity: "info", + Summary: "No valid traceability patterns", + Duration: time.Since(start).Milliseconds(), + }, nil + } + + var refs []TicketReference + refSet := make(map[string]bool) + + // Search commit messages + if containsSource(sources, "commit-message") && e.gitAdapter != nil { + commits, err := e.gitAdapter.GetCommitRange(opts.BaseBranch, opts.HeadBranch) + if err == nil { + for _, c := range commits { + for _, re := range regexps { + matches := re.FindAllString(c.Message, -1) + for _, m := range matches { + if !refSet[m] { + refSet[m] = true + refs = append(refs, TicketReference{ + ID: m, + Source: "commit-message", + Commit: c.Hash, + }) + } + } + } + } + } + } + + // Search branch name + if containsSource(sources, "branch-name") { + branchName := opts.HeadBranch + if branchName == "" || branchName == "HEAD" { + if e.gitAdapter != nil { + branchName, _ = e.gitAdapter.GetCurrentBranch() + } + } + if branchName != "" { + for _, re := range regexps { + matches := re.FindAllString(branchName, -1) + for _, m := range matches { + if !refSet[m] { + refSet[m] = true + refs = append(refs, TicketReference{ + ID: m, + Source: "branch-name", + }) + } + } + } + } + } + + linked := len(refs) > 0 + + // Determine critical-path orphans + var findings []ReviewFinding + hasCriticalOrphan := false + + if !linked && policy.RequireTraceForCriticalPaths && len(policy.CriticalPaths) > 0 { + for _, f := range files { + for _, pattern := range policy.CriticalPaths { + matched, _ := matchGlob(pattern, f) + if matched { + hasCriticalOrphan = true + findings = append(findings, ReviewFinding{ + Check: "traceability", + Severity: "error", + File: f, + Message: fmt.Sprintf("Safety-critical file changed without ticket reference (pattern: %s)", pattern), + Suggestion: fmt.Sprintf("Add a ticket reference matching one of: %s", strings.Join(patterns, ", ")), + Category: "compliance", + RuleID: "ckb/traceability/critical-orphan", + }) + break + } + } + } + } + + if !linked && policy.RequireTraceability { + findings = append(findings, ReviewFinding{ + Check: "traceability", + Severity: "warning", + Message: fmt.Sprintf("No ticket reference found in commits or branch name (expected: %s)", strings.Join(patterns, ", ")), + Suggestion: "Reference a ticket in your commit message or branch name", + Category: "compliance", + RuleID: "ckb/traceability/no-ticket", + }) + } + + // Identify orphan files (files with no ticket linkage) + var orphanFiles []string + if !linked { + orphanFiles = files + } + + status := "pass" + summary := fmt.Sprintf("%d ticket reference(s) found", len(refs)) + if !linked { + if hasCriticalOrphan { + status = "fail" + summary = "Critical-path changes without ticket reference" + } else if policy.RequireTraceability { + status = "warn" + summary = "No ticket references found" + } + } + + return ReviewCheck{ + Name: "traceability", + Status: status, + Severity: "warning", + Summary: summary, + Details: TraceabilityResult{ + TicketRefs: refs, + Linked: linked, + OrphanFiles: orphanFiles, + CriticalOrphan: hasCriticalOrphan, + }, + Duration: time.Since(start).Milliseconds(), + }, findings +} + +func containsSource(sources []string, target string) bool { + for _, s := range sources { + if s == target { + return true + } + } + return false +} diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index 286ce916..def3e6e8 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "path/filepath" + "regexp" "sort" "strings" "time" @@ -382,10 +383,32 @@ func calculateConfidence(secret string, pattern Pattern) float64 { return confidence } +// goStructDeclRe matches Go struct field declarations like: +// +// Token string `json:"token"` +// Secret string `json:"secret"` +// Password []byte +var goStructDeclRe = regexp.MustCompile(`(?i)\b(secret|token|password|passwd|pwd)\s+(string|bool|int|\[\]byte|\[\]string|\*?\w+Config)\b`) + +// configKeyVarRe matches config/map key assignments where the value is a +// variable name (not a string literal), e.g.: +// +// "token": rawToken, +// "new_token": rawToken, +var configKeyVarRe = regexp.MustCompile(`(?i)["'](?:secret|token|password|passwd|pwd|new_token)["']\s*:\s*[a-zA-Z]\w*[,\s})]`) + // isLikelyFalsePositive checks for common false positive patterns. func isLikelyFalsePositive(line, secret string) bool { lineLower := strings.ToLower(line) + // Go struct field declarations and config key→variable assignments are not secrets + if goStructDeclRe.MatchString(line) { + return true + } + if configKeyVarRe.MatchString(line) { + return true + } + // Check for test/example indicators falsePositiveIndicators := []string{ "example", diff --git a/testdata/review/codeclimate.json b/testdata/review/codeclimate.json new file mode 100644 index 00000000..99d15cb7 --- /dev/null +++ b/testdata/review/codeclimate.json @@ -0,0 +1,130 @@ +[ + { + "type": "issue", + "check_name": "ckb/breaking/removed-symbol", + "description": "Removed public function HandleAuth()", + "categories": [ + "Compatibility" + ], + "location": { + "path": "api/handler.go", + "lines": { + "begin": 42 + } + }, + "severity": "critical", + "fingerprint": "ddebf33febf83e49eb21b4acb86bbe10" + }, + { + "type": "issue", + "check_name": "ckb/breaking/changed-signature", + "description": "Changed signature of ValidateToken()", + "categories": [ + "Compatibility" + ], + "location": { + "path": "api/middleware.go", + "lines": { + "begin": 15 + } + }, + "severity": "critical", + "fingerprint": "55468b6c78d409683d77b03117163950" + }, + { + "type": "issue", + "check_name": "ckb/critical/safety-path", + "description": "Safety-critical path changed (pattern: drivers/**)", + "content": { + "body": "Requires sign-off from safety team" + }, + "categories": [ + "Security", + "Bug Risk" + ], + "location": { + "path": "drivers/hw/plc_comm.go", + "lines": { + "begin": 78 + } + }, + "severity": "critical", + "fingerprint": "f5f83721df9e9da102b433f65ded16cc" + }, + { + "type": "issue", + "check_name": "ckb/critical/safety-path", + "description": "Safety-critical path changed (pattern: protocol/**)", + "content": { + "body": "Requires sign-off from safety team" + }, + "categories": [ + "Security", + "Bug Risk" + ], + "location": { + "path": "protocol/modbus.go" + }, + "severity": "critical", + "fingerprint": "5345e6b9c3896879a25c07dbe60d6238" + }, + { + "type": "issue", + "check_name": "ckb/complexity/increase", + "description": "Complexity 12→20 in parseQuery()", + "content": { + "body": "Consider extracting helper functions" + }, + "categories": [ + "Complexity" + ], + "location": { + "path": "internal/query/engine.go", + "lines": { + "begin": 155, + "end": 210 + } + }, + "severity": "major", + "fingerprint": "87610dd70c92e2f17d937d70e5a1bc31" + }, + { + "type": "issue", + "check_name": "ckb/coupling/missing-cochange", + "description": "Missing co-change: engine_test.go (87% co-change rate)", + "categories": [ + "Duplication" + ], + "location": { + "path": "internal/query/engine.go" + }, + "severity": "major", + "fingerprint": "d4c9562ec51cef9d16e46a2b6861372c" + }, + { + "type": "issue", + "check_name": "ckb/coupling/missing-cochange", + "description": "Missing co-change: modbus_test.go (91% co-change rate)", + "categories": [ + "Duplication" + ], + "location": { + "path": "protocol/modbus.go" + }, + "severity": "major", + "fingerprint": "7c222e1f6619f439975e82681592d58c" + }, + { + "type": "issue", + "check_name": "ckb/hotspots/volatile-file", + "description": "Hotspot file (score: 0.78) — extra review attention recommended", + "categories": [ + "Bug Risk" + ], + "location": { + "path": "config/settings.go" + }, + "severity": "minor", + "fingerprint": "a3d03fb0c9c16505cc72c55764a675af" + } +] \ No newline at end of file diff --git a/testdata/review/compliance.txt b/testdata/review/compliance.txt new file mode 100644 index 00000000..1da8337f --- /dev/null +++ b/testdata/review/compliance.txt @@ -0,0 +1,84 @@ +====================================================================== + CKB COMPLIANCE EVIDENCE REPORT +====================================================================== + +Generated: +CKB Version: 8.2.0 +Schema: 8.2 +Verdict: WARN (68/100) + +1. CHANGE SUMMARY +---------------------------------------- + Total Files: 25 + Reviewable Files: 22 + Generated Files: 3 (excluded) + Critical Files: 2 + Total Changes: 480 + Modules Changed: 3 + Languages: Go, TypeScript + +2. QUALITY GATE RESULTS +---------------------------------------- + CHECK STATUS DETAIL + -------------------- -------- ------------------------------ + breaking FAIL 2 breaking API changes detected + critical FAIL 2 safety-critical files changed + complexity WARN +8 cyclomatic (engine.go) + coupling WARN 2 missing co-change files + secrets PASS No secrets detected + tests PASS 12 tests cover the changes + risk PASS Risk score: 0.42 (low) + hotspots PASS No volatile files touched + generated INFO 3 generated files detected and excluded + + Passed: 4 Warned: 2 Failed: 1 Skipped: 1 + +3. TRACEABILITY +---------------------------------------- + Not configured (traceability patterns not set) + +4. REVIEWER INDEPENDENCE +---------------------------------------- + Not configured (requireIndependentReview not set) + +5. SAFETY-CRITICAL PATH FINDINGS +---------------------------------------- + [ERROR] Safety-critical path changed (pattern: drivers/**) + File: drivers/hw/plc_comm.go + Action: Requires sign-off from safety team + [ERROR] Safety-critical path changed (pattern: protocol/**) + File: protocol/modbus.go + Action: Requires sign-off from safety team + +6. CODE HEALTH +---------------------------------------- + FILE BEFORE AFTER DELTA + ---------------------------------------- -------- -------- -------- + api/handler.go B(82) B(70) -12 + internal/query/engine.go B(75) C(68) -7 + protocol/modbus.go C(60) C(65) +5 + + Degraded: 2 Improved: 1 Average Delta: -4.7 + +7. COMPLETE FINDINGS +---------------------------------------- + 1. [ERROR] [ckb/breaking/removed-symbol] Removed public function HandleAuth() + File: api/handler.go:42 + 2. [ERROR] [ckb/breaking/changed-signature] Changed signature of ValidateToken() + File: api/middleware.go:15 + 3. [ERROR] [ckb/critical/safety-path] Safety-critical path changed (pattern: drivers/**) + File: drivers/hw/plc_comm.go:78 + 4. [ERROR] [ckb/critical/safety-path] Safety-critical path changed (pattern: protocol/**) + File: protocol/modbus.go + 5. [WARNING] [ckb/complexity/increase] Complexity 12→20 in parseQuery() + File: internal/query/engine.go:155 + 6. [WARNING] [ckb/coupling/missing-cochange] Missing co-change: engine_test.go (87% co-change rate) + File: internal/query/engine.go + 7. [WARNING] [ckb/coupling/missing-cochange] Missing co-change: modbus_test.go (91% co-change rate) + File: protocol/modbus.go + 8. [INFO] [ckb/hotspots/volatile-file] Hotspot file (score: 0.78) — extra review attention recommended + File: config/settings.go + +====================================================================== + END OF COMPLIANCE EVIDENCE REPORT +====================================================================== diff --git a/testdata/review/github-actions.txt b/testdata/review/github-actions.txt new file mode 100644 index 00000000..7dcbecce --- /dev/null +++ b/testdata/review/github-actions.txt @@ -0,0 +1,8 @@ +::error file=api/handler.go,line=42::Removed public function HandleAuth() [ckb/breaking/removed-symbol] +::error file=api/middleware.go,line=15::Changed signature of ValidateToken() [ckb/breaking/changed-signature] +::error file=drivers/hw/plc_comm.go,line=78::Safety-critical path changed (pattern: drivers/**) [ckb/critical/safety-path] +::error file=protocol/modbus.go::Safety-critical path changed (pattern: protocol/**) [ckb/critical/safety-path] +::warning file=internal/query/engine.go,line=155::Complexity 12→20 in parseQuery() [ckb/complexity/increase] +::warning file=internal/query/engine.go::Missing co-change: engine_test.go (87%25 co-change rate) [ckb/coupling/missing-cochange] +::warning file=protocol/modbus.go::Missing co-change: modbus_test.go (91%25 co-change rate) [ckb/coupling/missing-cochange] +::notice file=config/settings.go::Hotspot file (score: 0.78) — extra review attention recommended [ckb/hotspots/volatile-file] diff --git a/testdata/review/human.txt b/testdata/review/human.txt new file mode 100644 index 00000000..14a37811 --- /dev/null +++ b/testdata/review/human.txt @@ -0,0 +1,57 @@ +CKB Review: ⚠ WARN · 25 files · 480 lines +════════════════════════════════════════════════════════ +22 reviewable · 3 generated (excluded) · 2 critical + + Changes 25 files across 3 modules (Go, TypeScript). 2 breaking API + changes detected; 2 safety-critical files changed. 2 safety-critical + files need focused review. + +Checks: + ✗ breaking 2 breaking API changes detected + ✗ critical 2 safety-critical files changed + ⚠ complexity +8 cyclomatic (engine.go) + ⚠ coupling 2 missing co-change files + ○ generated 3 generated files detected and excluded + ✓ secrets · tests · risk · hotspots + +Top Findings: + ⚠ api/handler.go + Removed public function HandleAuth() + ⚠ api/middleware.go + Changed signature of ValidateToken() + ⚠ drivers/hw/plc_comm.go + Safety-critical path changed (pattern: drivers/**) + ⚠ protocol/modbus.go + Safety-critical path changed (pattern: protocol/**) + ⚠ internal/query/engine.go + Complexity 12→20 in parseQuery() + ⚠ internal/query/engine.go + Usually changed with: + ⚠ protocol/modbus.go + Usually changed with: + ... and 1 informational + +Estimated Review: ~95min (complex) + · 22 reviewable files (44min base) + · 3 module context switches (15min) + · 2 safety-critical files (20min) + +Change Breakdown: + generated 3 files + modified 10 files + new 5 files + refactoring 3 files + test 4 files + +PR Split: + API Handler Refactor 8 files +240 −120 + Protocol Update 5 files +130 −60 + Driver Changes 12 files +80 −30 + +Code Health: + B ↓ api/handler.go (70) + C ↓ internal/query/engine.go (68) + C ↑ protocol/modbus.go (65) + 2 degraded · 1 improved · avg -4.7 + +Reviewers: @alice (85%) · @bob (45%) diff --git a/testdata/review/json.json b/testdata/review/json.json new file mode 100644 index 00000000..84e4f56d --- /dev/null +++ b/testdata/review/json.json @@ -0,0 +1,299 @@ +{ + "ckbVersion": "8.2.0", + "schemaVersion": "8.2", + "tool": "reviewPR", + "verdict": "warn", + "score": 68, + "summary": { + "totalFiles": 25, + "totalChanges": 480, + "generatedFiles": 3, + "reviewableFiles": 22, + "criticalFiles": 2, + "checksPassed": 4, + "checksWarned": 2, + "checksFailed": 1, + "checksSkipped": 1, + "topRisks": [ + "2 breaking API changes", + "Critical path touched" + ], + "languages": [ + "Go", + "TypeScript" + ], + "modulesChanged": 3 + }, + "checks": [ + { + "name": "breaking", + "status": "fail", + "severity": "error", + "summary": "2 breaking API changes detected", + "durationMs": 120 + }, + { + "name": "critical", + "status": "fail", + "severity": "error", + "summary": "2 safety-critical files changed", + "durationMs": 15 + }, + { + "name": "complexity", + "status": "warn", + "severity": "warning", + "summary": "+8 cyclomatic (engine.go)", + "durationMs": 340 + }, + { + "name": "coupling", + "status": "warn", + "severity": "warning", + "summary": "2 missing co-change files", + "durationMs": 210 + }, + { + "name": "secrets", + "status": "pass", + "severity": "error", + "summary": "No secrets detected", + "durationMs": 95 + }, + { + "name": "tests", + "status": "pass", + "severity": "warning", + "summary": "12 tests cover the changes", + "durationMs": 180 + }, + { + "name": "risk", + "status": "pass", + "severity": "warning", + "summary": "Risk score: 0.42 (low)", + "durationMs": 150 + }, + { + "name": "hotspots", + "status": "pass", + "severity": "info", + "summary": "No volatile files touched", + "durationMs": 45 + }, + { + "name": "generated", + "status": "info", + "severity": "info", + "summary": "3 generated files detected and excluded", + "durationMs": 0 + } + ], + "findings": [ + { + "check": "breaking", + "severity": "error", + "file": "api/handler.go", + "startLine": 42, + "message": "Removed public function HandleAuth()", + "category": "breaking", + "ruleId": "ckb/breaking/removed-symbol", + "tier": 1 + }, + { + "check": "breaking", + "severity": "error", + "file": "api/middleware.go", + "startLine": 15, + "message": "Changed signature of ValidateToken()", + "category": "breaking", + "ruleId": "ckb/breaking/changed-signature", + "tier": 1 + }, + { + "check": "critical", + "severity": "error", + "file": "drivers/hw/plc_comm.go", + "startLine": 78, + "message": "Safety-critical path changed (pattern: drivers/**)", + "suggestion": "Requires sign-off from safety team", + "category": "critical", + "ruleId": "ckb/critical/safety-path", + "tier": 1 + }, + { + "check": "critical", + "severity": "error", + "file": "protocol/modbus.go", + "message": "Safety-critical path changed (pattern: protocol/**)", + "suggestion": "Requires sign-off from safety team", + "category": "critical", + "ruleId": "ckb/critical/safety-path", + "tier": 1 + }, + { + "check": "complexity", + "severity": "warning", + "file": "internal/query/engine.go", + "startLine": 155, + "endLine": 210, + "message": "Complexity 12→20 in parseQuery()", + "suggestion": "Consider extracting helper functions", + "category": "complexity", + "ruleId": "ckb/complexity/increase", + "tier": 2 + }, + { + "check": "coupling", + "severity": "warning", + "file": "internal/query/engine.go", + "message": "Missing co-change: engine_test.go (87% co-change rate)", + "category": "coupling", + "ruleId": "ckb/coupling/missing-cochange", + "tier": 2 + }, + { + "check": "coupling", + "severity": "warning", + "file": "protocol/modbus.go", + "message": "Missing co-change: modbus_test.go (91% co-change rate)", + "category": "coupling", + "ruleId": "ckb/coupling/missing-cochange", + "tier": 2 + }, + { + "check": "hotspots", + "severity": "info", + "file": "config/settings.go", + "message": "Hotspot file (score: 0.78) — extra review attention recommended", + "category": "risk", + "ruleId": "ckb/hotspots/volatile-file", + "tier": 3 + } + ], + "reviewers": [ + { + "owner": "alice", + "reason": "", + "coverage": 0.85, + "confidence": 0.9 + }, + { + "owner": "bob", + "reason": "", + "coverage": 0.45, + "confidence": 0.7 + } + ], + "generated": [ + { + "file": "api/types.pb.go", + "reason": "Matches pattern *.pb.go", + "sourceFile": "api/types.proto" + }, + { + "file": "parser/parser.tab.c", + "reason": "flex/yacc generated output", + "sourceFile": "parser/parser.y" + }, + { + "file": "ui/generated.ts", + "reason": "Matches pattern *.generated.*" + } + ], + "splitSuggestion": { + "shouldSplit": true, + "reason": "25 files across 3 independent clusters — split recommended", + "clusters": [ + { + "name": "API Handler Refactor", + "files": [ + "api/handler.go", + "api/middleware.go" + ], + "fileCount": 8, + "additions": 240, + "deletions": 120, + "independent": true + }, + { + "name": "Protocol Update", + "files": [ + "protocol/modbus.go" + ], + "fileCount": 5, + "additions": 130, + "deletions": 60, + "independent": true + }, + { + "name": "Driver Changes", + "files": [ + "drivers/hw/plc_comm.go" + ], + "fileCount": 12, + "additions": 80, + "deletions": 30, + "independent": false + } + ] + }, + "changeBreakdown": { + "classifications": null, + "summary": { + "generated": 3, + "modified": 10, + "new": 5, + "refactoring": 3, + "test": 4 + } + }, + "reviewEffort": { + "estimatedMinutes": 95, + "estimatedHours": 1.58, + "factors": [ + "22 reviewable files (44min base)", + "3 module context switches (15min)", + "2 safety-critical files (20min)" + ], + "complexity": "complex" + }, + "healthReport": { + "deltas": [ + { + "file": "api/handler.go", + "healthBefore": 82, + "healthAfter": 70, + "delta": -12, + "grade": "B", + "gradeBefore": "B", + "topFactor": "significant health degradation" + }, + { + "file": "internal/query/engine.go", + "healthBefore": 75, + "healthAfter": 68, + "delta": -7, + "grade": "C", + "gradeBefore": "B", + "topFactor": "minor health decrease" + }, + { + "file": "protocol/modbus.go", + "healthBefore": 60, + "healthAfter": 65, + "delta": 5, + "grade": "C", + "gradeBefore": "C", + "topFactor": "unchanged" + } + ], + "averageDelta": -4.67, + "worstFile": "protocol/modbus.go", + "worstGrade": "C", + "degraded": 2, + "improved": 1 + }, + "narrative": "Changes 25 files across 3 modules (Go, TypeScript). 2 breaking API changes detected; 2 safety-critical files changed. 2 safety-critical files need focused review.", + "prTier": "medium" +} \ No newline at end of file diff --git a/testdata/review/markdown.md b/testdata/review/markdown.md new file mode 100644 index 00000000..3fee0c06 --- /dev/null +++ b/testdata/review/markdown.md @@ -0,0 +1,80 @@ +## CKB Review: 🟡 WARN — 68/100 + +**25 files** (+480 changes) · **3 modules** · `Go` `TypeScript` +**22 reviewable** · 3 generated (excluded) · **2 safety-critical** + +> Changes 25 files across 3 modules (Go, TypeScript). 2 breaking API changes detected; 2 safety-critical files changed. 2 safety-critical files need focused review. + +| Check | Status | Detail | +|-------|--------|--------| +| breaking | 🔴 FAIL | 2 breaking API changes detected | +| critical | 🔴 FAIL | 2 safety-critical files changed | +| complexity | 🟡 WARN | +8 cyclomatic (engine.go) | +| coupling | 🟡 WARN | 2 missing co-change files | +| secrets | ✅ PASS | No secrets detected | +| tests | ✅ PASS | 12 tests cover the changes | +| risk | ✅ PASS | Risk score: 0.42 (low) | +| hotspots | ✅ PASS | No volatile files touched | +| generated | ℹ️ INFO | 3 generated files detected and excluded | + +### Top Risks + +- 2 breaking API changes +- Critical path touched + +
Findings (7 actionable, 1 informational) + +| Severity | File | Finding | +|----------|------|---------| +| 🔴 | `api/handler.go:42` | Removed public function HandleAuth() | +| 🔴 | `api/middleware.go:15` | Changed signature of ValidateToken() | +| 🔴 | `drivers/hw/plc_comm.go:78` | Safety-critical path changed (pattern: drivers/**) | +| 🔴 | `protocol/modbus.go` | Safety-critical path changed (pattern: protocol/**) | +| 🟡 | `internal/query/engine.go:155` | Complexity 12→20 in parseQuery() | +| 🟡 | `internal/query/engine.go` | Missing co-change: engine_test.go (87% co-change rate) | +| 🟡 | `protocol/modbus.go` | Missing co-change: modbus_test.go (91% co-change rate) | + +
+ +
Change Breakdown + +| Category | Files | Review Priority | +|----------|-------|-----------------| +| generated | 3 | ⚪ Skip (review source) | +| modified | 10 | 🟡 Standard review | +| new | 5 | 🔴 Full review | +| refactoring | 3 | 🟡 Verify correctness | +| test | 4 | 🟡 Verify coverage | + +
+ +
✂️ Suggested PR Split (3 clusters) + +| Cluster | Files | Changes | Independent | +|---------|-------|---------|-------------| +| API Handler Refactor | 8 | +240 −120 | ✅ | +| Protocol Update | 5 | +130 −60 | ✅ | +| Driver Changes | 12 | +80 −30 | ❌ | + +
+ +
Code Health — 2 degraded + +**Degraded:** + +| File | Before | After | Delta | Grade | +|------|--------|-------|-------|-------| +| `api/handler.go` | 82 | 70 | -12 | B→B | +| `internal/query/engine.go` | 75 | 68 | -7 | B→C | + +**Improved:** 1 file(s) + +2 degraded · 1 improved · avg -4.7 + +
+ +**Estimated review:** ~95min (complex) + +**Reviewers:** @alice (85%) · @bob (45%) + + diff --git a/testdata/review/sarif.json b/testdata/review/sarif.json new file mode 100644 index 00000000..e312d50e --- /dev/null +++ b/testdata/review/sarif.json @@ -0,0 +1,278 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "runs": [ + { + "results": [ + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "api/handler.go" + }, + "region": { + "startLine": 42 + } + } + } + ], + "message": { + "text": "Removed public function HandleAuth()" + }, + "partialFingerprints": { + "ckb/v1": "240d8f11ef76fe7e" + }, + "ruleId": "ckb/breaking/removed-symbol" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "api/middleware.go" + }, + "region": { + "startLine": 15 + } + } + } + ], + "message": { + "text": "Changed signature of ValidateToken()" + }, + "partialFingerprints": { + "ckb/v1": "0af5741d1513e4ca" + }, + "ruleId": "ckb/breaking/changed-signature" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "drivers/hw/plc_comm.go" + }, + "region": { + "startLine": 78 + } + } + } + ], + "message": { + "text": "Safety-critical path changed (pattern: drivers/**)" + }, + "partialFingerprints": { + "ckb/v1": "3560de9d31495454" + }, + "relatedLocations": [ + { + "id": 1, + "message": { + "text": "Suggestion: Requires sign-off from safety team" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "drivers/hw/plc_comm.go" + } + } + } + ], + "ruleId": "ckb/critical/safety-path" + }, + { + "level": "error", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "protocol/modbus.go" + } + } + } + ], + "message": { + "text": "Safety-critical path changed (pattern: protocol/**)" + }, + "partialFingerprints": { + "ckb/v1": "4d1d167a0820404c" + }, + "relatedLocations": [ + { + "id": 1, + "message": { + "text": "Suggestion: Requires sign-off from safety team" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "protocol/modbus.go" + } + } + } + ], + "ruleId": "ckb/critical/safety-path" + }, + { + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "internal/query/engine.go" + }, + "region": { + "endLine": 210, + "startLine": 155 + } + } + } + ], + "message": { + "text": "Complexity 12→20 in parseQuery()" + }, + "partialFingerprints": { + "ckb/v1": "237a7a640d0c0d09" + }, + "relatedLocations": [ + { + "id": 1, + "message": { + "text": "Suggestion: Consider extracting helper functions" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "internal/query/engine.go" + } + } + } + ], + "ruleId": "ckb/complexity/increase" + }, + { + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "internal/query/engine.go" + } + } + } + ], + "message": { + "text": "Missing co-change: engine_test.go (87% co-change rate)" + }, + "partialFingerprints": { + "ckb/v1": "eab286fec52665b4" + }, + "ruleId": "ckb/coupling/missing-cochange" + }, + { + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "protocol/modbus.go" + } + } + } + ], + "message": { + "text": "Missing co-change: modbus_test.go (91% co-change rate)" + }, + "partialFingerprints": { + "ckb/v1": "5a14fe5e0d062660" + }, + "ruleId": "ckb/coupling/missing-cochange" + }, + { + "level": "note", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "config/settings.go" + } + } + } + ], + "message": { + "text": "Hotspot file (score: 0.78) — extra review attention recommended" + }, + "partialFingerprints": { + "ckb/v1": "949cc432e21fd92d" + }, + "ruleId": "ckb/hotspots/volatile-file" + } + ], + "tool": { + "driver": { + "informationUri": "https://github.com/SimplyLiz/CodeMCP", + "name": "CKB", + "rules": [ + { + "defaultConfiguration": { + "level": "error" + }, + "id": "ckb/breaking/changed-signature", + "shortDescription": { + "text": "ckb/breaking/changed-signature" + } + }, + { + "defaultConfiguration": { + "level": "error" + }, + "id": "ckb/breaking/removed-symbol", + "shortDescription": { + "text": "ckb/breaking/removed-symbol" + } + }, + { + "defaultConfiguration": { + "level": "warning" + }, + "id": "ckb/complexity/increase", + "shortDescription": { + "text": "ckb/complexity/increase" + } + }, + { + "defaultConfiguration": { + "level": "warning" + }, + "id": "ckb/coupling/missing-cochange", + "shortDescription": { + "text": "ckb/coupling/missing-cochange" + } + }, + { + "defaultConfiguration": { + "level": "error" + }, + "id": "ckb/critical/safety-path", + "shortDescription": { + "text": "ckb/critical/safety-path" + } + }, + { + "defaultConfiguration": { + "level": "note" + }, + "id": "ckb/hotspots/volatile-file", + "shortDescription": { + "text": "ckb/hotspots/volatile-file" + } + } + ], + "semanticVersion": "8.1.0", + "version": "8.1.0" + } + } + } + ], + "version": "2.1.0" +} \ No newline at end of file