Skip to content

CKB

CKB #352

Workflow file for this run

# ══════════════════════════════════════════════════════════════════════════════
# ██████╗██╗ ██╗██████╗ █████╗ ███╗ ██╗ █████╗ ██╗ ██╗ ██╗███████╗██╗███████╗
# ██╔════╝██║ ██╔╝██╔══██╗ ██╔══██╗████╗ ██║██╔══██╗██║ ╚██╗ ██╔╝██╔════╝██║██╔════╝
# ██║ █████╔╝ ██████╔╝ ███████║██╔██╗ ██║███████║██║ ╚████╔╝ ███████╗██║███████╗
# ██║ ██╔═██╗ ██╔══██╗ ██╔══██║██║╚██╗██║██╔══██║██║ ╚██╔╝ ╚════██║██║╚════██║
# ╚██████╗██║ ██╗██████╔╝ ██║ ██║██║ ╚████║██║ ██║███████╗██║ ███████║██║███████║
# ╚═════╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══════╝╚═╝╚══════╝
# ══════════════════════════════════════════════════════════════════════════════
# Code Intelligence for Pull Requests — Full Feature Showcase
#
# Features:
# • Risk Scoring — Quantified risk based on hotspots, modules, history
# • Blast Radius — Know exactly what breaks before merging
# • Complexity Gates — Block merges exceeding thresholds
# • Coupling Analysis — Warn when coupled files change independently
# • Contract Detection — Flag API/proto/GraphQL boundary changes
# • Dead Code — Identify unused code from telemetry
# • Ownership Drift — CODEOWNERS vs actual contributors
# • Suggested Reviewers — Based on real code ownership
# • Eval Suite — Regression testing for search quality
# • Language Quality — Per-language indexer quality metrics
#
# ══════════════════════════════════════════════════════════════════════════════
name: CKB
on:
pull_request:
branches: [develop, main, 'feature/**']
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
force:
description: 'Force full reindex'
type: boolean
default: false
concurrency:
group: ckb-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
env:
# ┌─────────────────────────────────────────────────────────────────────────┐
# │ THRESHOLDS │
# └─────────────────────────────────────────────────────────────────────────┘
COMPLEXITY_CYCLOMATIC: 15
COMPLEXITY_COGNITIVE: 20
COUPLING_MIN: 0.7
DOC_COVERAGE_MIN: 70
RISK_SCORE_MIN: 60
EVAL_PASS_RATE: 90
# Set to 'true' to block PRs with complexity violations
COMPLEXITY_GATE_ENABLED: 'false'
jobs:
# ╔═════════════════════════════════════════════════════════════════════════╗
# ║ PR ANALYSIS ║
# ╚═════════════════════════════════════════════════════════════════════════╝
analyze:
name: Analyze
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
outputs:
risk: ${{ steps.summary.outputs.risk }}
score: ${{ steps.summary.outputs.score }}
steps:
# ───────────────────────────────────────────────────────────────────────
# Setup
# ───────────────────────────────────────────────────────────────────────
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Build
run: go build -ldflags="-s -w" -o ckb ./cmd/ckb
- name: Install SCIP indexer
run: go install github.com/sourcegraph/scip-go/cmd/scip-go@latest
# ───────────────────────────────────────────────────────────────────────
# Cache & Index
# ───────────────────────────────────────────────────────────────────────
- name: Cache
id: cache
uses: actions/cache@v5
with:
path: .ckb/
key: ckb-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ github.base_ref }}
restore-keys: ckb-${{ runner.os }}-
- name: Index
id: index
run: |
START=$(date +%s)
./ckb init
if ! ./ckb index 2>&1 | tee /tmp/index.log; then
if grep -q "Indexer not found" /tmp/index.log; then
echo "::error::SCIP indexer not installed"
echo ""
echo "╔═══════════════════════════════════════════════════════════════════════════════╗"
echo "║ INDEXER NOT FOUND ║"
echo "╠═══════════════════════════════════════════════════════════════════════════════╣"
echo "║ Go: go install github.com/sourcegraph/scip-go/cmd/scip-go@latest ║"
echo "║ TypeScript: npm i -g @sourcegraph/scip-typescript ║"
echo "║ Python: pip install scip-python ║"
echo "║ Rust: cargo install scip ║"
echo "╚═══════════════════════════════════════════════════════════════════════════════╝"
exit 1
fi
echo "::warning::Indexing failed, continuing with fallback"
echo "mode=fallback" >> $GITHUB_OUTPUT
else
echo "mode=indexed" >> $GITHUB_OUTPUT
fi
echo "duration=$(($(date +%s)-START))s" >> $GITHUB_OUTPUT
# ───────────────────────────────────────────────────────────────────────
# Core Analysis
# ───────────────────────────────────────────────────────────────────────
- name: PR Summary
id: summary
run: |
./ckb pr-summary --base=origin/${{ github.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
# ───────────────────────────────────────────────────────────────────────
# Change Impact Analysis (v7.5) — Dogfooding our own tool
# ───────────────────────────────────────────────────────────────────────
- name: Impact Analysis
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
# Extract key metrics
echo "symbols_changed=$(jq '.summary.symbolsChanged // 0' impact.json)" >> $GITHUB_OUTPUT
echo "directly_affected=$(jq '.summary.directlyAffected // 0' impact.json)" >> $GITHUB_OUTPUT
echo "transitively_affected=$(jq '.summary.transitivelyAffected // 0' impact.json)" >> $GITHUB_OUTPUT
echo "estimated_risk=$(jq -r '.summary.estimatedRisk // "unknown"' impact.json)" >> $GITHUB_OUTPUT
echo "module_count=$(jq '.blastRadius.moduleCount // 0' impact.json)" >> $GITHUB_OUTPUT
echo "file_count=$(jq '.blastRadius.fileCount // 0' impact.json)" >> $GITHUB_OUTPUT
# Log summary for debugging
echo "::group::Impact Analysis Summary"
jq '.summary' impact.json
echo "::endgroup::"
# Warn on high-risk changes
RISK=$(jq -r '.summary.estimatedRisk // "unknown"' impact.json)
if [ "$RISK" = "critical" ]; then
echo "::error::CRITICAL RISK: This change has significant downstream impact"
elif [ "$RISK" = "high" ]; then
echo "::warning::HIGH RISK: This change affects many downstream symbols"
fi
- name: Post Impact Comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: ckb-impact
path: impact.md
- name: Complexity
id: complexity
run: |
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
[ -f "$f" ] || continue
r=$(./ckb complexity "$f" --format=json 2>/dev/null || echo '{}')
cy=$(echo "$r" | jq '.summary.maxCyclomatic // 0')
cg=$(echo "$r" | jq '.summary.maxCognitive // 0')
if [ "$cy" -gt ${{ env.COMPLEXITY_CYCLOMATIC }} ]; then
echo "::warning file=$f::Cyclomatic complexity $cy exceeds threshold ${{ env.COMPLEXITY_CYCLOMATIC }}"
VIOLATIONS=$((VIOLATIONS+1))
fi
if [ "$cg" -gt ${{ env.COMPLEXITY_COGNITIVE }} ]; then
echo "::warning file=$f::Cognitive complexity $cg exceeds threshold ${{ env.COMPLEXITY_COGNITIVE }}"
VIOLATIONS=$((VIOLATIONS+1))
fi
jq -n --arg f "$f" --argjson cy "$cy" --argjson cg "$cg" \
'{file:$f,cyclomatic:$cy,cognitive:$cg}' >> complexity.json.tmp
done
[ -f complexity.json.tmp ] && jq -s '.' complexity.json.tmp > complexity.json || echo '[]' > complexity.json
rm -f complexity.json.tmp
echo "violations=$VIOLATIONS" >> $GITHUB_OUTPUT
- name: Coupling
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)
echo '[]' > missing_coupled.json
for f in $(echo "$changed_files" | head -10); do
[ -f "$f" ] || continue
result=$(./ckb coupling "$f" --min-correlation=${{ env.COUPLING_MIN }} --format=json 2>/dev/null || echo '{}')
# Extract correlations and check if coupled files are in the PR
echo "$result" | jq -r --arg changed "$changed_files" --arg source "$f" '
.correlations // [] | .[] |
select(.correlation >= ${{ env.COUPLING_MIN }}) |
select(($changed | split("\n") | map(select(. != "")) | index(.file)) == null) |
{file: .file, coupledTo: $source, correlation: .correlation}
' 2>/dev/null >> missing_coupled.json.tmp || true
done
# Deduplicate and format final output
if [ -f missing_coupled.json.tmp ]; then
jq -s 'unique_by(.file) | {missingCoupled: .}' missing_coupled.json.tmp > coupling.json 2>/dev/null || echo '{"missingCoupled":[]}' > coupling.json
rm -f missing_coupled.json.tmp
else
echo '{"missingCoupled":[]}' > coupling.json
fi
echo "missing=$(jq '.missingCoupled | length' coupling.json)" >> $GITHUB_OUTPUT
- name: Contracts
id: contracts
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)
if [ -n "$contracts" ]; then
# List contract files - breaking change detection not available in CLI
echo "$contracts" | jq -R -s 'split("\n") | map(select(length > 0)) | {files: ., breaking: []}' > contracts.json
echo "::warning::Contract files changed: $(echo "$contracts" | tr '\n' ' ')"
fi
echo "count=$(jq '.files | length' contracts.json)" >> $GITHUB_OUTPUT
echo "breaking=$(jq '.breaking | length' contracts.json)" >> $GITHUB_OUTPUT
- name: Blast Radius
id: blast
run: |
# Derive blast radius from pr-summary changed files
# Note: v7.6+ provides per-symbol blast radius via `ckb impact <symbol-id>`
# TODO: Add --include-blast-radius to pr-summary for aggregate PR-level data
if [ -f analysis.json ]; then
jq '{
affectedSymbols: (.changedFiles // []) | map(.symbols // []) | flatten,
affectedTests: (.changedFiles // []) | map(select(.path | test("_test\\.go$|test_|\\.test\\."))) | map(.path),
affectedConsumers: []
}' analysis.json > blast.json 2>/dev/null || echo '{"affectedSymbols":[],"affectedTests":[],"affectedConsumers":[]}' > blast.json
else
echo '{"affectedSymbols":[],"affectedTests":[],"affectedConsumers":[]}' > blast.json
fi
echo "symbols=$(jq '.affectedSymbols | length' blast.json)" >> $GITHUB_OUTPUT
echo "tests=$(jq '.affectedTests | length' blast.json)" >> $GITHUB_OUTPUT
echo "consumers=$(jq '.affectedConsumers | length' blast.json)" >> $GITHUB_OUTPUT
- name: Audit
id: audit
run: |
./ckb audit --min-score=${{ env.RISK_SCORE_MIN }} --limit=10 --quick-wins --format=json > audit.json 2>/dev/null || echo '{"items":[],"quickWins":[],"summary":{}}' > audit.json
echo "critical=$(jq '.summary.critical // 0' audit.json)" >> $GITHUB_OUTPUT
echo "high=$(jq '.summary.high // 0' audit.json)" >> $GITHUB_OUTPUT
- name: Dead Code
run: |
./ckb dead-code --min-confidence=0.9 --limit=10 --format=json > deadcode.json 2>/dev/null || echo '{"candidates":[]}' > deadcode.json
- name: Telemetry Dead Code
id: telemetry_dead
run: |
# Check if telemetry is configured (optional feature)
if ./ckb telemetry status 2>/dev/null | grep -q "connected"; then
./ckb dead-code --source=telemetry --days=30 --limit=10 --format=json > telemetry-deadcode.json 2>/dev/null || echo '{"candidates":[],"source":"telemetry"}' > telemetry-deadcode.json
echo "enabled=true" >> $GITHUB_OUTPUT
echo "count=$(jq '.candidates | length // 0' telemetry-deadcode.json)" >> $GITHUB_OUTPUT
else
echo '{"candidates":[],"source":"telemetry","coverage":{}}' > telemetry-deadcode.json
echo "enabled=false" >> $GITHUB_OUTPUT
echo "count=0" >> $GITHUB_OUTPUT
fi
- name: Docs
id: docs
run: |
./ckb docs index 2>/dev/null || true
./ckb docs coverage --format=json > docs-coverage.json 2>/dev/null || echo '{"coverage":0}' > docs-coverage.json
./ckb docs stale --all --format=json > docs-stale.json 2>/dev/null || echo '{"totalStale":0,"reports":[]}' > docs-stale.json
echo "coverage=$(jq '.coveragePercent // 0' docs-coverage.json)" >> $GITHUB_OUTPUT
echo "stale=$(jq '.totalStale // 0' docs-stale.json)" >> $GITHUB_OUTPUT
- name: Drift
run: |
./ckb ownership drift --threshold=0.3 --limit=10 --format=json > drift.json 2>/dev/null || echo '{"driftedFiles":[]}' > drift.json
- name: Affected Tests
id: affected_tests
run: |
RANGE="origin/${{ github.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
echo "strategy=$(jq -r '.strategy // "none"' affected-tests.json)" >> $GITHUB_OUTPUT
- name: Languages
id: languages
run: |
# Use doctor to check actual indexer status
./ckb doctor --format=json > doctor.json 2>/dev/null || echo '{"languages":[]}' > doctor.json
# Derive quality from doctor output
jq '{
languages: [(.languages // [])[] | {
name,
quality: (if .status == "ready" then 1 else if .status == "partial" then 0.5 else 0 end end),
tier: (.tier // "unknown"),
status: (.status // "unknown"),
issues: (.issues // [])
}],
overallQuality: (
[(.languages // [])[] | if .status == "ready" then 1 else 0 end] |
if length > 0 then add / length else 1 end
)
}' doctor.json > languages.json 2>/dev/null || echo '{"languages":[],"overallQuality":1}' > languages.json
echo "quality=$(jq '.overallQuality // 1' languages.json)" >> $GITHUB_OUTPUT
echo "lowQuality=$(jq '[.languages[] | select(.quality < 0.7)] | length' languages.json)" >> $GITHUB_OUTPUT
- name: Eval
id: eval
run: |
if [ -d ".ckb/fixtures" ]; then
./ckb eval --fixtures=.ckb/fixtures --format=json > eval.json 2>/dev/null || echo '{"passedTests":0,"totalTests":0,"results":[]}' > eval.json
echo "skipped=false" >> $GITHUB_OUTPUT
else
echo '{"passedTests":0,"totalTests":0,"results":[],"skipped":true}' > eval.json
echo "skipped=true" >> $GITHUB_OUTPUT
fi
echo "passed=$(jq '.passedTests // 0' eval.json)" >> $GITHUB_OUTPUT
echo "total=$(jq '.totalTests // 0' eval.json)" >> $GITHUB_OUTPUT
# ───────────────────────────────────────────────────────────────────────
# Quality Gates
# ───────────────────────────────────────────────────────────────────────
- name: Complexity Gate
if: env.COMPLEXITY_GATE_ENABLED == 'true' && steps.complexity.outputs.violations > 0
run: |
echo "::error::❌ Complexity gate failed: ${{ steps.complexity.outputs.violations }} violations"
echo ""
echo "Files exceeding thresholds:"
jq -r '.[] | select(.cyclomatic > ${{ env.COMPLEXITY_CYCLOMATIC }} or .cognitive > ${{ env.COMPLEXITY_COGNITIVE }}) | " \(.file): cyclomatic=\(.cyclomatic), cognitive=\(.cognitive)"' complexity.json
exit 1
# ───────────────────────────────────────────────────────────────────────
# Report
# ───────────────────────────────────────────────────────────────────────
- name: Comment
if: always()
uses: actions/github-script@v8
env:
CACHE_HIT: ${{ steps.cache.outputs.cache-hit }}
INDEX_MODE: ${{ steps.index.outputs.mode }}
INDEX_TIME: ${{ steps.index.outputs.duration }}
COMPLEXITY_CYCLOMATIC: ${{ env.COMPLEXITY_CYCLOMATIC }}
COMPLEXITY_COGNITIVE: ${{ env.COMPLEXITY_COGNITIVE }}
DOC_COVERAGE_MIN: ${{ env.DOC_COVERAGE_MIN }}
EVAL_PASS_RATE: ${{ env.EVAL_PASS_RATE }}
with:
script: |
const fs = require('fs');
const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f)); } catch { return d; } };
// Thresholds
const TH_CY = parseInt(process.env.COMPLEXITY_CYCLOMATIC || '15');
const TH_CG = parseInt(process.env.COMPLEXITY_COGNITIVE || '20');
const TH_DOC = parseInt(process.env.DOC_COVERAGE_MIN || '70');
const TH_EVAL = parseInt(process.env.EVAL_PASS_RATE || '90');
// Load all analysis data
const pr = read('analysis.json', {});
const impact = read('impact.json', { summary: {}, changedSymbols: [], affectedSymbols: [], blastRadius: {}, recommendations: [] });
const complexity = read('complexity.json', []);
const coupling = read('coupling.json', { missingCoupled: [] });
const contracts = read('contracts.json', { files: [], breaking: [] });
const blast = read('blast.json', { affectedSymbols: [], affectedTests: [], affectedConsumers: [] });
const audit = read('audit.json', { items: [], quickWins: [], summary: {} });
const deadcode = read('deadcode.json', { candidates: [] });
const telemetryDead = read('telemetry-deadcode.json', { candidates: [], source: 'telemetry' });
const docsCov = read('docs-coverage.json', { coverage: 0 });
const docsStale = read('docs-stale.json', { totalStale: 0, reports: [] });
const drift = read('drift.json', []);
const languages = read('languages.json', { languages: [], overallQuality: 1 });
const evalResults = read('eval.json', { passed: 0, total: 0, results: [], skipped: true });
const affectedTests = read('affected-tests.json', { tests: [], strategy: 'none' });
// Extract data
const s = pr.summary || {};
const risk = pr.riskAssessment || {};
const reviewers = pr.suggestedReviewers || [];
const modules = pr.modulesAffected || [];
const hotspots = (pr.changedFiles || []).filter(f => f.isHotspot);
const breakingChanges = Array.isArray(contracts.breaking) ? contracts.breaking : [];
const blastSymbols = blast.affectedSymbols || [];
const blastTests = blast.affectedTests || [];
const blastConsumers = blast.affectedConsumers || [];
const lowQualityLangs = (languages.languages || []).filter(l => (l.quality || 1) < 0.7);
// Impact analysis data
const impactSummary = impact.summary || {};
const changedSymbols = impact.changedSymbols || [];
const affectedSymbols = impact.affectedSymbols || [];
const impactBlast = impact.blastRadius || {};
const impactRecs = impact.recommendations || [];
const impactRisk = impactSummary.estimatedRisk || 'unknown';
// Computed
const complexViolations = complexity.filter(c => c.cyclomatic > TH_CY || c.cognitive > TH_CG);
const criticalItems = (audit.items || []).filter(i => i.riskLevel === 'critical');
const highItems = (audit.items || []).filter(i => i.riskLevel === 'high');
const riskyModules = modules.filter(m => m.riskLevel === 'high' || m.riskLevel === 'medium');
// Helpers
const pct = v => Math.round((v || 0) * 100);
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const sha = context.payload.pull_request?.head?.sha || context.sha;
const fileLink = (path, line) => `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${sha}/${path}${line ? '#L' + line : ''}`;
const linkedFile = (path, line) => `[\`${path}${line ? ':' + line : ''}\`](${fileLink(path, line)})`;
// Risk styling
const riskStyle = {
high: { color: 'e74c3c', label: 'HIGH' },
medium: { color: 'f39c12', label: 'MEDIUM' },
low: { color: '27ae60', label: 'LOW' }
}[risk.level] || { color: '95a5a6', label: 'UNKNOWN' };
// ═══════════════════════════════════════════════════════════════════
// Build Comment
// ═══════════════════════════════════════════════════════════════════
let c = [];
// Header
c.push('<!-- ckb -->');
c.push('');
c.push('## CKB Analysis');
c.push('');
// Badges (cap risk score at 100%)
const riskPct = Math.min(pct(risk.score), 100);
c.push(`[![Risk](https://img.shields.io/badge/${riskStyle.label}-${riskPct}%25-${riskStyle.color}?style=for-the-badge)](${runUrl}) ` +
`![Files](https://img.shields.io/badge/Files-${s.totalFiles || 0}-3498db?style=flat-square) ` +
`![+${s.totalAdditions || 0}](https://img.shields.io/badge/%2B${s.totalAdditions || 0}-2ecc71?style=flat-square) ` +
`![-${s.totalDeletions || 0}](https://img.shields.io/badge/−${s.totalDeletions || 0}-e74c3c?style=flat-square) ` +
`![Modules](https://img.shields.io/badge/Modules-${s.totalModules || 0}-3498db?style=flat-square)`);
c.push('');
// Quick Stats Line
const stats = [];
if (impactSummary.symbolsChanged) stats.push(`🎯 ${impactSummary.symbolsChanged} changed → ${impactSummary.transitivelyAffected || 0} affected`);
if (hotspots.length) stats.push(`🔥 ${hotspots.length} hotspot${hotspots.length > 1 ? 's' : ''}`);
if (criticalItems.length + highItems.length) stats.push(`⚠️ ${criticalItems.length + highItems.length} risk`);
if (complexViolations.length) stats.push(`📊 ${complexViolations.length} complex`);
if (coupling.missingCoupled?.length) stats.push(`🔗 ${coupling.missingCoupled.length} coupled`);
if (breakingChanges.length) stats.push(`💥 ${breakingChanges.length} breaking`);
if (blastSymbols.length + blastTests.length) stats.push(`💣 ${blastSymbols.length + blastTests.length} blast`);
if (contracts.files?.length) stats.push(`📜 ${contracts.files.length} contract${contracts.files.length > 1 ? 's' : ''}`);
if (docsStale.totalStale) stats.push(`📚 ${docsStale.totalStale} stale`);
if (deadcode.candidates?.length) stats.push(`💀 ${deadcode.candidates.length} dead`);
if (telemetryDead.candidates?.length) stats.push(`📡 ${telemetryDead.candidates.length} telemetry`);
if (affectedTests.tests?.length) stats.push(`🧪 ${affectedTests.tests.length} tests`);
if (lowQualityLangs.length) stats.push(`🌐 ${lowQualityLangs.length} lang`);
if (stats.length) {
c.push(`> ${stats.join(' · ')}`);
c.push('');
}
// Risk Factors
if (risk.factors?.length) {
c.push('**Risk factors:** ' + risk.factors.slice(0, 3).join(' • '));
c.push('');
}
// Suggested Reviewers
if (reviewers.length) {
const list = reviewers.slice(0, 3).map(r => `**${r.owner.replace(/^@?/, '@')}** (${pct(r.coverage)}%)`).join(', ');
c.push(`👥 Suggested: ${list}`);
c.push('');
}
// Metrics Table
const impactIcon = { critical: '🔴', high: '🟠', medium: '🟡', low: '🟢' }[impactRisk] || '⚪';
c.push('| Metric | Value | |');
c.push('|:-------|------:|:-:|');
c.push(`| Impact Analysis | ${impactSummary.symbolsChanged || 0} symbols → ${impactSummary.transitivelyAffected || 0} affected | ${impactIcon} |`);
c.push(`| Doc Coverage | ${docsCov.coveragePercent || docsCov.coverage || 0}% | ${(docsCov.coveragePercent || docsCov.coverage || 0) >= TH_DOC ? '✅' : '⚠️'} |`);
c.push(`| Complexity | ${complexViolations.length} violations | ${complexViolations.length === 0 ? '✅' : '⚠️'} |`);
c.push(`| Coupling | ${coupling.missingCoupled?.length || 0} gaps | ${!coupling.missingCoupled?.length ? '✅' : '⚠️'} |`);
c.push(`| Blast Radius | ${impactBlast.moduleCount || 0} modules, ${impactBlast.fileCount || 0} files | ${(impactBlast.moduleCount || 0) <= 2 ? '✅' : '⚠️'} |`);
c.push(`| Index | [${process.env.INDEX_MODE || 'unknown'} (${process.env.INDEX_TIME || '?'})](${runUrl}) | ${process.env.CACHE_HIT === 'true' ? '💾' : '🆕'} |`);
c.push('');
// ═══════════════════════════════════════════════════════════════════
// Collapsible Sections
// ═══════════════════════════════════════════════════════════════════
// 💥 Breaking Changes (open by default)
if (breakingChanges.length > 0) {
c.push('<details open>');
c.push(`<summary>💥 Breaking changes · ${breakingChanges.length} detected</summary>`);
c.push('');
c.push('| Symbol | Change |');
c.push('|:-------|:-------|');
breakingChanges.slice(0, 8).forEach(b => {
c.push(`| \`${b.symbol || b.name || '?'}\` | ${b.change || b.description || '?'} |`);
});
if (breakingChanges.length > 8) c.push(`| … | +${breakingChanges.length - 8} more |`);
c.push('');
c.push('</details>');
c.push('');
}
// 🎯 Change Impact Analysis (v7.5 - dogfooding)
if (changedSymbols.length > 0 || affectedSymbols.length > 0) {
const isOpen = impactRisk === 'high' || impactRisk === 'critical';
c.push(`<details${isOpen ? ' open' : ''}>`);
c.push(`<summary>🎯 Change Impact Analysis · ${impactIcon} ${impactRisk.toUpperCase()} · ${changedSymbols.length} changed → ${affectedSymbols.length} affected</summary>`);
c.push('');
// Summary stats
c.push('| Metric | Value |');
c.push('|:-------|------:|');
c.push(`| Symbols Changed | ${impactSummary.symbolsChanged || 0} |`);
c.push(`| Directly Affected | ${impactSummary.directlyAffected || 0} |`);
c.push(`| Transitively Affected | ${impactSummary.transitivelyAffected || 0} |`);
c.push(`| Modules in Blast Radius | ${impactBlast.moduleCount || 0} |`);
c.push(`| Files in Blast Radius | ${impactBlast.fileCount || 0} |`);
c.push('');
// Changed symbols (what was modified)
if (changedSymbols.length > 0) {
c.push('**Symbols changed in this PR:**');
changedSymbols.slice(0, 10).forEach(sym => {
const conf = sym.confidence ? ` (${pct(sym.confidence)}%)` : '';
const change = sym.changeType ? ` [${sym.changeType}]` : '';
c.push(`- \`${sym.name || sym.symbolId || '?'}\`${change}${conf} — ${linkedFile(sym.file)}`);
});
if (changedSymbols.length > 10) c.push(`- … and ${changedSymbols.length - 10} more`);
c.push('');
}
// Affected symbols (downstream impact)
if (affectedSymbols.length > 0) {
c.push('**Downstream symbols affected:**');
const directCallers = affectedSymbols.filter(s => s.kind === 'direct-caller' || s.distance === 1);
const transitiveCallers = affectedSymbols.filter(s => s.kind === 'transitive-caller' || (s.distance && s.distance > 1));
if (directCallers.length > 0) {
c.push(`*Direct callers (${directCallers.length}):*`);
directCallers.slice(0, 5).forEach(sym => {
const mod = sym.moduleId ? ` in \`${sym.moduleId}\`` : '';
c.push(`- \`${sym.name || sym.stableId || '?'}\`${mod}`);
});
if (directCallers.length > 5) c.push(`- … and ${directCallers.length - 5} more direct callers`);
}
if (transitiveCallers.length > 0) {
c.push(`*Transitive callers (${transitiveCallers.length}):*`);
transitiveCallers.slice(0, 5).forEach(sym => {
const dist = sym.distance ? ` (depth ${sym.distance})` : '';
c.push(`- \`${sym.name || sym.stableId || '?'}\`${dist}`);
});
if (transitiveCallers.length > 5) c.push(`- … and ${transitiveCallers.length - 5} more transitive callers`);
}
c.push('');
}
// Recommendations
if (impactRecs.length > 0) {
c.push('**Recommendations:**');
impactRecs.forEach(rec => {
const icon = rec.severity === 'warning' ? '⚠️' : rec.severity === 'error' ? '🔴' : 'ℹ️';
c.push(`- ${icon} ${rec.message}`);
if (rec.action) c.push(` - *Action:* ${rec.action}`);
});
c.push('');
}
// Index staleness warning
if (impact.indexStaleness?.isStale) {
const stale = impact.indexStaleness;
c.push(`> ⚠️ **Index is ${stale.commitsBehind} commit(s) behind HEAD.** Results may be incomplete. Run \`ckb index\` to refresh.`);
c.push('');
}
c.push('</details>');
c.push('');
}
// 💣 Blast Radius
if (blastSymbols.length > 0 || blastTests.length > 0 || blastConsumers.length > 0) {
c.push('<details open>');
c.push(`<summary>💣 Blast radius · ${blastSymbols.length} symbols · ${blastTests.length} tests · ${blastConsumers.length} consumers</summary>`);
c.push('');
if (blastSymbols.length > 0) {
c.push('**Affected symbols:**');
blastSymbols.slice(0, 8).forEach(sym => c.push(`- \`${typeof sym === 'string' ? sym : sym.name || sym.symbol || '?'}\``));
if (blastSymbols.length > 8) c.push(`- … and ${blastSymbols.length - 8} more`);
c.push('');
}
if (blastTests.length > 0) {
c.push('**Tests that may break:**');
blastTests.slice(0, 5).forEach(t => c.push(`- \`${typeof t === 'string' ? t : t.name || t.test || '?'}\``));
if (blastTests.length > 5) c.push(`- … and ${blastTests.length - 5} more`);
c.push('');
}
if (blastConsumers.length > 0) {
c.push('**Downstream consumers:**');
blastConsumers.slice(0, 5).forEach(con => c.push(`- \`${typeof con === 'string' ? con : con.name || con.repo || '?'}\``));
if (blastConsumers.length > 5) c.push(`- … and ${blastConsumers.length - 5} more`);
c.push('');
}
c.push('</details>');
c.push('');
}
// ⚠️ Risk Audit
if (criticalItems.length + highItems.length > 0) {
c.push('<details>');
c.push(`<summary>⚠️ Risk audit · ${criticalItems.length} critical · ${highItems.length} high</summary>`);
c.push('');
c.push('| | File | Score | Factor |');
c.push('|:-:|:-----|------:|:-------|');
[...criticalItems, ...highItems].slice(0, 8).forEach(item => {
const icon = item.riskLevel === 'critical' ? '🔴' : '🟠';
const factor = (item.factors || [])[0]?.factor || '—';
c.push(`| ${icon} | ${linkedFile(item.file)} | ${item.riskScore} | ${factor} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 🔥 Hotspots
if (hotspots.length > 0) {
c.push('<details>');
c.push(`<summary>🔥 Hotspots · ${hotspots.length} volatile files</summary>`);
c.push('');
c.push('| File | Churn Score |');
c.push('|:-----|------------:|');
hotspots.slice(0, 8).forEach(f => {
c.push(`| ${linkedFile(f.path)} | ${(f.hotspotScore || 0).toFixed(2)} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 📦 Modules
if (riskyModules.length > 0) {
c.push('<details>');
c.push(`<summary>📦 Modules · ${riskyModules.length} at risk</summary>`);
c.push('');
c.push('| | Module | Files |');
c.push('|:-:|:-------|------:|');
riskyModules.slice(0, 6).forEach(m => {
const icon = m.riskLevel === 'high' ? '🔴' : '🟡';
c.push(`| ${icon} | \`${m.moduleId}\` | ${m.filesChanged} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 📜 Contracts
if (contracts.files?.length > 0) {
c.push('<details>');
c.push(`<summary>📜 Contracts · ${contracts.files.length} changed</summary>`);
c.push('');
contracts.files.slice(0, 10).forEach(f => c.push(`- ${linkedFile(f)}`));
c.push('');
c.push('</details>');
c.push('');
}
// 📊 Complexity
if (complexViolations.length > 0) {
c.push('<details>');
c.push(`<summary>📊 Complexity · ${complexViolations.length} violations</summary>`);
c.push('');
c.push('| File | Cyclomatic | Cognitive |');
c.push('|:-----|----------:|----------:|');
complexViolations.slice(0, 8).forEach(v => {
const cyWarn = v.cyclomatic > TH_CY ? '⚠️ ' : '';
const cgWarn = v.cognitive > TH_CG ? '⚠️ ' : '';
c.push(`| ${linkedFile(v.file)} | ${cyWarn}${v.cyclomatic} | ${cgWarn}${v.cognitive} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 🔗 Coupling
if (coupling.missingCoupled?.length > 0) {
c.push('<details>');
c.push(`<summary>🔗 Coupling · ${coupling.missingCoupled.length} missing files</summary>`);
c.push('');
c.push('| Missing File | Usually Changed With | Correlation |');
c.push('|:-------------|:---------------------|------------:|');
coupling.missingCoupled.slice(0, 8).forEach(w => {
c.push(`| ${linkedFile(w.file)} | ${linkedFile(w.coupledTo)} | ${pct(w.correlation || w.couplingScore || 0)}% |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 💡 Quick Wins
if (audit.quickWins?.length > 0) {
c.push('<details>');
c.push(`<summary>💡 Quick wins · ${audit.quickWins.length} suggestions</summary>`);
c.push('');
audit.quickWins.slice(0, 8).forEach(w => {
const e = { low: '🟢', medium: '🟡', high: '🔴' }[w.effort] || '⚪';
c.push(`- ${e} **${w.action}** → ${linkedFile(w.target)}`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 👤 Ownership Drift
const driftedFiles = drift.driftedFiles || [];
if (driftedFiles.length > 0) {
c.push('<details>');
c.push(`<summary>👤 Ownership drift · ${driftedFiles.length} files</summary>`);
c.push('');
c.push('| File | CODEOWNERS | Actual Top Contributor |');
c.push('|:-----|:-----------|:-----------------------|');
driftedFiles.slice(0, 8).forEach(d => {
c.push(`| ${linkedFile(d.path || d.file)} | ${d.declaredOwner || d.codeowner || '—'} | ${d.actualOwner || d.topContributor || '—'} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 💀 Dead Code
if (deadcode.candidates?.length > 0) {
c.push('<details>');
c.push(`<summary>💀 Dead code · ${deadcode.candidates.length} candidates</summary>`);
c.push('');
c.push('| Symbol | Confidence | Last Used |');
c.push('|:-------|:-----------|:----------|');
deadcode.candidates.slice(0, 8).forEach(d => {
c.push(`| \`${d.name}\` | ${pct(d.confidence || 0)}% | ${d.lastUsed || '—'} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 📡 Telemetry Dead Code
if (telemetryDead.candidates?.length > 0) {
c.push('<details>');
c.push(`<summary>📡 Telemetry dead code · ${telemetryDead.candidates.length} never called in production</summary>`);
c.push('');
c.push('| Symbol | File | Last Called | Days Since |');
c.push('|:-------|:-----|:------------|:-----------|');
telemetryDead.candidates.slice(0, 8).forEach(d => {
c.push(`| \`${d.symbol || d.name}\` | ${linkedFile(d.file || '—')} | ${d.lastCalled || 'Never'} | ${d.daysSinceCall || '∞'} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 🧪 Affected Tests
if (affectedTests.tests?.length > 0) {
c.push('<details>');
c.push(`<summary>🧪 Affected tests · ${affectedTests.tests.length} tests (${affectedTests.strategy || 'unknown'})</summary>`);
c.push('');
c.push('| Test | Reason |');
c.push('|:-----|:-------|');
affectedTests.tests.slice(0, 10).forEach(t => {
const name = typeof t === 'string' ? t : (t.file || t.name || t.test || '?');
const reason = typeof t === 'object' ? (t.reason || '—') : '—';
c.push(`| \`${name}\` | ${reason} |`);
});
if (affectedTests.tests.length > 10) {
c.push(`| … | +${affectedTests.tests.length - 10} more |`);
}
c.push('');
c.push('</details>');
c.push('');
}
// 📚 Stale Docs
if (docsStale.totalStale > 0) {
c.push('<details>');
c.push(`<summary>📚 Stale docs · ${docsStale.totalStale} broken references</summary>`);
c.push('');
(docsStale.reports || []).slice(0, 5).forEach(r => {
(r.stale || []).slice(0, 3).forEach(s => {
c.push(`- ${linkedFile(r.docPath, s.line)} → \`${s.rawText}\``);
});
});
c.push('');
c.push('</details>');
c.push('');
}
// 🌐 Language Quality
if (lowQualityLangs.length > 0) {
c.push('<details>');
c.push(`<summary>🌐 Language quality · ${lowQualityLangs.length} below threshold</summary>`);
c.push('');
c.push('| Language | Quality | Tier | Issues |');
c.push('|:---------|--------:|:-----|:-------|');
lowQualityLangs.slice(0, 6).forEach(l => {
const issues = (l.issues || []).slice(0, 2).join(', ') || '—';
c.push(`| ${l.name} | ${pct(l.quality)}% | ${l.tier || '?'} | ${issues} |`);
});
c.push('');
c.push('</details>');
c.push('');
}
// 🧪 Eval Suite
const failed = (evalResults.results || evalResults.failedTests || []).filter(r => !r.passed);
if (!evalResults.skipped && (evalResults.totalTests || evalResults.total) > 0) {
const evalPassed = evalResults.passedTests || evalResults.passed || 0;
const evalTotal = evalResults.totalTests || evalResults.total || 0;
const evalPct = Math.round((evalPassed / evalTotal) * 100);
const evalIcon = evalPct >= TH_EVAL ? '✅' : '⚠️';
c.push('<details>');
c.push(`<summary>🧪 Eval suite · ${evalIcon} ${evalPassed}/${evalTotal} passed (${evalPct}%)</summary>`);
c.push('');
if (failed.length > 0) {
c.push('**Failed tests:**');
const showCount = Math.min(failed.length, 15);
failed.slice(0, showCount).forEach(r => {
c.push(`- \`${r.id || r.name}\`${r.reason ? ` — ${r.reason}` : ''}`);
});
if (failed.length > showCount) {
c.push(`- … and ${failed.length - showCount} more → [Run Summary](${runUrl})`);
}
c.push('');
}
c.push('</details>');
c.push('');
}
// Footer
c.push('---');
c.push(`<sub>Generated by <a href="https://github.com/SimplyLiz/CodeMCP">CKB</a> · <a href="${runUrl}">Run details</a></sub>`);
// ═══════════════════════════════════════════════════════════════════
// Post Comment
// ═══════════════════════════════════════════════════════════════════
let body = c.join('\n');
// Hard cap at 65k
if (body.length > 65000) {
body = body.slice(0, 64800) + `\n\n---\n<sub>✂️ Truncated. <a href="${runUrl}">Full report</a></sub>`;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const existing = comments.find(comment =>
comment.user?.type === 'Bot' && comment.body?.includes('<!-- ckb -->')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
console.log(`Updated comment ${existing.id}`);
} else {
const { data: newComment } = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
console.log(`Created comment ${newComment.id}`);
}
// Write full failed tests to Step Summary if > 15
if (failed.length > 15) {
let summary = `## 🧪 Failed Eval Tests (${failed.length})\n\n`;
summary += failed.map(r => `- \`${r.id || r.name}\`${r.reason ? ` — ${r.reason}` : ''}`).join('\n');
await core.summary.addRaw(summary + '\n', true).write();
}
# ───────────────────────────────────────────────────────────────────────
# Auto-Assign Reviewers
# ───────────────────────────────────────────────────────────────────────
- name: Reviewers
if: always()
continue-on-error: true
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
try {
const { suggestedReviewers = [] } = JSON.parse(fs.readFileSync('analysis.json'));
const reviewers = suggestedReviewers
.map(r => r.owner.replace(/^@/, ''))
.filter(r => !r.includes('/') && r !== context.actor)
.slice(0, 2);
if (reviewers.length) {
await github.rest.pulls.requestReviewers({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
reviewers
});
}
} catch {}
# ───────────────────────────────────────────────────────────────────────
# Artifacts
# ───────────────────────────────────────────────────────────────────────
- name: Save Cache
if: always()
uses: actions/cache/save@v5
with:
path: .ckb/
key: ckb-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ github.base_ref }}
- name: Upload
if: always()
uses: actions/upload-artifact@v6
with:
name: ckb-analysis
path: '*.json'
retention-days: 7
# ╔═════════════════════════════════════════════════════════════════════════╗
# ║ SCHEDULED REFRESH ║
# ╚═════════════════════════════════════════════════════════════════════════╝
refresh:
name: Refresh
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Build
run: go build -ldflags="-s -w" -o ckb ./cmd/ckb
- name: Install SCIP indexer
run: go install github.com/sourcegraph/scip-go/cmd/scip-go@latest
- name: Cache
uses: actions/cache@v5
with:
path: .ckb/
key: ckb-${{ runner.os }}-refresh-${{ github.run_id }}
restore-keys: ckb-${{ runner.os }}-
- name: Refresh
run: |
./ckb init
if ! ./ckb index --force 2>&1 | tee /tmp/index.log; then
if grep -q "Indexer not found" /tmp/index.log; then
echo "::error::SCIP indexer not installed"
exit 1
fi
echo "::warning::Indexing failed"
fi
mkdir -p reports
./ckb hotspots --limit=50 --format=json > reports/hotspots.json 2>/dev/null || echo '[]' > reports/hotspots.json
./ckb audit --min-score=40 --limit=50 --format=json > reports/audit.json 2>/dev/null || echo '{}' > reports/audit.json
./ckb ownership drift --format=json > reports/drift.json 2>/dev/null || echo '[]' > reports/drift.json
./ckb doctor --format=json > reports/doctor.json 2>/dev/null || echo '{"languages":[]}' > reports/doctor.json
jq '{
languages: [(.languages // [])[] | {name, quality: (if .status == "ready" then 1 else 0.5 end), tier, status}],
overallQuality: ([(.languages // [])[] | if .status == "ready" then 1 else 0 end] | if length > 0 then add / length else 1 end)
}' reports/doctor.json > reports/languages.json 2>/dev/null || echo '{"languages":[],"overallQuality":1}' > reports/languages.json
./ckb dead-code --min-confidence=0.8 --limit=20 --format=json > reports/deadcode.json 2>/dev/null || echo '{"candidates":[]}' > reports/deadcode.json
- name: Summary
run: |
echo "## 📊 CKB Nightly Refresh" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
echo "|--------|------:|" >> $GITHUB_STEP_SUMMARY
echo "| Hotspots | $(jq 'if type == "array" then length else 0 end' reports/hotspots.json) |" >> $GITHUB_STEP_SUMMARY
echo "| Critical Risk | $(jq '.summary.critical // 0' reports/audit.json) |" >> $GITHUB_STEP_SUMMARY
echo "| High Risk | $(jq '.summary.high // 0' reports/audit.json) |" >> $GITHUB_STEP_SUMMARY
echo "| Ownership Drift | $(jq 'if type == "array" then length else 0 end' reports/drift.json) |" >> $GITHUB_STEP_SUMMARY
echo "| Dead Code | $(jq '.candidates | if type == "array" then length else 0 end' reports/deadcode.json) |" >> $GITHUB_STEP_SUMMARY
echo "| Language Quality | $(jq '.overallQuality * 100 | floor' reports/languages.json)% |" >> $GITHUB_STEP_SUMMARY
- name: Upload
uses: actions/upload-artifact@v6
with:
name: ckb-refresh
path: reports/
retention-days: 30