diff --git a/.copilot/skills/pr-lifecycle/SKILL.md b/.copilot/skills/pr-lifecycle/SKILL.md index 6716ee62d..50456644e 100644 --- a/.copilot/skills/pr-lifecycle/SKILL.md +++ b/.copilot/skills/pr-lifecycle/SKILL.md @@ -3,7 +3,7 @@ name: "pr-lifecycle" description: "Complete issue → PR → merge lifecycle with readiness checks" domain: "workflow" confidence: "high" -source: "extracted from pr-readiness.mjs, CONTRIBUTING.md, PR_REQUIREMENTS.md, squad-ci.yml" +source: "extracted from CONTRIBUTING.md, PR_REQUIREMENTS.md, squad-ci.yml" --- ## Context @@ -419,7 +419,7 @@ Adds profile resolution API to the SDK. ## Readiness Check Gaps & Recommendations -After analyzing `scripts/pr-readiness.mjs`, `.github/workflows/squad-ci.yml`, and `.github/workflows/squad-repo-health.yml`, three gaps were identified. Gaps 1 and 3 are now implemented (checks 10 and 11). Gap 2 is deferred. +After analyzing `.github/workflows/squad-ci.yml`, three gaps were identified. Gaps 1 and 3 are now implemented (checks 10 and 11). Gap 2 is deferred. ### Gap 1: Issue Linkage Check (Check 10) — IMPLEMENTED diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a99f6ced6..5271c6c53 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -146,10 +146,6 @@ Any PR that modifies files under `packages/squad-cli/src/` or `packages/squad-sd - The `changelog-gate` CI check will fail without this - Escape hatch: add the `skip-changelog` label (use sparingly) -## Automated PR Nudge - -The **PR Nudge** workflow (`.github/workflows/squad-pr-nudge.yml`) runs on weekdays at 2pm UTC and posts actionable comments on open PRs that have been stale for 7+ days. It diagnoses specific blockers — failing CI checks, unresolved review threads, missing approvals, outdated branches, and draft status — so PR authors know exactly what to do next. Draft PRs get a 14-day grace period. The workflow won't nudge the same PR more than once per week. - ## Decisions If you make a decision that affects other team members, write it to: diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 986415810..0dffd2f1f 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -821,3 +821,32 @@ jobs: console.log('✅ All ' + passed + ' subpath exports resolve and import successfully'); " + + scope-check: + # Enforces PR scope boundary: repo-health PRs must not touch product source code. + # Replaces the removed squad-scope-check.yml workflow. + if: >- + github.event_name == 'pull_request' + && contains(github.event.pull_request.labels.*.name, 'repo-health') + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for product source changes in repo-health PR + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + CHANGED=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^packages/squad-(cli|sdk)/src/' || true) + if [ -n "$CHANGED" ]; then + echo "::error::SCOPE VIOLATION — repo-health PRs must not modify product source code." + echo "The following product source files were changed:" + echo "$CHANGED" | while read -r f; do echo " - $f"; done + echo "" + echo "Product source (packages/squad-cli/src/ and packages/squad-sdk/src/) is off-limits for repo-health PRs." + echo "If this change requires product source edits, use a 'fix' or 'feat' label instead." + exit 1 + fi + echo "✅ No product source files modified — scope boundary respected" diff --git a/.github/workflows/squad-docs-links.yml b/.github/workflows/squad-docs-links.yml deleted file mode 100644 index 9ff2f8961..000000000 --- a/.github/workflows/squad-docs-links.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Docs — Weekly Link Check - -on: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -jobs: - linkcheck: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/checkout@v4 - - - name: Check external links - id: lychee - uses: lycheeverse/lychee-action@v2 - with: - args: >- - --verbose - --no-progress - --timeout 30 - --max-retries 3 - --accept 200..=299,403,429 - 'docs/src/content/**/*.md' - 'README.md' - fail: false - - - name: Create issue on broken links - if: steps.lychee.outputs.exit_code != 0 - uses: peter-evans/create-issue-from-file@v5 - with: - title: '🔗 Broken external links detected' - content-filepath: ./lychee/out.md - labels: docs, automated diff --git a/.github/workflows/squad-impact.yml b/.github/workflows/squad-impact.yml deleted file mode 100644 index f7fd7c59a..000000000 --- a/.github/workflows/squad-impact.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Squad Impact Analysis - -on: - pull_request_target: - branches: [dev] - types: [opened, synchronize, reopened] - -# Security: Using pull_request_target so we get a write token for fork PRs. -# Scripts are checked out from the BASE branch (trusted), not the PR head. -# PR data is fetched read-only via gh CLI — no untrusted code is executed. -permissions: - contents: read - pull-requests: write - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - impact: - runs-on: ubuntu-latest - timeout-minutes: 5 - if: github.actor != 'dependabot[bot]' - steps: - - name: Checkout scripts (base branch only) - uses: actions/checkout@v4 - with: - sparse-checkout: | - scripts/analyze-impact.mjs - scripts/impact-utils/parse-diff.mjs - scripts/impact-utils/risk-scorer.mjs - scripts/impact-utils/report-generator.mjs - sparse-checkout-cone-mode: false - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Run impact analysis - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: node scripts/analyze-impact.mjs ${{ github.event.pull_request.number }} - - - name: Post impact report - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const marker = ''; - const report = fs.readFileSync('impact-report.md', 'utf8'); - const body = `${marker}\n${report}`; - const prNumber = ${{ github.event.pull_request.number }}; - - // Upsert: find existing comment by marker, update or create. - // paginate() follows Link headers so we never miss an existing marker. - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - per_page: 100, - }); - - const existing = comments.find(c => c.body && c.body.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - core.info('Updated existing impact report comment'); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); - core.info('Created new impact report comment'); - } diff --git a/.github/workflows/squad-pr-nudge.yml b/.github/workflows/squad-pr-nudge.yml deleted file mode 100644 index 5e7c55738..000000000 --- a/.github/workflows/squad-pr-nudge.yml +++ /dev/null @@ -1,184 +0,0 @@ -name: PR Nudge -on: - schedule: - - cron: '0 14 * * 1-5' # 2pm UTC weekdays (morning US Pacific) - workflow_dispatch: {} # manual trigger for testing - -permissions: - contents: read - pull-requests: write - checks: read - issues: read - -jobs: - nudge-stale-prs: - name: "Nudge Stale PRs" - runs-on: ubuntu-latest - steps: - - uses: actions/github-script@v7 - with: - script: | - const STALE_DAYS = 7; - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - STALE_DAYS); - - // Get all open PRs, oldest-updated first - const prs = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - sort: 'updated', - direction: 'asc', - per_page: 50 - }); - - for (const pr of prs.data) { - // Skip PRs updated recently - const lastPush = new Date(pr.updated_at); - if (lastPush > cutoff) continue; - - // Give draft PRs 14 days grace period instead of 7 - if (pr.draft) { - const created = new Date(pr.created_at); - const draftCutoff = new Date(); - draftCutoff.setDate(draftCutoff.getDate() - 14); - if (created > draftCutoff) continue; - } - - // Don't nudge the same PR more than once per week - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 5, - sort: 'created', - direction: 'desc' - }); - const recentNudge = comments.data.find(c => - c.user.login === 'github-actions[bot]' && - c.body.includes('') && - new Date(c.created_at) > cutoff - ); - if (recentNudge) continue; - - // Build the diagnosis — collect actionable items - const actions = []; - const daysSinceUpdate = Math.floor((Date.now() - lastPush) / (1000 * 60 * 60 * 24)); - - // 1. Check if still in draft - if (pr.draft) { - actions.push('📝 **Still in draft** — mark as "Ready for review" when you\'re done, or close if abandoned.'); - } - - // 2. Check CI status for failures - const checks = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: pr.head.sha, - per_page: 50 - }); - const failedChecks = checks.data.check_runs.filter(c => - c.conclusion === 'failure' - ).map(c => c.name); - if (failedChecks.length > 0) { - actions.push(`🔴 **${failedChecks.length} CI check(s) failing:** ${failedChecks.join(', ')}. Fix these first.`); - } - - // 3. Check for unresolved review threads (Copilot vs human) - const threads = await github.graphql(` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewThreads(first: 50) { - nodes { - isResolved - isOutdated - comments(first: 1) { - nodes { author { login } } - } - } - } - } - } - } - `, { - owner: context.repo.owner, - repo: context.repo.repo, - number: pr.number - }); - const unresolvedThreads = threads.repository.pullRequest.reviewThreads.nodes - .filter(t => !t.isResolved && !t.isOutdated); - if (unresolvedThreads.length > 0) { - const copilotThreads = unresolvedThreads.filter(t => - t.comments.nodes[0]?.author?.login?.includes('copilot') - ); - const humanThreads = unresolvedThreads.length - copilotThreads.length; - const parts = []; - if (copilotThreads.length > 0) parts.push(`${copilotThreads.length} from Copilot`); - if (humanThreads > 0) parts.push(`${humanThreads} from reviewers`); - actions.push(`💬 **${unresolvedThreads.length} unresolved review thread(s)** (${parts.join(', ')}). Address and resolve them.`); - } - - // 4. Check review state (changes requested vs approved vs none) - const reviews = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number - }); - const latestByUser = {}; - for (const r of reviews.data) { - if (r.state === 'COMMENTED') continue; - latestByUser[r.user.login] = r; - } - const changesRequested = Object.values(latestByUser).filter(r => r.state === 'CHANGES_REQUESTED'); - const approvals = Object.values(latestByUser).filter(r => r.state === 'APPROVED'); - if (changesRequested.length > 0) { - const reviewers = changesRequested.map(r => `@${r.user.login}`).join(', '); - actions.push(`🔄 **Changes requested** by ${reviewers}. Address their feedback and request re-review.`); - } else if (approvals.length === 0 && !pr.draft) { - actions.push('👀 **No approving reviews yet.** Request a review from a teammate.'); - } - - // 5. Check if branch is behind base - const comparison = await github.rest.repos.compareCommits({ - owner: context.repo.owner, - repo: context.repo.repo, - base: pr.head.sha, - head: pr.base.ref - }); - if (comparison.data.ahead_by > 10) { - actions.push(`⬇️ **${comparison.data.ahead_by} commits behind ${pr.base.ref}.** Rebase to pick up latest changes.`); - } - - // 6. If everything looks good and approved — it's ready to merge - if (actions.length === 0 && approvals.length > 0) { - actions.push('✅ **Looks ready to merge!** All checks pass, approved — just needs someone to click merge.'); - } - - // Fallback if no specific blockers found - if (actions.length === 0) { - actions.push('🤔 **No obvious blockers found** — but this PR has been quiet. Is it still active?'); - } - - // Post the nudge comment - const body = [ - '', - `👋 **Friendly nudge** — this PR has had no activity for **${daysSinceUpdate} days**.`, - '', - '**What needs attention:**', - ...actions.map(a => `- ${a}`), - '', - '---', - '*If this PR is abandoned, please close it. If it\'s blocked on something external, leave a comment so the team knows.*', - '*This is an automated check that runs on weekdays. It won\'t nudge the same PR more than once per week.*' - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: body - }); - - core.info(`Nudged PR #${pr.number}: ${pr.title} (${daysSinceUpdate} days stale, ${actions.length} action items)`); - } diff --git a/.github/workflows/squad-pr-readiness.yml b/.github/workflows/squad-pr-readiness.yml deleted file mode 100644 index 223918765..000000000 --- a/.github/workflows/squad-pr-readiness.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Squad PR Readiness - -on: - pull_request_target: - branches: [dev, main, insider] - # opened + synchronize + reopened removed: redundant with workflow_run - # trigger (Squad CI fires on those events, then workflow_run fires PR readiness) - types: [edited, ready_for_review] - workflow_run: - workflows: ["Squad CI"] - types: [completed] - -# NOTE: Using pull_request_target so we get a write token even for fork PRs. -# This is safe because we never check out or execute PR code — all data -# comes from the GitHub API. -permissions: - contents: read - pull-requests: write - issues: write - statuses: read - checks: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number }} - cancel-in-progress: true - -jobs: - readiness: - runs-on: ubuntu-latest - timeout-minutes: 5 - # Skip bots, and skip workflow_run events with no associated PR - if: >- - github.actor != 'dependabot[bot]' - && (github.event_name != 'workflow_run' - || github.event.workflow_run.pull_requests[0] != null) - steps: - - name: Checkout scripts - uses: actions/checkout@v4 - with: - sparse-checkout: scripts/pr-readiness.mjs - sparse-checkout-cone-mode: false - - name: Check PR readiness - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number }} - PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.workflow_run.actor.login }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha }} - PR_BASE_REF: ${{ github.event.pull_request.base.ref || github.event.workflow_run.pull_requests[0].base.ref }} - PR_DRAFT: ${{ github.event.pull_request.draft || 'false' }} - PR_LABELS: ${{ toJson(github.event.pull_request.labels || '[]') }} - REPO_OWNER: ${{ github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - RUN_NAME: ${{ github.workflow }} - run: node scripts/pr-readiness.mjs diff --git a/.github/workflows/squad-repo-health.yml b/.github/workflows/squad-repo-health.yml deleted file mode 100644 index 2ef89a5c5..000000000 --- a/.github/workflows/squad-repo-health.yml +++ /dev/null @@ -1,181 +0,0 @@ -name: Repo Health - -on: - pull_request_target: - branches: [dev] - types: [opened, synchronize, reopened] - -# pull_request_target gives write token even for fork PRs. -# SAFETY: We check out the BASE branch (trusted scripts) and fetch the PR -# head only as a git ref for analysis — no PR-supplied code is executed. -permissions: - contents: read - pull-requests: write - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - # ═══════════════════════════════════════════════════════════════════════ - # Consolidated repo-health job — runs all checks on a single runner. - # Each check is a step with if: always() so all checks run even if - # one fails. Saves ~4 runner boots per PR push vs separate jobs. - # ═══════════════════════════════════════════════════════════════════════ - repo-health: - name: Repo Health - runs-on: ubuntu-latest - timeout-minutes: 10 - if: github.actor != 'dependabot[bot]' - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Fetch PR head (data only — not executed) - run: | - git fetch origin ${{ github.event.pull_request.head.sha }} - git fetch origin dev --quiet - - - uses: actions/setup-node@v4 - with: - node-version: '22' - - # ─── Bootstrap Protection (BLOCKING) ────────────────────────────── - - name: "Check: Bootstrap Protection" - id: bootstrap - if: always() - run: | - echo "## 🔒 Bootstrap Protection" >> $GITHUB_STEP_SUMMARY - set +e - OUTPUT=$(node scripts/check-bootstrap-deps.mjs --ref ${{ github.event.pull_request.head.sha }} 2>&1) - EXIT_CODE=$? - echo "$OUTPUT" - echo "result<> $GITHUB_OUTPUT - echo "$OUTPUT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT - if [ "$EXIT_CODE" -eq 0 ]; then - echo "✅ Passed" >> $GITHUB_STEP_SUMMARY - else - echo "❌ Failed" >> $GITHUB_STEP_SUMMARY - fi - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$OUTPUT" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - exit $EXIT_CODE - - # ─── Diff Size Guard (WARNING) ──────────────────────────────────── - - name: "Check: Diff Size Guard" - if: always() - run: | - echo "## 📏 Diff Size Guard" >> $GITHUB_STEP_SUMMARY - FILE_COUNT=$(git diff --name-only origin/dev...${{ github.event.pull_request.head.sha }} | wc -l) - COMMIT_COUNT=$(git rev-list --count origin/dev..${{ github.event.pull_request.head.sha }}) - if [ "$COMMIT_COUNT" -le 2 ] && [ "$FILE_COUNT" -gt 30 ]; then - echo "::warning::⚠️ This PR has $COMMIT_COUNT commit(s) but touches $FILE_COUNT files." - echo "::warning::This may indicate branch contamination from broad staging (--all or .) on a stale branch." - echo "::warning::Please verify all changed files are intentional: git diff --name-only origin/dev...${{ github.event.pull_request.head.sha }}" - echo "⚠️ Warning: $COMMIT_COUNT commit(s) but $FILE_COUNT file(s) — possible contamination" >> $GITHUB_STEP_SUMMARY - else - echo "✅ Diff looks proportional: $COMMIT_COUNT commit(s), $FILE_COUNT file(s)." >> $GITHUB_STEP_SUMMARY - fi - - # ─── Squad File Leakage (WARNING) ───────────────────────────────── - - name: "Check: Squad File Leakage" - id: leakage - if: always() - run: | - echo "## 🔍 Squad File Leakage" >> $GITHUB_STEP_SUMMARY - echo "result<> $GITHUB_OUTPUT - echo "(no output)" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - OUTPUT=$(node scripts/check-squad-leakage.mjs origin/dev ${{ github.event.pull_request.head.sha }} 2>&1) - echo "$OUTPUT" - echo "result<> $GITHUB_OUTPUT - echo "$OUTPUT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$OUTPUT" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - - name: "Comment: Leakage" - if: always() - uses: actions/github-script@v7 - env: - LEAKAGE_OUTPUT: ${{ steps.leakage.outputs.result }} - with: - script: | - const { run } = await import(`${process.env.GITHUB_WORKSPACE}/scripts/repo-health-comment.mjs`); - await run({ - github, - context, - output: process.env.LEAKAGE_OUTPUT ?? '', - job: 'leakage', - }); - - # ─── Architectural Review (INFORMATIONAL) ───────────────────────── - - name: "Check: Architectural Review" - id: arch - if: always() - run: | - echo "## 🏗️ Architectural Review" >> $GITHUB_STEP_SUMMARY - echo "result<> $GITHUB_OUTPUT - echo "(no output)" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - OUTPUT=$(node scripts/architectural-review.mjs origin/dev ${{ github.event.pull_request.head.sha }} 2>&1) - echo "$OUTPUT" - echo "result<> $GITHUB_OUTPUT - echo "$OUTPUT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$OUTPUT" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - - name: "Comment: Architectural Review" - if: always() - uses: actions/github-script@v7 - env: - ARCH_OUTPUT: ${{ steps.arch.outputs.result }} - with: - script: | - const { run } = await import(`${process.env.GITHUB_WORKSPACE}/scripts/repo-health-comment.mjs`); - await run({ - github, - context, - output: process.env.ARCH_OUTPUT ?? '', - job: 'architectural', - }); - - # ─── Security Review (INFORMATIONAL) ────────────────────────────── - - name: "Check: Security Review" - id: security - if: always() - run: | - echo "## 🔐 Security Review" >> $GITHUB_STEP_SUMMARY - echo "result<> $GITHUB_OUTPUT - echo "(no output)" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - OUTPUT=$(node scripts/security-review.mjs origin/dev ${{ github.event.pull_request.head.sha }} 2>&1) - echo "$OUTPUT" - echo "result<> $GITHUB_OUTPUT - echo "$OUTPUT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$OUTPUT" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - - name: "Comment: Security Review" - if: always() - uses: actions/github-script@v7 - env: - SECURITY_OUTPUT: ${{ steps.security.outputs.result }} - with: - script: | - const { run } = await import(`${process.env.GITHUB_WORKSPACE}/scripts/repo-health-comment.mjs`); - await run({ - github, - context, - output: process.env.SECURITY_OUTPUT ?? '', - job: 'security', - }); diff --git a/.github/workflows/squad-scope-check.yml b/.github/workflows/squad-scope-check.yml deleted file mode 100644 index 7aeec1370..000000000 --- a/.github/workflows/squad-scope-check.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Scope Check -on: - pull_request: - types: [opened, synchronize, reopened, labeled] - -permissions: - pull-requests: read - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - scope-boundary: - name: "Scope Boundary" - runs-on: ubuntu-latest - if: >- - startsWith(github.head_ref, 'repo-health/') || - contains(github.event.pull_request.labels.*.name, 'repo-health') - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Check for product code in repo-health PR - run: | - PRODUCT_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'packages/squad-cli/src/' 'packages/squad-sdk/src/') - if [ -n "$PRODUCT_FILES" ]; then - echo "::error::Repo-health PRs must not modify product source code." - echo "::error::The following product files were changed:" - echo "$PRODUCT_FILES" | while read -r f; do echo "::error:: - $f"; done - echo "::error::Move product changes to a separate PR." - exit 1 - fi - echo "✅ No product source files in this repo-health PR." diff --git a/scripts/analyze-impact.mjs b/scripts/analyze-impact.mjs deleted file mode 100644 index 3693829bf..000000000 --- a/scripts/analyze-impact.mjs +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -/** - * PR Architectural Impact Analysis - * - * Usage: node scripts/analyze-impact.mjs - * - * Uses the gh CLI to fetch PR data, then: - * 1. Maps changed files → modules - * 2. Calculates a risk tier (LOW / MEDIUM / HIGH / CRITICAL) - * 3. Writes impact-report.md to cwd - * 4. Outputs JSON summary to stdout - * - * Uses only Node.js built-ins (no npm dependencies). - * Issue: #733 - */ - -import { execSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { parseDiffNames, enrichFileStatuses } from './impact-utils/parse-diff.mjs'; -import { calculateRisk } from './impact-utils/risk-scorer.mjs'; -import { generateReport } from './impact-utils/report-generator.mjs'; - -// ── Module mapping ──────────────────────────────────────────────────────── -// Directory prefix → module name (first match wins). -const MODULE_MAP = [ - ['packages/squad-sdk/', 'squad-sdk'], - ['packages/squad-cli/', 'squad-cli'], - ['.squad-templates/', 'templates'], - ['.github/', 'ci-workflows'], - ['scripts/', 'scripts'], - ['.copilot/', 'copilot-config'], - ['.squad/', 'squad-state'], - ['test/', 'tests'], - ['docs/', 'docs'], -]; - -// Patterns that flag a file as "critical" (config or entry points). -const CRITICAL_PATTERNS = [/package\.json$/, /tsconfig\.json$/, /index\.ts$/]; - -function mapFileToModule(filePath) { - for (const [prefix, mod] of MODULE_MAP) { - if (filePath.startsWith(prefix)) return mod; - } - return 'root'; -} - -function isCriticalFile(filePath) { - return CRITICAL_PATTERNS.some((p) => p.test(filePath)); -} - -// ── Main ────────────────────────────────────────────────────────────────── - -const prNumberRaw = process.argv[2]; -const prNumber = parseInt(prNumberRaw, 10); -if (!Number.isInteger(prNumber) || prNumber <= 0) { - console.error('Usage: node scripts/analyze-impact.mjs (must be a positive integer)'); - process.exit(1); -} - -// Resolve repo slug (works in CI via env var, locally via gh). -const repoSlug = - process.env.GITHUB_REPOSITORY || - execSync('gh repo view --json nameWithOwner -q .nameWithOwner', { - encoding: 'utf8', - }).trim(); - -// 1. Get changed files with statuses -let files; -try { - // --paginate can emit multiple JSON arrays; use --jq '.[]' to emit one - // JSON object per line, then parse each line individually. - const apiOutput = execSync( - `gh api repos/${repoSlug}/pulls/${prNumber}/files --paginate --jq '.[]'`, - { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }, - ); - const apiFiles = apiOutput - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line)); - files = enrichFileStatuses(apiFiles); -} catch { - // Fallback: name-only diff (no added/deleted distinction) - console.error('⚠ API file listing unavailable, falling back to gh pr diff --name-only'); - const diffOutput = execSync(`gh pr diff ${prNumber} --name-only`, { - encoding: 'utf8', - }); - files = parseDiffNames(diffOutput); -} - -// 2. Map files → modules -const modules = {}; -for (const filePath of files.all) { - const mod = mapFileToModule(filePath); - if (!modules[mod]) modules[mod] = []; - modules[mod].push(filePath); -} - -// 3. Identify critical files -const criticalFiles = files.all.filter((f) => isCriticalFile(f)); - -// 4. Calculate risk tier -const risk = calculateRisk({ - filesChanged: files.all.length, - filesDeleted: files.deleted.length, - modulesTouched: Object.keys(modules).length, - criticalFiles, -}); - -// 5. Generate markdown report and write to cwd -const report = generateReport({ prNumber, risk, modules, files, criticalFiles }); -writeFileSync('impact-report.md', report, 'utf8'); - -// 6. Output JSON summary to stdout -const result = { - prNumber: Number(prNumber), - risk, - modules: Object.fromEntries(Object.entries(modules).map(([k, v]) => [k, v.length])), - filesChanged: files.all.length, - filesAdded: files.added.length, - filesModified: files.modified.length, - filesDeleted: files.deleted.length, - criticalFiles, -}; - -console.log(JSON.stringify(result, null, 2)); diff --git a/scripts/architectural-review.mjs b/scripts/architectural-review.mjs deleted file mode 100644 index a8a6158df..000000000 --- a/scripts/architectural-review.mjs +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Architectural Review Check — detects structural concerns in PRs. - * - * Checks for: - * - Bootstrap area modifications (packages/squad-cli/src/cli/core/) - * - New/modified exports in package entry points - * - Cross-package import violations (CLI ↔ SDK direct paths) - * - Template file sync (changes in one template dir without others) - * - Sweeping refactors (>20 files changed) - * - File deletions (potential breakage) - * - * Usage: node scripts/architectural-review.mjs [base-ref] - * Default base-ref: origin/dev - * - * Exit code: always 0 (informational) - * Output: JSON { findings: [{category, severity, message, files}], summary } - * - * Uses only node:* built-ins (runs in CI before npm install). - */ - -import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const baseRef = process.argv[2] || 'origin/dev'; -const headRef = process.argv[3] || 'HEAD'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function gitDiffNames(filter) { - try { - const args = ['diff', `${baseRef}...${headRef}`, '--name-only']; - if (filter) args.push(`--diff-filter=${filter}`); - const output = execFileSync('git', args, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - return output - .split('\n') - .map((f) => f.trim()) - .filter(Boolean); - } catch { - return []; - } -} - -function gitDiffContent() { - try { - return execFileSync('git', ['diff', `${baseRef}...${headRef}`, '-U3'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - }); - } catch { - return ''; - } -} - -function readFileSafe(filePath) { - try { - if (headRef !== 'HEAD') { - return execFileSync('git', ['show', `${headRef}:${filePath}`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - } - return readFileSync(resolve(filePath), 'utf-8'); - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Checks -// --------------------------------------------------------------------------- - -const findings = []; - -const allChanged = gitDiffNames('ACMRT'); -const deletedFiles = gitDiffNames('D'); -const diff = gitDiffContent(); - -// 1. Bootstrap area modifications -const bootstrapFiles = allChanged.filter((f) => - f.startsWith('packages/squad-cli/src/cli/core/'), -); -if (bootstrapFiles.length > 0) { - findings.push({ - category: 'bootstrap-area', - severity: 'warning', - message: - `${bootstrapFiles.length} file(s) in the bootstrap area (packages/squad-cli/src/cli/core/) were modified. ` + - 'These files must maintain zero external dependencies. Review carefully.', - files: bootstrapFiles, - }); -} - -// 2. Entry point export changes -const entryPoints = [ - 'packages/squad-sdk/src/index.ts', - 'packages/squad-cli/src/index.ts', -]; -const changedEntryPoints = allChanged.filter((f) => entryPoints.includes(f)); -if (changedEntryPoints.length > 0) { - // Check for added export lines in the diff - const exportLines = diff - .split('\n') - .filter( - (line) => - line.startsWith('+') && - !line.startsWith('+++') && - /\bexport\b/.test(line), - ); - if (exportLines.length > 0) { - findings.push({ - category: 'export-surface', - severity: 'warning', - message: - `Package entry point(s) modified with ${exportLines.length} new/changed export(s). ` + - 'New public API surface requires careful review for backward compatibility.', - files: changedEntryPoints, - }); - } -} - -// 3. Cross-package imports -const cliFiles = allChanged.filter((f) => - f.startsWith('packages/squad-cli/'), -); -const sdkFiles = allChanged.filter((f) => - f.startsWith('packages/squad-sdk/'), -); - -const crossImportViolations = []; -for (const file of cliFiles) { - const content = readFileSafe(file); - if (!content) continue; - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - if ( - /from\s+['"].*squad-sdk\/src\//.test(lines[i]) || - /require\(['"].*squad-sdk\/src\//.test(lines[i]) - ) { - crossImportViolations.push({ file, line: i + 1, direction: 'CLI → SDK src' }); - } - } -} -for (const file of sdkFiles) { - const content = readFileSafe(file); - if (!content) continue; - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - if ( - /from\s+['"].*squad-cli\/src\//.test(lines[i]) || - /require\(['"].*squad-cli\/src\//.test(lines[i]) - ) { - crossImportViolations.push({ file, line: i + 1, direction: 'SDK → CLI src' }); - } - } -} -if (crossImportViolations.length > 0) { - findings.push({ - category: 'cross-package-import', - severity: 'error', - message: - `${crossImportViolations.length} cross-package import(s) detected. ` + - 'Packages should import via the published package name, not direct src/ paths.', - files: crossImportViolations.map( - (v) => `${v.file}:${v.line} (${v.direction})`, - ), - }); -} - -// 4. Template sync check -const TEMPLATE_DIRS = [ - 'templates/', - '.squad-templates/', - 'packages/squad-cli/templates/', - '.github/workflows/', -]; -const touchedTemplateDirs = TEMPLATE_DIRS.filter((dir) => - allChanged.some((f) => f.startsWith(dir)), -); -if (touchedTemplateDirs.length === 1) { - const untouched = TEMPLATE_DIRS.filter((d) => !touchedTemplateDirs.includes(d)); - findings.push({ - category: 'template-sync', - severity: 'info', - message: - `Template files changed in ${touchedTemplateDirs[0]} but not in other template locations. ` + - 'If these templates should stay in sync, consider updating the others too.', - files: [ - `Changed: ${touchedTemplateDirs.join(', ')}`, - `Unchanged: ${untouched.join(', ')}`, - ], - }); -} - -// 5. Sweeping refactor signal -const totalChanged = allChanged.length + deletedFiles.length; -if (totalChanged > 20) { - findings.push({ - category: 'sweeping-refactor', - severity: 'warning', - message: - `This PR touches ${totalChanged} files (${allChanged.length} modified/added, ${deletedFiles.length} deleted). ` + - 'Large PRs are harder to review — consider splitting if possible.', - files: [], - }); -} - -// 6. File deletions -if (deletedFiles.length > 0) { - const publicDeletions = deletedFiles.filter( - (f) => - f.startsWith('packages/') && - (f.endsWith('/index.ts') || f.includes('/src/')), - ); - if (publicDeletions.length > 0) { - findings.push({ - category: 'file-deletion', - severity: 'warning', - message: - `${publicDeletions.length} source file(s) deleted from packages/. ` + - 'Verify no public API or imports are broken.', - files: publicDeletions, - }); - } -} - -// --------------------------------------------------------------------------- -// Output -// --------------------------------------------------------------------------- - -const errorCount = findings.filter((f) => f.severity === 'error').length; -const warnCount = findings.filter((f) => f.severity === 'warning').length; -const infoCount = findings.filter((f) => f.severity === 'info').length; - -let summary; -if (findings.length === 0) { - summary = '✅ No architectural concerns found.'; -} else { - const parts = []; - if (errorCount) parts.push(`${errorCount} error(s)`); - if (warnCount) parts.push(`${warnCount} warning(s)`); - if (infoCount) parts.push(`${infoCount} info`); - summary = `⚠️ Architectural review: ${parts.join(', ')}.`; -} - -const result = { findings, summary }; -console.log(JSON.stringify(result, null, 2)); -console.log(`\n${summary}`); diff --git a/scripts/check-bootstrap-deps.mjs b/scripts/check-bootstrap-deps.mjs deleted file mode 100644 index cc89b1da8..000000000 --- a/scripts/check-bootstrap-deps.mjs +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Bootstrap Protection Gate — validates that protected bootstrap files - * use only node:* built-in module imports. No npm or workspace deps allowed. - * - * Exit code: 0 = pass, 1 = violations found - * Output: JSON { pass, violations: [{file, import, line}] } - * - * Uses only node:* built-ins (runs in CI before npm install). - */ - -import { readFileSync } from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { resolve } from 'node:path'; - -// --------------------------------------------------------------------------- -// Protected bootstrap files — these MUST have zero non-node:* dependencies. -// --------------------------------------------------------------------------- - -const PROTECTED_FILES = [ - 'packages/squad-cli/src/cli/core/detect-squad-dir.ts', - 'packages/squad-cli/src/cli/core/errors.ts', - 'packages/squad-cli/src/cli/core/gh-cli.ts', - 'packages/squad-cli/src/cli/core/output.ts', - 'packages/squad-cli/src/cli/core/history-split.ts', -]; - -const refIndex = process.argv.indexOf('--ref'); -const gitRef = refIndex !== -1 ? process.argv[refIndex + 1] : null; - -// Node.js built-in modules (with and without node: prefix) -const NODE_BUILTINS = new Set([ - 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', - 'console', 'constants', 'crypto', 'dgram', 'diagnostics_channel', - 'dns', 'domain', 'events', 'fs', 'http', 'http2', 'https', - 'inspector', 'module', 'net', 'os', 'path', 'perf_hooks', - 'process', 'punycode', 'querystring', 'readline', 'repl', - 'stream', 'string_decoder', 'sys', 'test', 'timers', 'tls', - 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi', - 'worker_threads', 'zlib', -]); - -/** - * Check whether an import specifier is a node built-in. - * Accepts both `node:fs` and `fs` forms, as well as subpaths like `node:fs/promises`. - */ -function isNodeBuiltin(specifier) { - if (specifier.startsWith('node:')) return true; - const base = specifier.split('/')[0]; - return NODE_BUILTINS.has(base); -} - -/** - * Check whether an import is a relative path (sibling bootstrap file). - * Relative imports within the same directory are allowed. - */ -function isRelativeImport(specifier) { - return specifier.startsWith('./') || specifier.startsWith('../'); -} - -// Patterns that capture import/require specifiers in TS/JS -const IMPORT_PATTERNS = [ - // ES import — import ... from 'specifier' - /(?:^|\s)import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g, - // Dynamic import — import('specifier') - /import\(\s*['"]([^'"]+)['"]\s*\)/g, - // require — require('specifier') - /require\(\s*['"]([^'"]+)['"]\s*\)/g, -]; - -/** - * Scan a file for non-node:* imports. - * @param {string} filePath - * @returns {{ file: string, import: string, line: number }[]} - */ -function scanFile(filePath) { - const violations = []; - let content; - try { - if (gitRef) { - content = execFileSync('git', ['show', `${gitRef}:${filePath}`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - } else { - content = readFileSync(resolve(filePath), 'utf-8'); - } - } catch (err) { - // File might not exist in sparse checkout — skip silently - const errorMessage = err instanceof Error ? err.message : String(err); - console.error(`Warning: could not read ${filePath}: ${errorMessage}`); - return violations; - } - - const lines = content.split('\n'); - let inBlockComment = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - - // Track block comment state - if (inBlockComment) { - if (trimmed.includes('*/')) inBlockComment = false; - continue; - } - if (trimmed.startsWith('/*')) { - if (!trimmed.includes('*/')) inBlockComment = true; - continue; - } - // Skip single-line comments - if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue; - - for (const pattern of IMPORT_PATTERNS) { - // Reset regex state - pattern.lastIndex = 0; - let match; - while ((match = pattern.exec(line)) !== null) { - const specifier = match[1]; - if (!isNodeBuiltin(specifier) && !isRelativeImport(specifier)) { - violations.push({ - file: filePath, - import: specifier, - line: i + 1, - }); - } - } - } - } - return violations; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -const allViolations = []; -for (const file of PROTECTED_FILES) { - allViolations.push(...scanFile(file)); -} - -const result = { - pass: allViolations.length === 0, - violations: allViolations, -}; - -console.log(JSON.stringify(result, null, 2)); - -if (!result.pass) { - console.error( - `\n❌ Bootstrap protection: ${allViolations.length} violation(s) found.`, - ); - console.error('Protected bootstrap files must only import node:* built-in modules.'); - for (const v of allViolations) { - console.error(` ${v.file}:${v.line} — imports "${v.import}"`); - } - process.exitCode = 1; -} else { - console.log('\n✅ Bootstrap protection: all protected files use only node:* imports.'); -} diff --git a/scripts/check-squad-leakage.mjs b/scripts/check-squad-leakage.mjs deleted file mode 100644 index 0606de8ff..000000000 --- a/scripts/check-squad-leakage.mjs +++ /dev/null @@ -1,56 +0,0 @@ -/** - * .squad/ Leakage Detector — warns if .squad/ files are included in a PR. - * - * Feature branches should not typically modify .squad/ files (team config, - * agent charters, routing). This script detects accidental leakage. - * - * Usage: node scripts/check-squad-leakage.mjs [base-ref] - * Default base-ref: origin/dev - * - * Exit code: always 0 (informational only — does not block merge) - * Output: JSON { leaked: boolean, files: string[] } - * - * Uses only node:* built-ins (runs in CI before npm install). - */ - -import { execFileSync } from 'node:child_process'; - -const baseRef = process.argv[2] || 'origin/dev'; -const headRef = process.argv[3] || 'HEAD'; - -let changedFiles = []; -try { - const output = execFileSync( - 'git', - ['diff', `${baseRef}...${headRef}`, '--name-only', '--diff-filter=ACMRT', '--', '.squad/'], - { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, - ); - changedFiles = output - .split('\n') - .map((f) => f.trim()) - .filter(Boolean); -} catch (err) { - // git diff can fail if base ref is missing — treat as no leakage - const errorMessage = err instanceof Error ? err.message : String(err); - console.error(`Warning: git diff failed: ${errorMessage}`); -} - -const result = { - leaked: changedFiles.length > 0, - files: changedFiles, -}; - -console.log(JSON.stringify(result, null, 2)); - -if (result.leaked) { - console.warn(`\n⚠️ Squad file leakage: ${changedFiles.length} .squad/ file(s) modified in this PR:`); - for (const f of changedFiles) { - console.warn(` - ${f}`); - } - console.warn( - '\nThis is usually unintentional. If these changes are deliberate, ensure they are ' + - 'approved by the team lead. .squad/ files affect team routing, agent charters, and decisions.', - ); -} else { - console.log('\n✅ No .squad/ file leakage detected.'); -} diff --git a/scripts/impact-utils/parse-diff.mjs b/scripts/impact-utils/parse-diff.mjs deleted file mode 100644 index 8a36f4312..000000000 --- a/scripts/impact-utils/parse-diff.mjs +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Parse PR diff output into structured file data. - * Uses only Node.js built-ins. - * - * Issue: #733 - */ - -/** - * Parse `gh pr diff --name-only` output into structured data. - * @param {string} diffOutput — raw output from `gh pr diff --name-only` - * @returns {{ added: string[], modified: string[], deleted: string[], all: string[] }} - */ -export function parseDiffNames(diffOutput) { - const all = diffOutput - .trim() - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - - // Name-only output has no status info; classify all as modified. - // Caller should use enrichFileStatuses() when API data is available. - return { added: [], modified: [...all], deleted: [], all }; -} - -/** - * Build structured file data from the GitHub Pulls files API response. - * Each entry has {filename, status} where status is added|removed|modified|renamed|copied|changed. - * @param {Array<{filename: string, status: string}>} apiFiles - * @returns {{ added: string[], modified: string[], deleted: string[], all: string[] }} - */ -export function enrichFileStatuses(apiFiles) { - const added = []; - const modified = []; - const deleted = []; - const all = []; - - for (const f of apiFiles) { - all.push(f.filename); - switch (f.status) { - case 'added': - added.push(f.filename); - break; - case 'removed': - deleted.push(f.filename); - break; - default: // modified, renamed, copied, changed - modified.push(f.filename); - break; - } - } - - return { added, modified, deleted, all }; -} diff --git a/scripts/impact-utils/report-generator.mjs b/scripts/impact-utils/report-generator.mjs deleted file mode 100644 index 35ecfa87a..000000000 --- a/scripts/impact-utils/report-generator.mjs +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Generate markdown impact report from analysis results. - * Uses only Node.js built-ins. - * - * Issue: #733 - */ - -const TIER_EMOJI = { - LOW: '🟢', - MEDIUM: '🟡', - HIGH: '🟠', - CRITICAL: '🔴', -}; - -/** - * Generate a markdown impact report. - * - * @param {{ prNumber: number|string, risk: {tier: string, factors: string[]}, modules: Record, files: {added: string[], modified: string[], deleted: string[], all: string[]}, criticalFiles: string[] }} params - * @returns {string} Markdown report body - */ -export function generateReport({ prNumber, risk, modules, files, criticalFiles }) { - const emoji = TIER_EMOJI[risk.tier] || '⚪'; - const lines = []; - - lines.push(`## ${emoji} Impact Analysis — PR #${prNumber}`); - lines.push(''); - lines.push(`**Risk tier:** ${emoji} **${risk.tier}**`); - lines.push(''); - - // Summary table - lines.push('### 📊 Summary'); - lines.push(''); - lines.push('| Metric | Count |'); - lines.push('|--------|-------|'); - lines.push(`| Files changed | ${files.all.length} |`); - lines.push(`| Files added | ${files.added.length} |`); - lines.push(`| Files modified | ${files.modified.length} |`); - lines.push(`| Files deleted | ${files.deleted.length} |`); - lines.push(`| Modules touched | ${Object.keys(modules).length} |`); - if (criticalFiles.length > 0) { - lines.push(`| Critical files | ${criticalFiles.length} |`); - } - lines.push(''); - - // Risk factors - lines.push('### 🎯 Risk Factors'); - lines.push(''); - for (const factor of risk.factors) { - lines.push(`- ${factor}`); - } - lines.push(''); - - // Module breakdown - lines.push('### 📦 Modules Affected'); - lines.push(''); - const moduleNames = Object.keys(modules).sort(); - for (const mod of moduleNames) { - const modFiles = modules[mod]; - lines.push( - `
${mod} (${modFiles.length} file${modFiles.length === 1 ? '' : 's'})`, - ); - lines.push(''); - for (const f of modFiles) { - lines.push(`- \`${f}\``); - } - lines.push(''); - lines.push('
'); - lines.push(''); - } - - // Critical files - if (criticalFiles.length > 0) { - lines.push('### ⚠️ Critical Files'); - lines.push(''); - for (const f of criticalFiles) { - lines.push(`- \`${f}\``); - } - lines.push(''); - } - - lines.push('---'); - lines.push( - '*This report is generated automatically for every PR. See [#733](https://github.com/bradygaster/squad/issues/733) for details.*', - ); - - return lines.join('\n'); -} diff --git a/scripts/impact-utils/risk-scorer.mjs b/scripts/impact-utils/risk-scorer.mjs deleted file mode 100644 index 4c1e07ce4..000000000 --- a/scripts/impact-utils/risk-scorer.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Calculate risk tier from file counts and module data. - * Uses only Node.js built-ins. - * - * Issue: #733 - */ - -const TIER_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; - -function maxTier(a, b) { - return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b; -} - -/** - * Calculate risk tier based on PR change metrics. - * Takes the highest tier from all individual factors. - * - * @param {{ filesChanged: number, filesDeleted: number, modulesTouched: number, criticalFiles: string[] }} params - * @returns {{ tier: 'LOW'|'MEDIUM'|'HIGH'|'CRITICAL', factors: string[] }} - */ -export function calculateRisk({ filesChanged, filesDeleted, modulesTouched, criticalFiles }) { - const factors = []; - let tier = 'LOW'; - - // Files changed: ≤5=LOW, 6-20=MEDIUM, 21-50=HIGH, >50=CRITICAL - if (filesChanged > 50) { - tier = maxTier(tier, 'CRITICAL'); - factors.push(`${filesChanged} files changed (>50 → CRITICAL)`); - } else if (filesChanged > 20) { - tier = maxTier(tier, 'HIGH'); - factors.push(`${filesChanged} files changed (21-50 → HIGH)`); - } else if (filesChanged > 5) { - tier = maxTier(tier, 'MEDIUM'); - factors.push(`${filesChanged} files changed (6-20 → MEDIUM)`); - } else { - factors.push(`${filesChanged} files changed (≤5 → LOW)`); - } - - // Modules touched: ≤1=LOW, 2-4=MEDIUM, 5-8=HIGH, >8=CRITICAL - if (modulesTouched > 8) { - tier = maxTier(tier, 'CRITICAL'); - factors.push(`${modulesTouched} modules touched (>8 → CRITICAL)`); - } else if (modulesTouched >= 5) { - tier = maxTier(tier, 'HIGH'); - factors.push(`${modulesTouched} modules touched (5-8 → HIGH)`); - } else if (modulesTouched >= 2) { - tier = maxTier(tier, 'MEDIUM'); - factors.push(`${modulesTouched} modules touched (2-4 → MEDIUM)`); - } else { - factors.push(`${modulesTouched} module(s) touched (≤1 → LOW)`); - } - - // Files deleted: >10=CRITICAL - if (filesDeleted > 10) { - tier = maxTier(tier, 'CRITICAL'); - factors.push(`${filesDeleted} files deleted (>10 → CRITICAL)`); - } else if (filesDeleted > 0) { - factors.push(`${filesDeleted} file(s) deleted`); - } - - // Critical files (package.json, tsconfig.json, index.ts entry points) - if (criticalFiles.length > 0) { - tier = maxTier(tier, 'MEDIUM'); - factors.push(`Critical files touched: ${criticalFiles.join(', ')}`); - } - - return { tier, factors }; -} diff --git a/scripts/pr-readiness.mjs b/scripts/pr-readiness.mjs deleted file mode 100644 index e052771c0..000000000 --- a/scripts/pr-readiness.mjs +++ /dev/null @@ -1,700 +0,0 @@ -/** - * PR Readiness checks — pure check functions + orchestration. - * - * Each check is a pure function that returns { pass, detail }. - * The `run()` function is the orchestrator: it reads PR context from - * environment variables, calls the GitHub API via `fetchFn`, runs all - * checks, builds the checklist markdown, and upserts the PR comment. - * - * The workflow invokes this script via `node scripts/pr-readiness.mjs`. - * - * Issue: #750 - */ - -import { fileURLToPath } from 'node:url'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -export const COMMENT_MARKER = ''; - -/** Check-run names belonging to this workflow (filtered from CI checks). */ -export const SELF_CHECK_NAMES = ['readiness', 'PR Readiness Check']; - -/** Regex for source files that require a changeset. */ -export const SOURCE_PATTERN = /^packages\/squad-(sdk|cli)\/src\//; - -/** Bootstrap files that must remain zero-dependency. */ -export const PROTECTED_FILES = [ - 'packages/squad-cli/src/cli/core/detect-squad-dir.ts', - 'packages/squad-cli/src/cli/core/errors.ts', - 'packages/squad-cli/src/cli/core/gh-cli.ts', - 'packages/squad-cli/src/cli/core/output.ts', - 'packages/squad-cli/src/cli/core/history-split.ts', -]; - -// --------------------------------------------------------------------------- -// Pure check functions -// --------------------------------------------------------------------------- - -/** - * Check 1: Single commit. - * @param {number} commitCount - * @returns {{ pass: boolean, detail: string }} - */ -export function checkCommitCount(commitCount) { - return { - pass: commitCount === 1, - detail: commitCount === 1 - ? '1 commit — clean history' - : `${commitCount} commits — consider squashing before review`, - }; -} - -/** - * Check 2: Not in draft. - * @param {boolean} isDraft - * @returns {{ pass: boolean, detail: string }} - */ -export function checkDraftStatus(isDraft) { - return { - pass: !isDraft, - detail: isDraft - ? 'PR is still in draft — mark as ready for review when done' - : 'Ready for review', - }; -} - -/** - * Check 3: Branch freshness (up to date with base). - * @param {number|null} behindBy — null when comparison failed - * @param {string} baseRef - * @returns {{ pass: boolean, detail: string }} - */ -export function checkBranchFreshness(behindBy, baseRef) { - if (behindBy === null) { - return { pass: false, detail: 'Could not determine — check manually' }; - } - const upToDate = behindBy === 0; - return { - pass: upToDate, - detail: upToDate - ? `Up to date with ${baseRef}` - : `${baseRef} is ${behindBy} commit(s) ahead — rebase recommended`, - }; -} - -/** - * Check 4: Copilot review. - * @param {Array<{ user?: { login?: string }, state?: string, submitted_at?: string, created_at?: string }>} reviews - * @returns {{ pass: boolean, detail: string }} - */ -export function checkCopilotReview(reviews) { - const copilotReviews = (reviews || []).filter( - (r) => r.user && r.user.login === 'copilot-pull-request-reviewer', - ); - const latest = copilotReviews.length - ? copilotReviews.reduce((a, b) => { - const aTime = new Date(a.submitted_at || a.created_at || 0).getTime(); - const bTime = new Date(b.submitted_at || b.created_at || 0).getTime(); - return bTime > aTime ? b : a; - }) - : null; - const approved = latest && latest.state === 'APPROVED'; - return { - pass: !!approved, - detail: approved - ? 'Copilot reviewed and approved' - : latest - ? `Copilot reviewed (state: ${latest.state})` - : 'No Copilot review yet — it may still be processing', - }; -} - -/** - * Check 5: Changeset present. - * @param {Array<{ filename: string }>} files — files changed in the PR - * @param {Array<{ name: string }>} labels — PR labels - * @returns {{ pass: boolean, detail: string }} - */ -export function checkChangeset(files, labels) { - const hasChangeset = files.some( - (f) => f.filename.startsWith('.changeset/') && f.filename.endsWith('.md'), - ); - const hasChangelogEdit = files.some((f) => f.filename === 'CHANGELOG.md'); - const hasChangelogArtifact = hasChangeset || hasChangelogEdit; - const touchesSource = files.some((f) => SOURCE_PATTERN.test(f.filename)); - const hasSkipLabel = (labels || []).some((l) => l.name === 'skip-changelog'); - - let pass = hasChangelogArtifact || !touchesSource; - let detail = ''; - - if (hasChangeset) { - detail = 'Changeset file found'; - } else if (hasChangelogEdit) { - detail = 'CHANGELOG.md edit found'; - } else if (!touchesSource) { - detail = 'No source files changed — changeset not required'; - } else { - detail = - 'Missing `.changeset/*.md` or `CHANGELOG.md` edit — run `npx changeset add` (or add `skip-changelog` label)'; - } - - if (hasSkipLabel && !hasChangelogArtifact) { - pass = true; - detail = 'Changeset skipped via `skip-changelog` label'; - } - - return { pass, detail }; -} - -/** - * Check 6: No merge conflicts (mergeability). - * @param {boolean|null} mergeable — true/false/null - * @returns {{ pass: boolean, detail: string }} - */ -export function checkMergeability(mergeable) { - if (mergeable === true) { - return { pass: true, detail: 'No merge conflicts' }; - } - if (mergeable === false) { - return { pass: false, detail: 'Merge conflicts detected — resolve before review' }; - } - // null / unknown — don't penalize - return { pass: true, detail: 'Merge status unknown — GitHub is still computing' }; -} - -/** - * Check 7: Scope cleanliness — warn when PR includes `.squad/` or `docs/proposals/` files. - * Informational only (always passes); helps flag accidental scope creep. - * @param {Array<{ filename: string }>} files — files changed in the PR - * @returns {{ pass: boolean, detail: string }} - */ -export function checkScopeClean(files) { - const squadFiles = (files || []).filter((f) => f.filename.startsWith('.squad/')); - const proposalFiles = (files || []).filter((f) => f.filename.startsWith('docs/proposals/')); - const squadCount = squadFiles.length; - const proposalCount = proposalFiles.length; - - if (squadCount === 0 && proposalCount === 0) { - return { pass: true, detail: 'No .squad/ or docs/proposals/ files' }; - } - - const parts = []; - if (squadCount > 0) parts.push(`${squadCount} .squad/ file(s)`); - if (proposalCount > 0) parts.push(`${proposalCount} docs/proposals/ file(s)`); - return { - pass: true, - detail: `⚠️ PR includes ${parts.join(' and ')} — ensure these are intentional`, - }; -} - -/** - * Check 8: All Copilot review threads resolved. - * @param {Array<{ isResolved: boolean, isOutdated: boolean, comments: { nodes: Array<{ author: { login: string } }> } }>} threads - * @returns {{ pass: boolean, detail: string }} - */ -export function checkCopilotThreads(threads) { - const copilotThreads = (threads || []).filter( - (t) => - t.comments && - t.comments.nodes && - t.comments.nodes[0]?.author?.login === 'copilot-pull-request-reviewer', - ); - const unresolved = copilotThreads.filter((t) => !t.isResolved && !t.isOutdated); - const outdatedCount = copilotThreads.filter((t) => t.isOutdated).length; - const activeCount = copilotThreads.length - outdatedCount; - - let detail; - if (unresolved.length > 0) { - detail = `${unresolved.length} unresolved Copilot thread(s) — fix and resolve before merging`; - } else if (copilotThreads.length === 0) { - detail = 'No Copilot review threads'; - } else if (outdatedCount > 0) { - detail = `${activeCount} active Copilot thread(s) resolved (${outdatedCount} outdated skipped)`; - } else { - detail = `All ${copilotThreads.length} Copilot thread(s) resolved`; - } - - return { pass: unresolved.length === 0, detail }; -} - -/** - * Check 9: CI passing. - * @param {Array<{ name: string, conclusion: string|null, status: string }>} checkRuns - * @param {Array} statuses — combined status entries - * @returns {{ pass: boolean, detail: string }} - */ -export function checkCIStatus(checkRuns, statuses) { - const otherChecks = (checkRuns || []).filter( - (cr) => !SELF_CHECK_NAMES.includes(cr.name), - ); - const failedChecks = otherChecks.filter( - (cr) => cr.conclusion === 'failure' || cr.conclusion === 'cancelled', - ); - const pendingChecks = otherChecks.filter( - (cr) => cr.status === 'in_progress' || cr.status === 'queued', - ); - - if (failedChecks.length > 0) { - return { - pass: false, - detail: `${failedChecks.length} check(s) failing: ${failedChecks.map((c) => c.name).join(', ')}`, - }; - } - if (pendingChecks.length > 0) { - return { - pass: false, - detail: `${pendingChecks.length} check(s) still running`, - }; - } - if (otherChecks.length === 0 && (statuses || []).length === 0) { - return { pass: false, detail: 'No CI checks have run yet' }; - } - return { pass: true, detail: 'All checks passing' }; -} - -/** - * Check 10: Issue linkage — PR body or commit message references an issue. - * @param {string} prBody — PR description text - * @param {Array<{ commit: { message: string } }>} commits - * @returns {{ pass: boolean, detail: string }} - */ -export function checkIssueLinkage(prBody, commits) { - const issuePattern = /(closes|fixes|resolves|part of)\s+#\d+/i; - const bodyHasRef = issuePattern.test(prBody || ''); - const commitHasRef = (commits || []).some( - (c) => issuePattern.test(c.commit?.message || ''), - ); - if (bodyHasRef || commitHasRef) { - return { pass: true, detail: 'Issue reference found' }; - } - return { - pass: false, - detail: 'No issue reference — add `Closes #N` to PR body or commit message', - }; -} - -/** - * Check 11: Protected file changes (informational). - * Warns when bootstrap zero-dependency files are modified. - * @param {Array<{ filename: string }>} files — files changed in the PR - * @returns {{ pass: boolean, detail: string }} - */ -export function checkProtectedFiles(files) { - const touched = (files || []).filter( - (f) => PROTECTED_FILES.includes(f.filename), - ); - if (touched.length === 0) { - return { pass: true, detail: 'No protected bootstrap files changed' }; - } - const names = touched.map((f) => f.filename.split('/').pop()).join(', '); - return { - pass: true, - detail: `⚠️ ${touched.length} protected bootstrap file(s) changed: ${names} — verify zero-dependency constraint`, - }; -} - -// --------------------------------------------------------------------------- -// Scope classification -// --------------------------------------------------------------------------- - -/** - * Classify the PR scope based on changed files. - * @param {Array<{ filename: string }>} files - * @returns {{ label: string, emoji: string }} - */ -export function classifyScope(files) { - const hasProduct = (files || []).some((f) => SOURCE_PATTERN.test(f.filename)); - const hasInfra = (files || []).some((f) => !SOURCE_PATTERN.test(f.filename)); - - if (hasProduct && hasInfra) { - return { label: 'Mixed (product + infrastructure)', emoji: '📦🔧' }; - } - if (hasProduct) { - return { label: 'Product', emoji: '📦' }; - } - return { label: 'Infrastructure', emoji: '🔧' }; -} - -// --------------------------------------------------------------------------- -// File list builder -// --------------------------------------------------------------------------- - -/** Maximum files shown in the file list before truncation. */ -export const MAX_FILE_LIST = 50; - -/** - * Sanitize a filename for safe inclusion in a markdown table cell. - * Escapes pipe characters, replaces backticks, and collapses newlines. - * @param {string} name - * @returns {string} - */ -export function sanitizeFilename(name) { - return name - .replace(/\|/g, '\\|') - .replace(/`/g, "'") - .replace(/[\r\n]+/g, ' '); -} - -/** - * Build a markdown section listing changed files with per-file line stats. - * @param {Array<{ filename: string, additions?: number, deletions?: number }>} files - * @returns {string} - */ -export function buildFileList(files) { - if (!files || files.length === 0) { - return ''; - } - - const totalAdded = files.reduce((sum, f) => sum + (f.additions || 0), 0); - const totalDeleted = files.reduce((sum, f) => sum + (f.deletions || 0), 0); - - const displayed = files.slice(0, MAX_FILE_LIST); - const truncated = files.length > MAX_FILE_LIST; - - const rows = displayed.map((f) => { - const added = f.additions || 0; - const deleted = f.deletions || 0; - return `| \`${sanitizeFilename(f.filename)}\` | +${added} −${deleted} |`; - }); - - if (truncated) { - const remaining = files.length - MAX_FILE_LIST; - rows.push(`| ... | **+${remaining} more files** | |`); - } - - return [ - `### Files Changed (${files.length} file${files.length === 1 ? '' : 's'}, +${totalAdded} −${totalDeleted})`, - '', - '| File | +/− |', - '|------|-----|', - ...rows, - '', - `**Total: +${totalAdded} −${totalDeleted}**`, - ].join('\n'); -} - -// --------------------------------------------------------------------------- -// Checklist markdown builder -// --------------------------------------------------------------------------- - -/** - * Build the PR readiness comment body. - * @param {Array<{ name: string, pass: boolean, detail: string }>} checks - * @param {string} owner - * @param {string} repo - * @param {string} baseRef - * @param {string} [headSha] — commit SHA that triggered the check - * @param {Array<{ filename: string, additions?: number, deletions?: number }>} [files] - * @returns {string} - */ -export function buildChecklist(checks, owner, repo, baseRef, headSha, files) { - const allPass = checks.every((c) => c.pass); - const passCount = checks.filter((c) => c.pass).length; - - const status = allPass - ? '### ✅ PR is ready for review' - : `### ⚠️ ${checks.length - passCount} item(s) to address before review`; - - const rows = checks.map((c) => { - const icon = c.pass ? '✅' : '❌'; - return `| ${icon} | **${c.name}** | ${c.detail} |`; - }); - - const scope = classifyScope(files); - - const sections = [ - COMMENT_MARKER, - '## 🛫 PR Readiness Check', - ...(headSha - ? [`> ℹ️ This comment updates on each push. Last checked: commit \`${headSha.slice(0, 7)}\``] - : []), - '', - `**PR Scope:** ${scope.emoji} ${scope.label}`, - '', - status, - '', - '| Status | Check | Details |', - '|--------|-------|---------|', - ...rows, - ]; - - const fileList = buildFileList(files); - if (fileList) { - sections.push('', fileList); - } - - sections.push( - '', - '---', - '*This check runs automatically on every push. Fix any ❌ items and push again.*', - `*See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/${baseRef}/CONTRIBUTING.md#pr-readiness-checklist) and [PR Requirements](https://github.com/${owner}/${repo}/blob/${baseRef}/.github/PR_REQUIREMENTS.md) for details.*`, - ); - - return sections.join('\n'); -} - -// --------------------------------------------------------------------------- -// API helpers -// --------------------------------------------------------------------------- - -/** - * Paginate a GitHub REST list endpoint. - * @param {typeof globalThis.fetch} fetchFn - * @param {string} url — initial URL (with per_page param) - * @param {Record} headers - * @returns {Promise} - */ -export async function paginate(fetchFn, url, headers) { - const items = []; - let nextUrl = url; - while (nextUrl) { - const res = await fetchFn(nextUrl, { headers }); - if (!res.ok) throw new Error(`GitHub API ${res.status}: ${nextUrl}`); - const data = await res.json(); - items.push(...(Array.isArray(data) ? data : data.check_runs || [])); - // Parse Link header for next page - const link = res.headers.get('link') || ''; - const match = link.match(/<([^>]+)>;\s*rel="next"/); - nextUrl = match ? match[1] : null; - } - return items; -} - -// --------------------------------------------------------------------------- -// Orchestration -// --------------------------------------------------------------------------- - -/** - * Full PR readiness orchestrator. Reads context from env vars, calls - * GitHub API, runs all checks, and upserts the readiness comment. - * - * Dependencies (`env` and `fetchFn`) are injectable for testing. - * - * @param {object} [opts] - * @param {Record} [opts.env] — defaults to process.env - * @param {typeof globalThis.fetch} [opts.fetchFn] — defaults to global fetch - * @returns {Promise<{ checks: Array<{name:string,pass:boolean,detail:string}>, action: string }>} - */ -export async function run({ env = process.env, fetchFn = globalThis.fetch } = {}) { - const token = env.GITHUB_TOKEN; - const prNumber = env.PR_NUMBER; - const prDraft = env.PR_DRAFT === 'true'; - const prHeadSha = env.PR_HEAD_SHA; - const prBaseRef = env.PR_BASE_REF; - const owner = env.REPO_OWNER; - const repo = env.REPO_NAME; - const runName = env.RUN_NAME || ''; - // Labels come as a JSON array string when set, or empty - const prLabelsRaw = env.PR_LABELS || '[]'; - - let prLabels = []; - try { - prLabels = JSON.parse(prLabelsRaw); - } catch { - prLabels = []; - } - - const apiHeaders = { - Authorization: `token ${token}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'squad-pr-readiness', - }; - - const apiBase = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; - const checks = []; - - // 1. Commit count - const commits = await paginate( - fetchFn, - `${apiBase}/pulls/${prNumber}/commits?per_page=100`, - apiHeaders, - ); - checks.push({ name: 'Single commit', ...checkCommitCount(commits.length) }); - - // Fetch PR data once (used for draft status + mergeability) - let prData = null; - for (let attempt = 0; attempt < 2; attempt++) { - const prRes = await fetchFn(`${apiBase}/pulls/${prNumber}`, { - headers: apiHeaders, - }); - if (prRes.ok) { - prData = await prRes.json(); - if (prData.mergeable === null && attempt === 0) { - await new Promise((r) => setTimeout(r, 3000)); - continue; - } - } - break; - } - - // 2. Draft status — prefer API truth over env var (workflow_run can't provide it) - const isDraft = prData?.draft ?? prDraft; - checks.push({ name: 'Not in draft', ...checkDraftStatus(isDraft) }); - - // 3. Branch freshness - let behindBy = null; - try { - const compRes = await fetchFn( - `${apiBase}/compare/${encodeURIComponent(prBaseRef)}...${prHeadSha}`, - { headers: apiHeaders }, - ); - if (compRes.ok) { - const comp = await compRes.json(); - behindBy = comp.behind_by; - } - } catch { - // leave behindBy as null - } - checks.push({ name: 'Branch up to date', ...checkBranchFreshness(behindBy, prBaseRef) }); - - // 4. Copilot review - const reviews = await paginate( - fetchFn, - `${apiBase}/pulls/${prNumber}/reviews?per_page=100`, - apiHeaders, - ); - checks.push({ name: 'Copilot review', ...checkCopilotReview(reviews) }); - - // 5. Changeset present - const files = await paginate( - fetchFn, - `${apiBase}/pulls/${prNumber}/files?per_page=100`, - apiHeaders, - ); - checks.push({ name: 'Changeset present', ...checkChangeset(files, prLabels) }); - - // 6. Scope cleanliness - checks.push({ name: 'Scope clean', ...checkScopeClean(files) }); - - // 7. Mergeability (uses PR data fetched above) - const mergeable = prData?.mergeable ?? null; - checks.push({ name: 'No merge conflicts', ...checkMergeability(mergeable) }); - - // 8. Copilot review threads resolved (via GraphQL) - let reviewThreadNodes = []; - try { - const graphqlBody = JSON.stringify({ - query: `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewThreads(first: 100) { - nodes { - isResolved - isOutdated - comments(first: 1) { - nodes { - author { login } - } - } - } - } - } - } - }`, - variables: { owner, repo, number: parseInt(prNumber, 10) }, - }); - const threadsRes = await fetchFn('https://api.github.com/graphql', { - method: 'POST', - headers: { ...apiHeaders, 'Content-Type': 'application/json' }, - body: graphqlBody, - }); - if (threadsRes.ok) { - const threadsData = await threadsRes.json(); - reviewThreadNodes = - threadsData.data?.repository?.pullRequest?.reviewThreads?.nodes || []; - } - } catch { - // leave empty — will show "No Copilot review threads" - } - checks.push({ - name: 'Copilot threads resolved', - ...checkCopilotThreads(reviewThreadNodes), - }); - - // 9. CI status - let checkRuns = []; - let statusEntries = []; - try { - checkRuns = await paginate( - fetchFn, - `${apiBase}/commits/${prHeadSha}/check-runs?per_page=100`, - apiHeaders, - ); - const statusRes = await fetchFn( - `${apiBase}/commits/${prHeadSha}/status`, - { headers: apiHeaders }, - ); - if (statusRes.ok) { - const statusData = await statusRes.json(); - statusEntries = statusData.statuses || []; - } - } catch { - // leave empty - } - checks.push({ name: 'CI passing', ...checkCIStatus(checkRuns, statusEntries) }); - - // 10. Issue linkage - const prBody = prData?.body || ''; - checks.push({ name: 'Issue linked', ...checkIssueLinkage(prBody, commits) }); - - // 11. Protected file changes (informational) - checks.push({ name: 'Protected files', ...checkProtectedFiles(files) }); - - // ── Build checklist and upsert comment ── - const body = buildChecklist(checks, owner, repo, prBaseRef, prHeadSha, files); - - // Find existing comment - const existingComments = await paginate( - fetchFn, - `${apiBase}/issues/${prNumber}/comments?per_page=100`, - apiHeaders, - ); - const existing = existingComments.find( - (c) => c.body && c.body.includes(COMMENT_MARKER), - ); - - let action; - if (existing) { - await fetchFn(`${apiBase}/issues/comments/${existing.id}`, { - method: 'PATCH', - headers: { ...apiHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify({ body }), - }); - action = 'updated'; - } else { - await fetchFn(`${apiBase}/issues/${prNumber}/comments`, { - method: 'POST', - headers: { ...apiHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify({ body }), - }); - action = 'created'; - } - - // Log summary - const passCount = checks.filter((c) => c.pass).length; - console.log(`PR Readiness: ${passCount}/${checks.length} checks passing`); - if (passCount < checks.length) { - const failing = checks.filter((c) => !c.pass).map((c) => c.name); - console.log(`Failing: ${failing.join(', ')}`); - } - - return { checks, action }; -} - -// --------------------------------------------------------------------------- -// CLI entry point -// --------------------------------------------------------------------------- - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - run() - .then((result) => { - console.log(`Comment ${result.action}.`); - }) - .catch((err) => { - console.error('PR readiness script failed:', err); - process.exitCode = 1; - }); -} diff --git a/scripts/repo-health-comment.mjs b/scripts/repo-health-comment.mjs deleted file mode 100644 index 4ebe1570a..000000000 --- a/scripts/repo-health-comment.mjs +++ /dev/null @@ -1,151 +0,0 @@ -// scripts/repo-health-comment.mjs — zero dependencies -// Shared utility for posting/upserting repo health PR comments. -// DI pattern: run({ github, context, output, job }) for testability. - -const JOBS = { - leakage: { - marker: '', - parse(output) { - try { - const jsonMatch = output.match(/\{[\s\S]*?\}/); - const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : { leaked: false, files: [] }; - return parsed.leaked ? parsed : null; - } catch { - return null; - } - }, - format(parsed) { - const fileList = parsed.files.map(f => `- \`${f}\``).join('\n'); - return [ - '## ⚠️ Squad File Leakage Detected', - '', - 'The following `.squad/` files were modified in this PR:', - '', - fileList, - '', - 'These files affect team routing, agent charters, and decisions.', - 'If intentional, ensure approval from the team lead.', - ].join('\n'); - }, - }, - architectural: { - marker: '', - parse(output) { - try { - const jsonMatch = output.match(/\{[\s\S]*"findings"[\s\S]*\}/); - const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : null; - return parsed && parsed.findings.length > 0 ? parsed : null; - } catch { - return null; - } - }, - format(parsed) { - const severityIcon = { error: '🔴', warning: '🟡', info: 'ℹ️' }; - const rows = parsed.findings.map(f => { - const icon = severityIcon[f.severity] || '❓'; - const files = f.files.length > 0 - ? f.files.map(fi => `\`${fi}\``).join(', ') - : '—'; - return `| ${icon} ${f.severity} | **${f.category}** | ${f.message} | ${files} |`; - }); - return [ - '## 🏗️ Architectural Review', - '', - parsed.summary, - '', - '| Severity | Category | Finding | Files |', - '|----------|----------|---------|-------|', - ...rows, - '', - '---', - '*Automated architectural review — informational only.*', - ].join('\n'); - }, - }, - security: { - marker: '', - parse(output) { - try { - const jsonMatch = output.match(/\{[\s\S]*"findings"[\s\S]*\}/); - const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : null; - return parsed && parsed.findings.length > 0 ? parsed : null; - } catch { - return null; - } - }, - format(parsed) { - const severityIcon = { error: '🔴', warning: '🟡', info: 'ℹ️' }; - const rows = parsed.findings.map(f => { - const icon = severityIcon[f.severity] || '❓'; - const loc = f.line ? `\`${f.file}:${f.line}\`` : (f.file ? `\`${f.file}\`` : '—'); - return `| ${icon} ${f.severity} | **${f.category}** | ${f.message} | ${loc} |`; - }); - return [ - '## 🔒 Security Review', - '', - parsed.summary, - '', - '| Severity | Category | Finding | Location |', - '|----------|----------|---------|----------|', - ...rows, - '', - '---', - '*Automated security review — informational only.*', - ].join('\n'); - }, - }, -}; - -/** - * Post or update a repo health comment on a PR. - * @param {object} opts - * @param {object} opts.github Octokit instance (DI) - * @param {object} opts.context GitHub Actions context (DI) - * @param {string} opts.output Raw output from the check step - * @param {string} opts.job 'leakage' | 'architectural' | 'security' - */ -export async function run({ github, context, output, job }) { - const config = JOBS[job]; - if (!config) throw new Error(`Unknown repo-health job type: ${job}`); - - const parsed = config.parse(output); - - // Fetch all comments (paginated) to find existing marker - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - per_page: 100, - }); - const existing = comments.find(c => c.body && c.body.includes(config.marker)); - - // No findings — clean up stale marker comment if one exists - if (!parsed) { - if (existing) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - }); - } - return; - } - - const body = `${config.marker}\n${config.format(parsed)}`; - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } -} diff --git a/scripts/security-review.mjs b/scripts/security-review.mjs deleted file mode 100644 index dcef9a0ef..000000000 --- a/scripts/security-review.mjs +++ /dev/null @@ -1,527 +0,0 @@ -/** - * Security Review Check — detects potential security concerns in PR diffs. - * - * Checks for: - * - secrets.* references in workflow files - * - eval() usage in JS/TS - * - child_process.exec with template literals (injection risk) - * - Unsafe git operations (git add ., git add -A, git commit -a, git push --force) - * - New npm dependencies - * - PII-related environment variable patterns - * - Workflow files with write permissions - * - pull_request_target + actions/checkout combination (token exposure) - * - * Usage: node scripts/security-review.mjs [base-ref] - * Default base-ref: origin/dev - * - * Exit code: always 0 (informational) - * Output: JSON { findings: [{category, severity, message, file, line}], summary } - * - * Uses only node:* built-ins (runs in CI before npm install). - */ - -import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const baseRef = process.argv[2] || 'origin/dev'; -const headRef = process.argv[3] || 'HEAD'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function gitDiffNames() { - try { - const output = execFileSync( - 'git', - ['diff', `${baseRef}...${headRef}`, '--name-only', '--diff-filter=ACMRT'], - { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, - ); - return output - .split('\n') - .map((f) => f.trim()) - .filter(Boolean); - } catch { - return []; - } -} - -function gitDiffPatch() { - try { - return execFileSync('git', ['diff', `${baseRef}...${headRef}`, '-U0'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - }); - } catch { - return ''; - } -} - -function readFileSafe(filePath) { - try { - if (headRef !== 'HEAD') { - return execFileSync('git', ['show', `${headRef}:${filePath}`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - } - return readFileSync(resolve(filePath), 'utf-8'); - } catch { - return null; - } -} - -/** - * Parse unified diff into per-file added lines with line numbers. - * Returns Map> - */ -function parseAddedLines(patch) { - const result = new Map(); - let currentFile = null; - let hunkLine = 0; - - for (const rawLine of patch.split('\n')) { - // New file header - const fileMatch = rawLine.match(/^\+\+\+ b\/(.+)/); - if (fileMatch) { - currentFile = fileMatch[1]; - if (!result.has(currentFile)) result.set(currentFile, []); - continue; - } - // Hunk header — extract new file line number - const hunkMatch = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)/); - if (hunkMatch) { - hunkLine = parseInt(hunkMatch[1], 10); - continue; - } - // Added line - if (rawLine.startsWith('+') && !rawLine.startsWith('+++') && currentFile) { - result.get(currentFile).push({ line: hunkLine, text: rawLine.slice(1) }); - hunkLine++; - } else if (!rawLine.startsWith('-')) { - // Context line — increment line counter - hunkLine++; - } - } - return result; -} - -// --------------------------------------------------------------------------- -// Skill security scanning — Phase 1 (PRD #881) -// --------------------------------------------------------------------------- - -const SKILL_CREDENTIAL_PATTERNS = [ - { name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/ }, - { name: 'GitHub PAT', regex: /ghp_[A-Za-z0-9]{36,}/ }, - { name: 'GitHub OAuth', regex: /gho_[A-Za-z0-9]{36,}/ }, - { name: 'GitHub App Token', regex: /ghu_[A-Za-z0-9]{36,}/ }, - { name: 'OpenAI Key', regex: /sk-[A-Za-z0-9]{20,}/ }, - { name: 'Private Key', regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, - { name: 'JWT Token', regex: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/ }, - { name: 'npm Token', regex: /npm_[A-Za-z0-9]{36,}/ }, - { name: 'Slack Token', regex: /xox[bpors]-[A-Za-z0-9-]+/ }, - { name: 'Generic Secret Assign', regex: /(?:API_KEY|SECRET|TOKEN|PASSWORD)\s*=\s*["']?[A-Za-z0-9+/=_-]{20,}/ }, -]; - -const SKILL_DOWNLOAD_EXEC_PATTERNS = [ - { name: 'curl pipe bash', regex: /curl\s+.*\|\s*(?:bash|sh|zsh)/ }, - { name: 'wget pipe bash', regex: /wget\s+.*\|\s*(?:bash|sh|zsh)/ }, - { name: 'irm pipe iex', regex: /irm\s+.*\|\s*iex/ }, - { name: 'Invoke-Expression', regex: /Invoke-Expression\s+.*(?:http|ftp|Invoke-WebRequest|irm)/ }, - { name: 'powershell -enc', regex: /powershell\s+.*-[Ee]nc(?:oded)?[Cc]ommand/ }, - { name: 'eval curl', regex: /eval\s+"\$\(curl/ }, - { name: 'source curl', regex: /source\s+<\(curl/ }, - { name: 'bash process sub', regex: /bash\s+<\(curl/ }, -]; - -const SKILL_CRED_FILE_READ_PATTERNS = [ - { name: '.env read (cmd)', regex: /(?:cat|type|Get-Content|less|more|head|tail)\s+.*\.env(?!\.example|\.sample|\.template)\b/ }, - { name: '.env read (js)', regex: /(?:readFileSync|readFile)\s*\(.*\.env(?!\.example|\.sample|\.template)\b/ }, - { name: 'Private key read (cmd)', regex: /(?:cat|type|Get-Content)\s+.*(?:id_rsa|id_ed25519|\.pem|\.key)\b/ }, - { name: 'Private key read (js)', regex: /(?:readFileSync|readFile)\s*\(.*(?:id_rsa|id_ed25519|\.pem|\.key)\b/ }, - { name: 'AWS credentials read', regex: /(?:cat|type|readFileSync|Get-Content)\s*[\s(].*\.aws\/credentials/ }, - { name: '.npmrc read', regex: /(?:cat|type|readFileSync|Get-Content)\s*[\s(].*\.npmrc/ }, - { name: '.netrc read', regex: /(?:cat|type|readFileSync|Get-Content)\s*[\s(].*\.netrc/ }, -]; - -const SKILL_PRIV_ESC_PATTERNS = [ - { name: 'sudo bash/sh', regex: /sudo\s+(?:bash|sh|zsh|su)/ }, - { name: 'sudo rm', regex: /sudo\s+rm\b/ }, - { name: 'RunAs admin', regex: /Start-Process\s+.*-Verb\s+RunAs/ }, - { name: 'SetExecutionPolicy', regex: /Set-ExecutionPolicy\s+(?:Bypass|Unrestricted)/ }, - { name: 'chmod 777', regex: /chmod\s+777/ }, -]; - -const PLACEHOLDER_RE = /\.{2,}|x{4,}|X{4,}|_{4,}|<[^>]+>/; - -/** Regex syntax markers — character classes [..] or quantifiers {n,m}. */ -const REGEX_SYNTAX_RE = /\[[^\]]+\]|\{\d+[,\d]*\}/; - -/** - * Check whether a line is a markdown table row documenting regex patterns. - * Used to suppress credential findings on pattern-documentation tables. - */ -function isRegexDocRow(line) { - return /^\s*\|/.test(line) && REGEX_SYNTAX_RE.test(line); -} - -/** - * Detect fenced code block regions in markdown. - * Handles backtick and tilde fences, variable-length delimiters, - * and up to 3 leading spaces per CommonMark. - */ -function parseFencedRegions(lines) { - let inFence = false; - let fenceChar = ''; - let fenceLen = 0; - const fenced = new Set(); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!inFence) { - const m = line.match(/^\s{0,3}((`{3,})|(~{3,}))/); - if (m) { - inFence = true; - fenceChar = m[2] ? '`' : '~'; - fenceLen = (m[2] || m[3]).length; - fenced.add(i); - } - } else { - fenced.add(i); - const closeRe = new RegExp( - `^\\s{0,3}${fenceChar === '`' ? '`' : '~'}{${fenceLen},}\\s*$`, - ); - if (closeRe.test(line)) { - inFence = false; - } - } - } - return { fenced, unclosed: inFence }; -} - -/** Remove inline code spans (backtick-delimited) from a line. */ -function stripInlineCode(line) { - return line.replace(/`[^`]*`/g, ''); -} - -/** Check whether a regex match looks like a placeholder token. */ -function isPlaceholder(matched) { - return PLACEHOLDER_RE.test(matched); -} - -/** - * Scan skill markdown content for high-confidence security patterns. - * Pure function — no side effects, no git calls. - * - * Suppression (Phase 1): - * - Lines inside fenced code blocks are skipped. - * - Inline code spans (backtick pairs) are stripped before matching. - * - Markdown table rows documenting regex patterns are suppressed for credentials. - * - Placeholder tokens (sk-..., ghp_xxxx, AKIA...) are ignored. - * - Fail-safe: unclosed fences = UNSUPPRESSED (fail-open for security). - * - * @param {string} content Full markdown file content - * @param {string} filePath Repo-relative file path (for findings) - * @returns {Array<{category:string, severity:string, message:string, file:string, line:number}>} - */ -export function scanSkillContent(content, filePath) { - const findings = []; - const lines = content.split('\n'); - const { fenced, unclosed } = parseFencedRegions(lines); - const suppressFenced = !unclosed; - - for (let i = 0; i < lines.length; i++) { - if (suppressFenced && fenced.has(i)) continue; - - const scanText = stripInlineCode(lines[i]); - const regexDocRow = isRegexDocRow(lines[i]); - - // P1: Embedded credentials (suppress on regex-doc table rows) - if (!regexDocRow) { - for (const { name, regex } of SKILL_CREDENTIAL_PATTERNS) { - const m = scanText.match(regex); - if (m && !isPlaceholder(m[0])) { - findings.push({ - category: 'skill-credentials', - severity: 'error', - message: `Possible embedded credential (${name}) in skill file.`, - file: filePath, - line: i + 1, - }); - } - } - } - - // P2: Credential file reads - for (const { name, regex } of SKILL_CRED_FILE_READ_PATTERNS) { - if (regex.test(scanText)) { - findings.push({ - category: 'skill-credential-file-read', - severity: 'error', - message: `Credential file read instruction (${name}) in skill file.`, - file: filePath, - line: i + 1, - }); - } - } - - for (const { name, regex } of SKILL_DOWNLOAD_EXEC_PATTERNS) { - if (regex.test(scanText)) { - findings.push({ - category: 'skill-download-exec', - severity: 'error', - message: `Download-and-execute pattern (${name}) in skill file.`, - file: filePath, - line: i + 1, - }); - } - } - - for (const { name, regex } of SKILL_PRIV_ESC_PATTERNS) { - if (regex.test(scanText)) { - findings.push({ - category: 'skill-privilege-escalation', - severity: 'error', - message: `Privilege escalation pattern (${name}) in skill file.`, - file: filePath, - line: i + 1, - }); - } - } - } - - return findings; -} - -// --------------------------------------------------------------------------- -// Security checks -// --------------------------------------------------------------------------- - -function run() { -const findings = []; -const changedFiles = gitDiffNames(); -const patch = gitDiffPatch(); -const addedByFile = parseAddedLines(patch); - -const workflowFiles = changedFiles.filter((f) => - f.startsWith('.github/workflows/') && (f.endsWith('.yml') || f.endsWith('.yaml')), -); -const jstsFiles = changedFiles.filter((f) => - /\.(js|ts|mjs|mts|cjs|cts)$/.test(f), -); -const pkgJsonFiles = changedFiles.filter((f) => f.endsWith('package.json')); - -// 1. secrets.* references in workflow files -for (const file of workflowFiles) { - const added = addedByFile.get(file) || []; - for (const { line, text } of added) { - // Exclude standard GITHUB_TOKEN and common safe patterns - if (/secrets\./.test(text) && !/secrets\.GITHUB_TOKEN/.test(text)) { - findings.push({ - category: 'secrets-reference', - severity: 'warning', - message: 'Non-standard secret reference in workflow — verify this secret is necessary and scoped correctly.', - file, - line, - }); - } - } -} - -// 2. eval() usage -for (const file of jstsFiles) { - const added = addedByFile.get(file) || []; - for (const { line, text } of added) { - if (/\beval\s*\(/.test(text)) { - findings.push({ - category: 'eval-usage', - severity: 'error', - message: 'eval() detected — this is a code injection risk. Use safer alternatives.', - file, - line, - }); - } - } -} - -// 3. child_process.exec with template literals -for (const file of jstsFiles) { - const added = addedByFile.get(file) || []; - for (const { line, text } of added) { - if (/exec\s*\(\s*`/.test(text) || /exec\s*\(\s*['"].*\$\{/.test(text)) { - findings.push({ - category: 'command-injection', - severity: 'error', - message: - 'exec() with template literal/interpolation detected — risk of command injection. ' + - 'Use execFile() with array arguments instead.', - file, - line, - }); - } - } -} - -// 4. Unsafe git operations -const GIT_UNSAFE_PATTERNS = [ - { pattern: /git\s+add\s+\./, label: 'git add .' }, - { pattern: /git\s+add\s+-A/, label: 'git add -A' }, - { pattern: /git\s+commit\s+-a/, label: 'git commit -a' }, - { pattern: /git\s+push\s+--force/, label: 'git push --force' }, - { pattern: /--force-with-lease/, label: 'git push --force-with-lease' }, -]; - -for (const file of changedFiles) { - // Skill docs reference unsafe patterns as warnings — skip them - if (file.startsWith('.copilot/skills/') && file.endsWith('.md')) continue; - if (file.startsWith('.squad/skills/') && file.endsWith('.md')) continue; - const added = addedByFile.get(file) || []; - for (const { line, text } of added) { - for (const { pattern, label } of GIT_UNSAFE_PATTERNS) { - if (pattern.test(text)) { - findings.push({ - category: 'unsafe-git', - severity: 'error', - message: `Unsafe git operation: \`${label}\` — this can stage unintended files or force-push shared branches.`, - file, - line, - }); - } - } - } -} - -// 5. New npm dependencies -for (const file of pkgJsonFiles) { - const added = addedByFile.get(file) || []; - // Look for lines adding new dependencies - const depLines = added.filter(({ text }) => - /^\s*"[^"]+"\s*:\s*"[~^]?\d/.test(text) || /^\s*"[^"]+"\s*:\s*"(workspace|npm):/.test(text), - ); - if (depLines.length > 0) { - findings.push({ - category: 'new-dependency', - severity: 'info', - message: - `${depLines.length} new/changed dependency version(s) in ${file}. ` + - 'Verify these packages are trusted and necessary.', - file, - line: depLines[0].line, - }); - } -} - -// 6. PII-related environment variable patterns -const PII_PATTERNS = [ - /PASSWORD/i, - /SECRET_KEY/i, - /PRIVATE_KEY/i, - /API_KEY/i, - /ACCESS_TOKEN/i, - /CREDENTIALS/i, - /AUTH_TOKEN/i, -]; - -for (const file of workflowFiles) { - const added = addedByFile.get(file) || []; - for (const { line, text } of added) { - for (const pattern of PII_PATTERNS) { - if (pattern.test(text) && !/secrets\./.test(text)) { - findings.push({ - category: 'pii-env-var', - severity: 'warning', - message: `Environment variable with sensitive name pattern (${pattern.source}) — ensure this isn't hardcoded.`, - file, - line, - }); - break; // one finding per line - } - } - } -} - -// 7. Workflow write permissions -for (const file of workflowFiles) { - const content = readFileSafe(file); - if (!content) continue; - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - if (/:\s*write\b/.test(lines[i]) && /permissions/i.test(lines.slice(Math.max(0, i - 5), i + 1).join('\n'))) { - // Only flag if this line was added in the diff - const added = addedByFile.get(file) || []; - if (added.some((a) => a.line === i + 1)) { - findings.push({ - category: 'workflow-permissions', - severity: 'info', - message: 'Workflow grants write permission — verify this is the minimum required scope.', - file, - line: i + 1, - }); - } - } - } -} - -// 8. pull_request_target + actions/checkout combination -for (const file of workflowFiles) { - const content = readFileSafe(file); - if (!content) continue; - const hasPRTarget = /pull_request_target/.test(content); - const hasCheckout = /actions\/checkout/.test(content); - const checksOutHead = - /ref:\s*.*pull_request\.head/.test(content) || - /ref:\s*.*github\.event\.pull_request\.head\.sha/.test(content); - - if (hasPRTarget && hasCheckout && checksOutHead) { - findings.push({ - category: 'pr-target-checkout', - severity: 'warning', - message: - 'This workflow uses pull_request_target AND checks out the PR head. ' + - 'This grants write token to untrusted code — ensure no scripts from the PR are executed ' + - 'or use sparse-checkout to limit exposure.', - file, - line: 0, - }); - } -} - -// 9. Skill security scanning (Phase 1 — PRD #881) -const skillFiles = changedFiles.filter((f) => - (f.startsWith('.copilot/skills/') || f.startsWith('.squad/skills/')) && f.endsWith('.md'), -); -for (const file of skillFiles) { - const content = readFileSafe(file); - if (!content) continue; - findings.push(...scanSkillContent(content, file)); -} - -// --------------------------------------------------------------------------- -// Output -// --------------------------------------------------------------------------- - -const errorCount = findings.filter((f) => f.severity === 'error').length; -const warnCount = findings.filter((f) => f.severity === 'warning').length; -const infoCount = findings.filter((f) => f.severity === 'info').length; - -let summary; -if (findings.length === 0) { - summary = '✅ No security concerns found.'; -} else { - const parts = []; - if (errorCount) parts.push(`${errorCount} error(s)`); - if (warnCount) parts.push(`${warnCount} warning(s)`); - if (infoCount) parts.push(`${infoCount} info`); - summary = `🔒 Security review: ${parts.join(', ')}.`; -} - -const result = { findings, summary }; -console.log(JSON.stringify(result, null, 2)); -console.log(`\n${summary}`); -} - -// Only run when executed directly (not imported for testing) -const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] === __filename) { - run(); -} diff --git a/test/pr-readiness.test.ts b/test/pr-readiness.test.ts deleted file mode 100644 index 473f36807..000000000 --- a/test/pr-readiness.test.ts +++ /dev/null @@ -1,1215 +0,0 @@ -/** - * Tests for PR readiness check functions and orchestration. - * Validates each pure check function independently, the checklist builder, - * and the run() orchestrator with mocked fetchFn. - * Issue: #750, PR: #752 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - checkCommitCount, - checkDraftStatus, - checkBranchFreshness, - checkCopilotReview, - checkChangeset, - checkScopeClean, - checkMergeability, - checkCopilotThreads, - checkCIStatus, - checkIssueLinkage, - checkProtectedFiles, - buildChecklist, - buildFileList, - sanitizeFilename, - MAX_FILE_LIST, - classifyScope, - paginate, - run, - COMMENT_MARKER, - SELF_CHECK_NAMES, - SOURCE_PATTERN, - PROTECTED_FILES, -} from '../scripts/pr-readiness.mjs'; - -// --------------------------------------------------------------------------- -// checkCommitCount -// --------------------------------------------------------------------------- - -describe('checkCommitCount', () => { - it('passes with exactly 1 commit', () => { - const result = checkCommitCount(1); - expect(result.pass).toBe(true); - expect(result.detail).toContain('1 commit'); - }); - - it('fails with 0 commits', () => { - const result = checkCommitCount(0); - expect(result.pass).toBe(false); - expect(result.detail).toContain('0 commits'); - }); - - it('fails with multiple commits and suggests squashing', () => { - const result = checkCommitCount(5); - expect(result.pass).toBe(false); - expect(result.detail).toContain('5 commits'); - expect(result.detail).toContain('squashing'); - }); -}); - -// --------------------------------------------------------------------------- -// checkDraftStatus -// --------------------------------------------------------------------------- - -describe('checkDraftStatus', () => { - it('passes when PR is not a draft', () => { - const result = checkDraftStatus(false); - expect(result.pass).toBe(true); - expect(result.detail).toBe('Ready for review'); - }); - - it('fails when PR is a draft', () => { - const result = checkDraftStatus(true); - expect(result.pass).toBe(false); - expect(result.detail).toContain('draft'); - }); -}); - -// --------------------------------------------------------------------------- -// checkBranchFreshness -// --------------------------------------------------------------------------- - -describe('checkBranchFreshness', () => { - it('passes when branch is up to date (behindBy=0)', () => { - const result = checkBranchFreshness(0, 'dev'); - expect(result.pass).toBe(true); - expect(result.detail).toContain('Up to date with dev'); - }); - - it('fails when branch is behind', () => { - const result = checkBranchFreshness(3, 'main'); - expect(result.pass).toBe(false); - expect(result.detail).toContain('3 commit(s) ahead'); - expect(result.detail).toContain('main'); - }); - - it('fails gracefully when comparison failed (null)', () => { - const result = checkBranchFreshness(null, 'dev'); - expect(result.pass).toBe(false); - expect(result.detail).toContain('Could not determine'); - }); -}); - -// --------------------------------------------------------------------------- -// checkCopilotReview -// --------------------------------------------------------------------------- - -describe('checkCopilotReview', () => { - it('passes when copilot approved', () => { - const reviews = [ - { user: { login: 'copilot-pull-request-reviewer' }, state: 'APPROVED', submitted_at: '2025-01-01T00:00:00Z' }, - ]; - const result = checkCopilotReview(reviews); - expect(result.pass).toBe(true); - expect(result.detail).toContain('approved'); - }); - - it('fails when copilot review state is CHANGES_REQUESTED', () => { - const reviews = [ - { user: { login: 'copilot-pull-request-reviewer' }, state: 'CHANGES_REQUESTED', submitted_at: '2025-01-01T00:00:00Z' }, - ]; - const result = checkCopilotReview(reviews); - expect(result.pass).toBe(false); - expect(result.detail).toContain('CHANGES_REQUESTED'); - }); - - it('fails when no copilot review exists', () => { - const result = checkCopilotReview([]); - expect(result.pass).toBe(false); - expect(result.detail).toContain('No Copilot review yet'); - }); - - it('handles null/undefined reviews array', () => { - const result = checkCopilotReview(null); - expect(result.pass).toBe(false); - }); - - it('ignores non-copilot reviews', () => { - const reviews = [ - { user: { login: 'human-reviewer' }, state: 'APPROVED', submitted_at: '2025-01-01T00:00:00Z' }, - ]; - const result = checkCopilotReview(reviews); - expect(result.pass).toBe(false); - expect(result.detail).toContain('No Copilot review yet'); - }); - - it('uses the latest copilot review when multiple exist', () => { - const reviews = [ - { user: { login: 'copilot-pull-request-reviewer' }, state: 'CHANGES_REQUESTED', submitted_at: '2025-01-01T00:00:00Z' }, - { user: { login: 'copilot-pull-request-reviewer' }, state: 'APPROVED', submitted_at: '2025-01-02T00:00:00Z' }, - ]; - const result = checkCopilotReview(reviews); - expect(result.pass).toBe(true); - }); - - it('picks the latest review even when older one is approved', () => { - const reviews = [ - { user: { login: 'copilot-pull-request-reviewer' }, state: 'APPROVED', submitted_at: '2025-01-01T00:00:00Z' }, - { user: { login: 'copilot-pull-request-reviewer' }, state: 'CHANGES_REQUESTED', submitted_at: '2025-01-02T00:00:00Z' }, - ]; - const result = checkCopilotReview(reviews); - expect(result.pass).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// checkChangeset -// --------------------------------------------------------------------------- - -describe('checkChangeset', () => { - it('passes when changeset file exists', () => { - const files = [{ filename: '.changeset/abc.md' }, { filename: 'packages/squad-sdk/src/foo.ts' }]; - const result = checkChangeset(files, []); - expect(result.pass).toBe(true); - expect(result.detail).toContain('Changeset file found'); - }); - - it('passes when CHANGELOG.md is edited', () => { - const files = [{ filename: 'CHANGELOG.md' }, { filename: 'packages/squad-cli/src/bar.ts' }]; - const result = checkChangeset(files, []); - expect(result.pass).toBe(true); - expect(result.detail).toContain('CHANGELOG.md edit found'); - }); - - it('passes when no source files changed', () => { - const files = [{ filename: 'README.md' }, { filename: '.github/workflows/ci.yml' }]; - const result = checkChangeset(files, []); - expect(result.pass).toBe(true); - expect(result.detail).toContain('changeset not required'); - }); - - it('fails when source files changed but no changeset', () => { - const files = [{ filename: 'packages/squad-sdk/src/index.ts' }]; - const result = checkChangeset(files, []); - expect(result.pass).toBe(false); - expect(result.detail).toContain('Missing'); - }); - - it('passes with skip-changelog label', () => { - const files = [{ filename: 'packages/squad-sdk/src/index.ts' }]; - const labels = [{ name: 'skip-changelog' }]; - const result = checkChangeset(files, labels); - expect(result.pass).toBe(true); - expect(result.detail).toContain('skip-changelog'); - }); - - it('prefers changeset detail over skip-changelog when both present', () => { - const files = [{ filename: '.changeset/abc.md' }, { filename: 'packages/squad-sdk/src/x.ts' }]; - const labels = [{ name: 'skip-changelog' }]; - const result = checkChangeset(files, labels); - expect(result.pass).toBe(true); - expect(result.detail).toContain('Changeset file found'); - }); - - it('handles null labels gracefully', () => { - const files = [{ filename: 'packages/squad-sdk/src/index.ts' }]; - const result = checkChangeset(files, null); - expect(result.pass).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// checkScopeClean -// --------------------------------------------------------------------------- - -describe('checkScopeClean', () => { - it('passes with no scope files', () => { - const files = [{ filename: 'packages/squad-sdk/src/index.ts' }, { filename: 'README.md' }]; - const result = checkScopeClean(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('No .squad/ or docs/proposals/ files'); - }); - - it('warns when .squad/ files are present', () => { - const files = [{ filename: '.squad/team.md' }, { filename: 'packages/squad-sdk/src/index.ts' }]; - const result = checkScopeClean(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('1 .squad/ file(s)'); - expect(result.detail).toContain('ensure these are intentional'); - }); - - it('warns when docs/proposals/ files are present', () => { - const files = [{ filename: 'docs/proposals/my-proposal.md' }]; - const result = checkScopeClean(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('1 docs/proposals/ file(s)'); - expect(result.detail).toContain('ensure these are intentional'); - }); - - it('warns with both .squad/ and docs/proposals/ counts', () => { - const files = [ - { filename: '.squad/team.md' }, - { filename: '.squad/routing.md' }, - { filename: 'docs/proposals/pr-readiness-checks.md' }, - ]; - const result = checkScopeClean(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('2 .squad/ file(s)'); - expect(result.detail).toContain('1 docs/proposals/ file(s)'); - }); - - it('catches nested .squad/ paths', () => { - const files = [{ filename: '.squad/agents/eecom/history.md' }]; - const result = checkScopeClean(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('1 .squad/ file(s)'); - }); - - it('handles null/undefined files gracefully', () => { - const result = checkScopeClean(null); - expect(result.pass).toBe(true); - expect(result.detail).toContain('No .squad/ or docs/proposals/ files'); - }); -}); - -// --------------------------------------------------------------------------- -// checkMergeability -// --------------------------------------------------------------------------- - -describe('checkMergeability', () => { - it('passes when mergeable is true', () => { - const result = checkMergeability(true); - expect(result.pass).toBe(true); - expect(result.detail).toContain('No merge conflicts'); - }); - - it('fails when mergeable is false', () => { - const result = checkMergeability(false); - expect(result.pass).toBe(false); - expect(result.detail).toContain('Merge conflicts'); - }); - - it('passes (not penalized) when mergeable is null', () => { - const result = checkMergeability(null); - expect(result.pass).toBe(true); - expect(result.detail).toContain('unknown'); - }); -}); - -// --------------------------------------------------------------------------- -// checkCIStatus -// --------------------------------------------------------------------------- - -describe('checkCIStatus', () => { - it('passes when all checks succeed', () => { - const checks = [ - { name: 'build', conclusion: 'success', status: 'completed' }, - { name: 'lint', conclusion: 'success', status: 'completed' }, - ]; - const result = checkCIStatus(checks, [{ state: 'success' }]); - expect(result.pass).toBe(true); - expect(result.detail).toContain('All checks passing'); - }); - - it('fails when checks are failing', () => { - const checks = [ - { name: 'build', conclusion: 'failure', status: 'completed' }, - { name: 'lint', conclusion: 'success', status: 'completed' }, - ]; - const result = checkCIStatus(checks, []); - expect(result.pass).toBe(false); - expect(result.detail).toContain('1 check(s) failing'); - expect(result.detail).toContain('build'); - }); - - it('fails when checks are pending', () => { - const checks = [ - { name: 'build', conclusion: null, status: 'in_progress' }, - ]; - const result = checkCIStatus(checks, []); - expect(result.pass).toBe(false); - expect(result.detail).toContain('still running'); - }); - - it('fails when no checks have run', () => { - const result = checkCIStatus([], []); - expect(result.pass).toBe(false); - expect(result.detail).toContain('No CI checks have run yet'); - }); - - it('filters out self check runs', () => { - const checks = [ - { name: 'readiness', conclusion: 'success', status: 'completed' }, - { name: 'PR Readiness Check', conclusion: 'success', status: 'completed' }, - ]; - const result = checkCIStatus(checks, []); - expect(result.pass).toBe(false); - expect(result.detail).toContain('No CI checks have run yet'); - }); - - it('reports cancelled checks as failures', () => { - const checks = [ - { name: 'build', conclusion: 'cancelled', status: 'completed' }, - ]; - const result = checkCIStatus(checks, []); - expect(result.pass).toBe(false); - expect(result.detail).toContain('failing'); - }); - - it('handles null checkRuns/statuses gracefully', () => { - const result = checkCIStatus(null, null); - expect(result.pass).toBe(false); - expect(result.detail).toContain('No CI checks have run yet'); - }); - - it('passes when only statuses exist (no check runs)', () => { - const result = checkCIStatus([], [{ state: 'success' }]); - expect(result.pass).toBe(true); - expect(result.detail).toContain('All checks passing'); - }); -}); - -// --------------------------------------------------------------------------- -// checkCopilotThreads -// --------------------------------------------------------------------------- - -describe('checkCopilotThreads', () => { - const copilotThread = (resolved: boolean, outdated = false) => ({ - isResolved: resolved, - isOutdated: outdated, - comments: { nodes: [{ author: { login: 'copilot-pull-request-reviewer' } }] }, - }); - - const humanThread = (resolved: boolean) => ({ - isResolved: resolved, - comments: { nodes: [{ author: { login: 'some-human' } }] }, - }); - - it('passes when all copilot threads are resolved', () => { - const threads = [copilotThread(true), copilotThread(true)]; - const result = checkCopilotThreads(threads); - expect(result.pass).toBe(true); - expect(result.detail).toContain('All 2 Copilot thread(s) resolved'); - }); - - it('passes when no copilot threads exist', () => { - const result = checkCopilotThreads([]); - expect(result.pass).toBe(true); - expect(result.detail).toBe('No Copilot review threads'); - }); - - it('passes with null/undefined input', () => { - expect(checkCopilotThreads(null).pass).toBe(true); - expect(checkCopilotThreads(undefined).pass).toBe(true); - }); - - it('fails when unresolved copilot threads exist', () => { - const threads = [copilotThread(true), copilotThread(false), copilotThread(false)]; - const result = checkCopilotThreads(threads); - expect(result.pass).toBe(false); - expect(result.detail).toContain('2 unresolved Copilot thread(s)'); - }); - - it('ignores non-copilot threads', () => { - const threads = [humanThread(false), copilotThread(true)]; - const result = checkCopilotThreads(threads); - expect(result.pass).toBe(true); - expect(result.detail).toContain('All 1 Copilot thread(s) resolved'); - }); - - it('passes when unresolved threads are outdated', () => { - const threads = [copilotThread(true), copilotThread(false, true)]; - const result = checkCopilotThreads(threads); - expect(result.pass).toBe(true); - expect(result.detail).not.toContain('unresolved'); - }); - - it('handles mix of resolved, unresolved, and outdated threads', () => { - const threads = [ - copilotThread(true), // resolved - copilotThread(false), // unresolved (active) - copilotThread(false, true), // outdated (skipped) - ]; - const result = checkCopilotThreads(threads); - expect(result.pass).toBe(false); - expect(result.detail).toContain('1 unresolved'); - }); - - it('includes "outdated skipped" in success message when applicable', () => { - const threads = [ - copilotThread(true), // resolved - copilotThread(true), // resolved - copilotThread(false, true), // outdated - ]; - const result = checkCopilotThreads(threads); - expect(result.pass).toBe(true); - expect(result.detail).toContain('2 active Copilot thread(s) resolved'); - expect(result.detail).toContain('1 outdated skipped'); - }); - - it('handles threads with missing comment data', () => { - const threads = [ - { isResolved: false, comments: { nodes: [] } }, - { isResolved: false, comments: null }, - copilotThread(true), - ]; - const result = checkCopilotThreads(threads); - expect(result.pass).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// buildChecklist -// --------------------------------------------------------------------------- -// checkIssueLinkage -// --------------------------------------------------------------------------- - -describe('checkIssueLinkage', () => { - it('passes when PR body has Closes #N', () => { - const result = checkIssueLinkage('Closes #42', []); - expect(result.pass).toBe(true); - expect(result.detail).toContain('Issue reference found'); - }); - - it('passes when PR body has Fixes #N (case-insensitive)', () => { - const result = checkIssueLinkage('fixes #100', []); - expect(result.pass).toBe(true); - }); - - it('passes when PR body has Resolves #N', () => { - const result = checkIssueLinkage('Resolves #7', []); - expect(result.pass).toBe(true); - }); - - it('passes when PR body has Part of #N', () => { - const result = checkIssueLinkage('Part of #55', []); - expect(result.pass).toBe(true); - }); - - it('passes when commit message has issue reference', () => { - const commits = [{ commit: { message: 'fix: thing\n\nCloses #10' } }]; - const result = checkIssueLinkage('', commits); - expect(result.pass).toBe(true); - }); - - it('fails when neither body nor commits have issue reference', () => { - const commits = [{ commit: { message: 'update docs' } }]; - const result = checkIssueLinkage('Some description', commits); - expect(result.pass).toBe(false); - expect(result.detail).toContain('No issue reference'); - }); - - it('handles null/empty inputs gracefully', () => { - const result = checkIssueLinkage(null as unknown as string, null as unknown as []); - expect(result.pass).toBe(false); - }); - - it('handles empty string body with empty commits', () => { - const result = checkIssueLinkage('', []); - expect(result.pass).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// checkProtectedFiles -// --------------------------------------------------------------------------- - -describe('checkProtectedFiles', () => { - it('passes when no protected files are changed', () => { - const files = [{ filename: 'packages/squad-sdk/src/index.ts' }]; - const result = checkProtectedFiles(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('No protected bootstrap files'); - }); - - it('passes with warning when protected file is changed', () => { - const files = [ - { filename: 'packages/squad-cli/src/cli/core/errors.ts' }, - { filename: 'packages/squad-sdk/src/index.ts' }, - ]; - const result = checkProtectedFiles(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('⚠️'); - expect(result.detail).toContain('errors.ts'); - expect(result.detail).toContain('zero-dependency'); - }); - - it('counts multiple protected files', () => { - const files = [ - { filename: 'packages/squad-cli/src/cli/core/errors.ts' }, - { filename: 'packages/squad-cli/src/cli/core/output.ts' }, - ]; - const result = checkProtectedFiles(files); - expect(result.pass).toBe(true); - expect(result.detail).toContain('2 protected bootstrap file(s)'); - }); - - it('handles null/undefined files gracefully', () => { - const result = checkProtectedFiles(null as unknown as []); - expect(result.pass).toBe(true); - expect(result.detail).toContain('No protected bootstrap files'); - }); - - it('exports PROTECTED_FILES constant', () => { - expect(PROTECTED_FILES).toBeDefined(); - expect(PROTECTED_FILES.length).toBeGreaterThan(0); - expect(PROTECTED_FILES).toContain('packages/squad-cli/src/cli/core/errors.ts'); - }); -}); - -// --------------------------------------------------------------------------- -// buildChecklist -// --------------------------------------------------------------------------- - -describe('buildChecklist', () => { - it('includes the comment marker', () => { - const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; - const body = buildChecklist(checks, 'owner', 'repo', 'dev'); - expect(body).toContain(COMMENT_MARKER); - }); - - it('shows ready status when all pass', () => { - const checks = [ - { name: 'Single commit', pass: true, detail: '1 commit' }, - { name: 'Not in draft', pass: true, detail: 'Ready' }, - ]; - const body = buildChecklist(checks, 'owner', 'repo', 'dev'); - expect(body).toContain('✅ PR is ready for review'); - }); - - it('shows warning status when some fail', () => { - const checks = [ - { name: 'Single commit', pass: false, detail: '3 commits' }, - { name: 'Not in draft', pass: true, detail: 'Ready' }, - ]; - const body = buildChecklist(checks, 'owner', 'repo', 'dev'); - expect(body).toContain('⚠️ 1 item(s) to address'); - }); - - it('includes links to CONTRIBUTING.md and PR_REQUIREMENTS.md', () => { - const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; - const body = buildChecklist(checks, 'myorg', 'myrepo', 'main'); - expect(body).toContain('https://github.com/myorg/myrepo/blob/main/CONTRIBUTING.md'); - expect(body).toContain('https://github.com/myorg/myrepo/blob/main/.github/PR_REQUIREMENTS.md'); - }); - - it('renders a table row for each check', () => { - const checks = [ - { name: 'A', pass: true, detail: 'OK' }, - { name: 'B', pass: false, detail: 'Bad' }, - ]; - const body = buildChecklist(checks, 'o', 'r', 'dev'); - expect(body).toContain('| ✅ | **A** | OK |'); - expect(body).toContain('| ❌ | **B** | Bad |'); - }); - - it('includes file list when files are provided', () => { - const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; - const files = [ - { filename: 'src/index.ts', additions: 10, deletions: 3 }, - { filename: 'README.md', additions: 2, deletions: 1 }, - ]; - const body = buildChecklist(checks, 'o', 'r', 'dev', undefined, files); - expect(body).toContain('### Files Changed (2 files, +12 −4)'); - expect(body).toContain('| `src/index.ts` | +10 −3 |'); - expect(body).toContain('| `README.md` | +2 −1 |'); - expect(body).toContain('**Total: +12 −4**'); - }); - - it('omits file list when files are undefined', () => { - const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; - const body = buildChecklist(checks, 'o', 'r', 'dev'); - expect(body).not.toContain('Files Changed'); - }); - - it('omits file list when files array is empty', () => { - const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; - const body = buildChecklist(checks, 'o', 'r', 'dev', undefined, []); - expect(body).not.toContain('Files Changed'); - }); -}); - -// --------------------------------------------------------------------------- -// buildFileList -// --------------------------------------------------------------------------- - -describe('buildFileList', () => { - it('returns empty string for null/undefined input', () => { - expect(buildFileList(null)).toBe(''); - expect(buildFileList(undefined)).toBe(''); - }); - - it('returns empty string for empty array', () => { - expect(buildFileList([])).toBe(''); - }); - - it('renders a single file with correct stats', () => { - const files = [{ filename: 'scripts/moderate-spam.mjs', additions: 142, deletions: 0 }]; - const result = buildFileList(files); - expect(result).toContain('### Files Changed (1 file, +142 −0)'); - expect(result).toContain('| `scripts/moderate-spam.mjs` | +142 −0 |'); - expect(result).toContain('**Total: +142 −0**'); - }); - - it('renders multiple files with totals', () => { - const files = [ - { filename: 'scripts/moderate-spam.mjs', additions: 142, deletions: 0 }, - { filename: 'test/scripts/moderate-spam.test.ts', additions: 98, deletions: 0 }, - { filename: '.github/workflows/squad-comment-moderation.yml', additions: 45, deletions: 0 }, - ]; - const result = buildFileList(files); - expect(result).toContain('### Files Changed (3 files, +285 −0)'); - expect(result).toContain('| `scripts/moderate-spam.mjs` | +142 −0 |'); - expect(result).toContain('| `test/scripts/moderate-spam.test.ts` | +98 −0 |'); - expect(result).toContain('| `.github/workflows/squad-comment-moderation.yml` | +45 −0 |'); - expect(result).toContain('**Total: +285 −0**'); - }); - - it('handles files with 0 additions and 0 deletions', () => { - const files = [{ filename: 'empty-change.ts', additions: 0, deletions: 0 }]; - const result = buildFileList(files); - expect(result).toContain('| `empty-change.ts` | +0 −0 |'); - expect(result).toContain('**Total: +0 −0**'); - }); - - it('handles files with both additions and deletions', () => { - const files = [ - { filename: 'src/refactored.ts', additions: 50, deletions: 30 }, - { filename: 'src/old.ts', additions: 0, deletions: 100 }, - ]; - const result = buildFileList(files); - expect(result).toContain('### Files Changed (2 files, +50 −130)'); - expect(result).toContain('| `src/refactored.ts` | +50 −30 |'); - expect(result).toContain('| `src/old.ts` | +0 −100 |'); - expect(result).toContain('**Total: +50 −130**'); - }); - - it('treats missing additions/deletions as 0', () => { - const files = [{ filename: 'binary-file.png' }]; - const result = buildFileList(files); - expect(result).toContain('| `binary-file.png` | +0 −0 |'); - expect(result).toContain('**Total: +0 −0**'); - }); - - it('uses singular "file" for single file', () => { - const files = [{ filename: 'one.ts', additions: 1, deletions: 0 }]; - const result = buildFileList(files); - expect(result).toContain('1 file,'); - expect(result).not.toContain('1 files,'); - }); - - it('includes table headers', () => { - const files = [{ filename: 'a.ts', additions: 1, deletions: 0 }]; - const result = buildFileList(files); - expect(result).toContain('| File | +/− |'); - expect(result).toContain('|------|-----|'); - }); - - it('truncates file list beyond MAX_FILE_LIST and shows summary row', () => { - const files = Array.from({ length: 60 }, (_, i) => ({ - filename: `src/file-${i}.ts`, - additions: 1, - deletions: 0, - })); - const result = buildFileList(files); - // Header should show the total count (60), not the truncated count - expect(result).toContain('### Files Changed (60 files, +60 −0)'); - // First 50 files should be present - expect(result).toContain('`src/file-0.ts`'); - expect(result).toContain('`src/file-49.ts`'); - // File 50 should NOT be present - expect(result).not.toContain('`src/file-50.ts`'); - // Summary row - expect(result).toContain('**+10 more files**'); - // Totals should include ALL files - expect(result).toContain('**Total: +60 −0**'); - }); - - it('does not truncate when file count equals MAX_FILE_LIST', () => { - const files = Array.from({ length: MAX_FILE_LIST }, (_, i) => ({ - filename: `src/file-${i}.ts`, - additions: 1, - deletions: 0, - })); - const result = buildFileList(files); - expect(result).not.toContain('more files'); - expect(result).toContain(`${MAX_FILE_LIST} files`); - }); - - it('sanitizes pipe characters in filenames', () => { - const files = [{ filename: 'path/with|pipe.ts', additions: 1, deletions: 0 }]; - const result = buildFileList(files); - expect(result).toContain("path/with\\|pipe.ts"); - expect(result).not.toContain('| path/with|pipe.ts'); - }); - - it('sanitizes backticks in filenames', () => { - const files = [{ filename: 'file`name.ts', additions: 1, deletions: 0 }]; - const result = buildFileList(files); - expect(result).toContain("file'name.ts"); - }); - - it('sanitizes newlines in filenames', () => { - const files = [{ filename: 'file\nname.ts', additions: 1, deletions: 0 }]; - const result = buildFileList(files); - expect(result).toContain('file name.ts'); - // The sanitized filename should not contain the literal newline - expect(result).not.toContain('file\nname.ts'); - }); -}); - -// --------------------------------------------------------------------------- -// sanitizeFilename -// --------------------------------------------------------------------------- - -describe('sanitizeFilename', () => { - it('escapes pipe characters', () => { - expect(sanitizeFilename('a|b|c')).toBe('a\\|b\\|c'); - }); - - it('replaces backticks with single quotes', () => { - expect(sanitizeFilename('file`name`test')).toBe("file'name'test"); - }); - - it('replaces newlines with spaces', () => { - expect(sanitizeFilename('line1\nline2\r\nline3')).toBe('line1 line2 line3'); - }); - - it('handles all special characters together', () => { - expect(sanitizeFilename('a|b`c\nd')).toBe("a\\|b'c d"); - }); - - it('returns normal filenames unchanged', () => { - expect(sanitizeFilename('src/components/App.tsx')).toBe('src/components/App.tsx'); - }); -}); - -// --------------------------------------------------------------------------- -// classifyScope -// --------------------------------------------------------------------------- - -describe('classifyScope', () => { - it('returns Infrastructure for only infrastructure files', () => { - const files = [ - { filename: '.github/workflows/ci.yml' }, - { filename: 'scripts/build.mjs' }, - { filename: 'test/foo.test.ts' }, - ]; - const result = classifyScope(files); - expect(result.label).toBe('Infrastructure'); - expect(result.emoji).toBe('🔧'); - }); - - it('returns Product for only product source files', () => { - const files = [ - { filename: 'packages/squad-sdk/src/index.ts' }, - { filename: 'packages/squad-cli/src/main.ts' }, - ]; - const result = classifyScope(files); - expect(result.label).toBe('Product'); - expect(result.emoji).toBe('📦'); - }); - - it('returns Mixed for both product and infrastructure files', () => { - const files = [ - { filename: 'packages/squad-sdk/src/index.ts' }, - { filename: 'scripts/build.mjs' }, - ]; - const result = classifyScope(files); - expect(result.label).toBe('Mixed (product + infrastructure)'); - expect(result.emoji).toBe('📦🔧'); - }); - - it('returns Infrastructure for empty array', () => { - const result = classifyScope([]); - expect(result.label).toBe('Infrastructure'); - expect(result.emoji).toBe('🔧'); - }); -}); - -// --------------------------------------------------------------------------- -// paginate -// --------------------------------------------------------------------------- - -describe('paginate', () => { - it('collects items from a single page', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => [{ id: 1 }, { id: 2 }], - headers: new Map([['link', '']]), - }); - const items = await paginate(mockFetch, 'https://api.example.com/items', {}); - expect(items).toHaveLength(2); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('follows pagination links', async () => { - const mockFetch = vi.fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => [{ id: 1 }], - headers: new Map([['link', '; rel="next"']]), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [{ id: 2 }], - headers: new Map([['link', '']]), - }); - const items = await paginate(mockFetch, 'https://api.example.com/items', {}); - expect(items).toHaveLength(2); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('throws on non-ok response', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 404, - headers: new Map(), - }); - await expect(paginate(mockFetch, 'https://api.example.com/items', {})).rejects.toThrow('404'); - }); - - it('extracts check_runs from wrapped response', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ check_runs: [{ id: 1 }, { id: 2 }] }), - headers: new Map([['link', '']]), - }); - const items = await paginate(mockFetch, 'https://api.example.com/check-runs', {}); - expect(items).toHaveLength(2); - }); -}); - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -describe('constants', () => { - it('SELF_CHECK_NAMES contains expected values', () => { - expect(SELF_CHECK_NAMES).toContain('readiness'); - expect(SELF_CHECK_NAMES).toContain('PR Readiness Check'); - }); - - it('SOURCE_PATTERN matches SDK source files', () => { - expect(SOURCE_PATTERN.test('packages/squad-sdk/src/index.ts')).toBe(true); - }); - - it('SOURCE_PATTERN matches CLI source files', () => { - expect(SOURCE_PATTERN.test('packages/squad-cli/src/cli.ts')).toBe(true); - }); - - it('SOURCE_PATTERN does not match non-source files', () => { - expect(SOURCE_PATTERN.test('README.md')).toBe(false); - expect(SOURCE_PATTERN.test('.github/workflows/ci.yml')).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// run() orchestration -// --------------------------------------------------------------------------- - -describe('run()', () => { - const baseEnv = { - GITHUB_TOKEN: 'test-token', - PR_NUMBER: '42', - PR_DRAFT: 'false', - PR_HEAD_SHA: 'abc123', - PR_BASE_REF: 'dev', - REPO_OWNER: 'testorg', - REPO_NAME: 'testrepo', - RUN_NAME: 'Squad PR Readiness', - PR_LABELS: '[]', - }; - - function createMockFetch(overrides = {}) { - const defaults = { - commits: [{ sha: 'abc123', commit: { message: 'fix: thing\n\nCloses #42' } }], - compare: { behind_by: 0 }, - reviews: [{ user: { login: 'copilot-pull-request-reviewer' }, state: 'APPROVED', submitted_at: '2025-01-01T00:00:00Z' }], - files: [{ filename: '.changeset/feat.md', additions: 5, deletions: 0 }], - pr: { mergeable: true, body: 'Closes #42' }, - checkRuns: { check_runs: [{ name: 'build', conclusion: 'success', status: 'completed' }] }, - status: { statuses: [{ state: 'success' }] }, - comments: [], - reviewThreads: { - data: { - repository: { - pullRequest: { - reviewThreads: { - nodes: [{ - isResolved: true, - comments: { nodes: [{ author: { login: 'copilot-pull-request-reviewer' } }] }, - }], - }, - }, - }, - }, - }, - }; - const data = { ...defaults, ...overrides }; - - return vi.fn().mockImplementation(async (url, opts) => { - const headers = new Map([['link', '']]); - const ok = (json) => ({ ok: true, json: async () => json, headers }); - - // GraphQL endpoint for review threads - if (url === 'https://api.github.com/graphql') return ok(data.reviewThreads); - - if (url.includes('/commits?')) return ok(data.commits); - if (url.includes('/compare/')) return ok(data.compare); - if (url.includes('/reviews?')) return ok(data.reviews); - if (url.includes('/files?')) return ok(data.files); - if (url.includes('/check-runs?')) return ok(data.checkRuns); - if (url.includes('/status')) return ok(data.status); - if (url.includes('/comments?')) return ok(data.comments); - if (url.match(/\/pulls\/\d+$/)) return ok(data.pr); - - // POST/PATCH for comment upsert - if (url.includes('/comments')) return ok({ id: 999 }); - - return ok({}); - }); - } - - it('creates a comment when none exists (all checks pass)', async () => { - const mockFetch = createMockFetch(); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - - expect(result.action).toBe('created'); - expect(result.checks).toHaveLength(11); - expect(result.checks.every((c) => c.pass)).toBe(true); - - // Verify POST was called for comment creation (not PATCH) - const postCalls = mockFetch.mock.calls.filter( - ([url, opts]) => opts && opts.method === 'POST' && url.includes('/issues/'), - ); - expect(postCalls.length).toBe(1); - expect(postCalls[0][0]).toContain('/issues/42/comments'); - }); - - it('updates an existing comment', async () => { - const mockFetch = createMockFetch({ - comments: [{ id: 123, body: `${COMMENT_MARKER}\nold body` }], - }); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - - expect(result.action).toBe('updated'); - const patchCalls = mockFetch.mock.calls.filter( - ([url, opts]) => opts && opts.method === 'PATCH', - ); - expect(patchCalls.length).toBe(1); - expect(patchCalls[0][0]).toContain('/issues/comments/123'); - }); - - it('marks draft PRs as failing', async () => { - const mockFetch = createMockFetch(); - const env = { ...baseEnv, PR_DRAFT: 'true' }; - const result = await run({ env, fetchFn: mockFetch }); - - const draftCheck = result.checks.find((c) => c.name === 'Not in draft'); - expect(draftCheck.pass).toBe(false); - }); - - it('handles multiple commits', async () => { - const mockFetch = createMockFetch({ - commits: [{ sha: '1' }, { sha: '2' }, { sha: '3' }], - }); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - - const commitCheck = result.checks.find((c) => c.name === 'Single commit'); - expect(commitCheck.pass).toBe(false); - expect(commitCheck.detail).toContain('3 commits'); - }); - - it('handles branch behind base', async () => { - const mockFetch = createMockFetch({ compare: { behind_by: 5 } }); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - - const branchCheck = result.checks.find((c) => c.name === 'Branch up to date'); - expect(branchCheck.pass).toBe(false); - expect(branchCheck.detail).toContain('5 commit(s) ahead'); - }); - - it('handles failed branch comparison gracefully', async () => { - const mockFetch = createMockFetch(); - mockFetch.mockImplementation(async (url) => { - const headers = new Map([['link', '']]); - const ok = (json) => ({ ok: true, json: async () => json, headers }); - if (url === 'https://api.github.com/graphql') return ok({ data: { repository: { pullRequest: { reviewThreads: { nodes: [] } } } } }); - if (url.includes('/compare/')) return { ok: false, status: 404, headers }; - if (url.includes('/commits?')) return ok([{ sha: '1' }]); - if (url.includes('/reviews?')) return ok([]); - if (url.includes('/files?')) return ok([]); - if (url.includes('/check-runs?')) return ok({ check_runs: [] }); - if (url.includes('/status')) return ok({ statuses: [] }); - if (url.includes('/comments?')) return ok([]); - if (url.match(/\/pulls\/\d+$/)) return ok({ mergeable: true }); - if (url.includes('/comments')) return ok({ id: 1 }); - return ok({}); - }); - - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - const branchCheck = result.checks.find((c) => c.name === 'Branch up to date'); - expect(branchCheck.pass).toBe(false); - expect(branchCheck.detail).toContain('Could not determine'); - }); - - it('passes PR labels through for changeset check', async () => { - const mockFetch = createMockFetch({ - files: [{ filename: 'packages/squad-sdk/src/foo.ts' }], - }); - const env = { - ...baseEnv, - PR_LABELS: JSON.stringify([{ name: 'skip-changelog' }]), - }; - const result = await run({ env, fetchFn: mockFetch }); - - const changesetCheck = result.checks.find((c) => c.name === 'Changeset present'); - expect(changesetCheck.pass).toBe(true); - expect(changesetCheck.detail).toContain('skip-changelog'); - }); - - it('handles invalid PR_LABELS JSON gracefully', async () => { - const mockFetch = createMockFetch({ - files: [{ filename: 'packages/squad-sdk/src/foo.ts' }], - }); - const env = { ...baseEnv, PR_LABELS: 'not-json' }; - const result = await run({ env, fetchFn: mockFetch }); - - const changesetCheck = result.checks.find((c) => c.name === 'Changeset present'); - expect(changesetCheck).toBeDefined(); - }); - - it('handles merge conflict detection', async () => { - const mockFetch = createMockFetch({ pr: { mergeable: false } }); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - - const mergeCheck = result.checks.find((c) => c.name === 'No merge conflicts'); - expect(mergeCheck.pass).toBe(false); - expect(mergeCheck.detail).toContain('Merge conflicts'); - }); - - it('handles CI failures', async () => { - const mockFetch = createMockFetch({ - checkRuns: { check_runs: [{ name: 'build', conclusion: 'failure', status: 'completed' }] }, - }); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - - const ciCheck = result.checks.find((c) => c.name === 'CI passing'); - expect(ciCheck.pass).toBe(false); - expect(ciCheck.detail).toContain('failing'); - }); - - it('handles empty reviews (no copilot review)', async () => { - const mockFetch = createMockFetch({ reviews: [] }); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - - const copilotCheck = result.checks.find((c) => c.name === 'Copilot review'); - expect(copilotCheck.pass).toBe(false); - expect(copilotCheck.detail).toContain('No Copilot review yet'); - }); - - it('always produces 11 checks', async () => { - const mockFetch = createMockFetch(); - const result = await run({ env: baseEnv, fetchFn: mockFetch }); - expect(result.checks).toHaveLength(11); - - const names = result.checks.map((c) => c.name); - expect(names).toEqual([ - 'Single commit', - 'Not in draft', - 'Branch up to date', - 'Copilot review', - 'Changeset present', - 'Scope clean', - 'No merge conflicts', - 'Copilot threads resolved', - 'CI passing', - 'Issue linked', - 'Protected files', - ]); - }); - - it('includes comment marker in upserted body', async () => { - const mockFetch = createMockFetch(); - await run({ env: baseEnv, fetchFn: mockFetch }); - - const postCall = mockFetch.mock.calls.find( - ([url, opts]) => opts && opts.method === 'POST' && url.includes('/comments'), - ); - expect(postCall).toBeDefined(); - const body = JSON.parse(postCall[1].body).body; - expect(body).toContain(COMMENT_MARKER); - }); - - // ------------------------------------------------------------------------- - // API-based draft status override (line 389: prData?.draft ?? prDraft) - // ------------------------------------------------------------------------- - - it('uses API draft value (true) over env PR_DRAFT=false', async () => { - const mockFetch = createMockFetch({ pr: { draft: true, mergeable: true } }); - const env = { ...baseEnv, PR_DRAFT: 'false' }; - const result = await run({ env, fetchFn: mockFetch }); - - const draftCheck = result.checks.find((c) => c.name === 'Not in draft'); - expect(draftCheck.pass).toBe(false); - expect(draftCheck.detail).toContain('draft'); - }); - - it('uses API draft value (false) over env PR_DRAFT=true', async () => { - const mockFetch = createMockFetch({ pr: { draft: false, mergeable: true } }); - const env = { ...baseEnv, PR_DRAFT: 'true' }; - const result = await run({ env, fetchFn: mockFetch }); - - const draftCheck = result.checks.find((c) => c.name === 'Not in draft'); - expect(draftCheck.pass).toBe(true); - expect(draftCheck.detail).toBe('Ready for review'); - }); - - it('falls back to env PR_DRAFT when PR API fetch fails', async () => { - const mockFetch = createMockFetch(); - mockFetch.mockImplementation(async (url, opts) => { - const headers = new Map([['link', '']]); - const ok = (json) => ({ ok: true, json: async () => json, headers }); - - if (url === 'https://api.github.com/graphql') return ok({ data: { repository: { pullRequest: { reviewThreads: { nodes: [] } } } } }); - // PR endpoint fails - if (url.match(/\/pulls\/\d+$/)) return { ok: false, status: 500, headers }; - if (url.includes('/commits?')) return ok([{ sha: '1' }]); - if (url.includes('/compare/')) return ok({ behind_by: 0 }); - if (url.includes('/reviews?')) return ok([]); - if (url.includes('/files?')) return ok([]); - if (url.includes('/check-runs?')) return ok({ check_runs: [] }); - if (url.includes('/status')) return ok({ statuses: [] }); - if (url.includes('/comments?')) return ok([]); - if (url.includes('/comments')) return ok({ id: 1 }); - return ok({}); - }); - - const env = { ...baseEnv, PR_DRAFT: 'true' }; - const result = await run({ env, fetchFn: mockFetch }); - - const draftCheck = result.checks.find((c) => c.name === 'Not in draft'); - expect(draftCheck.pass).toBe(false); - expect(draftCheck.detail).toContain('draft'); - }); - - it('includes file list with line stats in upserted comment', async () => { - const mockFetch = createMockFetch({ - files: [ - { filename: 'src/index.ts', additions: 25, deletions: 10 }, - { filename: 'test/index.test.ts', additions: 50, deletions: 0 }, - ], - }); - await run({ env: baseEnv, fetchFn: mockFetch }); - - const postCall = mockFetch.mock.calls.find( - ([url, opts]) => opts && opts.method === 'POST' && url.includes('/comments'), - ); - expect(postCall).toBeDefined(); - const body = JSON.parse(postCall[1].body).body; - expect(body).toContain('### Files Changed (2 files, +75 −10)'); - expect(body).toContain('| `src/index.ts` | +25 −10 |'); - expect(body).toContain('| `test/index.test.ts` | +50 −0 |'); - expect(body).toContain('**Total: +75 −10**'); - }); -}); diff --git a/test/scripts/parse-diff.test.ts b/test/scripts/parse-diff.test.ts deleted file mode 100644 index a66c74028..000000000 --- a/test/scripts/parse-diff.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseDiffNames, enrichFileStatuses } from '../../scripts/impact-utils/parse-diff.mjs'; - -// ── parseDiffNames ────────────────────────────────────────────────────── - -describe('parseDiffNames', () => { - it('parses a normal multi-line diff output', () => { - const result = parseDiffNames('src/a.ts\nsrc/b.ts\n'); - expect(result.all).toEqual(['src/a.ts', 'src/b.ts']); - expect(result.modified).toEqual(['src/a.ts', 'src/b.ts']); - expect(result.added).toEqual([]); - expect(result.deleted).toEqual([]); - }); - - it('returns empty arrays for empty string input', () => { - const result = parseDiffNames(''); - expect(result.all).toEqual([]); - expect(result.modified).toEqual([]); - }); - - it('returns empty arrays for whitespace-only input', () => { - const result = parseDiffNames(' \n \n '); - expect(result.all).toEqual([]); - }); - - it('handles trailing newlines without creating empty entries', () => { - const result = parseDiffNames('file.ts\n\n\n'); - expect(result.all).toEqual(['file.ts']); - }); - - it('trims leading/trailing whitespace from filenames', () => { - const result = parseDiffNames(' src/a.ts \n src/b.ts \n'); - expect(result.all).toEqual(['src/a.ts', 'src/b.ts']); - }); - - it('handles a single file with no trailing newline', () => { - const result = parseDiffNames('only-file.ts'); - expect(result.all).toEqual(['only-file.ts']); - }); -}); - -// ── enrichFileStatuses ────────────────────────────────────────────────── - -describe('enrichFileStatuses', () => { - it('classifies added files', () => { - const result = enrichFileStatuses([{ filename: 'new.ts', status: 'added' }]); - expect(result.added).toEqual(['new.ts']); - expect(result.modified).toEqual([]); - expect(result.deleted).toEqual([]); - expect(result.all).toEqual(['new.ts']); - }); - - it('classifies removed files', () => { - const result = enrichFileStatuses([{ filename: 'old.ts', status: 'removed' }]); - expect(result.deleted).toEqual(['old.ts']); - expect(result.added).toEqual([]); - }); - - it('classifies modified files', () => { - const result = enrichFileStatuses([{ filename: 'mod.ts', status: 'modified' }]); - expect(result.modified).toEqual(['mod.ts']); - }); - - it('classifies renamed files as modified', () => { - const result = enrichFileStatuses([{ filename: 'renamed.ts', status: 'renamed' }]); - expect(result.modified).toEqual(['renamed.ts']); - }); - - it('classifies copied files as modified', () => { - const result = enrichFileStatuses([{ filename: 'copy.ts', status: 'copied' }]); - expect(result.modified).toEqual(['copy.ts']); - }); - - it('classifies changed files as modified', () => { - const result = enrichFileStatuses([{ filename: 'chg.ts', status: 'changed' }]); - expect(result.modified).toEqual(['chg.ts']); - }); - - it('handles empty array input', () => { - const result = enrichFileStatuses([]); - expect(result.all).toEqual([]); - expect(result.added).toEqual([]); - expect(result.modified).toEqual([]); - expect(result.deleted).toEqual([]); - }); - - it('handles a mix of all statuses', () => { - const result = enrichFileStatuses([ - { filename: 'a.ts', status: 'added' }, - { filename: 'b.ts', status: 'removed' }, - { filename: 'c.ts', status: 'modified' }, - { filename: 'd.ts', status: 'renamed' }, - ]); - expect(result.added).toEqual(['a.ts']); - expect(result.deleted).toEqual(['b.ts']); - expect(result.modified).toEqual(['c.ts', 'd.ts']); - expect(result.all).toEqual(['a.ts', 'b.ts', 'c.ts', 'd.ts']); - }); -}); diff --git a/test/scripts/risk-scorer.test.ts b/test/scripts/risk-scorer.test.ts deleted file mode 100644 index 957a6c6c5..000000000 --- a/test/scripts/risk-scorer.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { calculateRisk } from '../../scripts/impact-utils/risk-scorer.mjs'; - -const defaults = { filesChanged: 1, filesDeleted: 0, modulesTouched: 1, criticalFiles: [] }; - -describe('calculateRisk', () => { - // ── Files-changed thresholds ────────────────────────────────────────── - - it('returns LOW when filesChanged ≤ 5', () => { - const { tier } = calculateRisk({ ...defaults, filesChanged: 5 }); - expect(tier).toBe('LOW'); - }); - - it('returns MEDIUM when filesChanged is 6 (boundary)', () => { - const { tier } = calculateRisk({ ...defaults, filesChanged: 6 }); - expect(tier).toBe('MEDIUM'); - }); - - it('returns MEDIUM when filesChanged is 20 (upper boundary)', () => { - const { tier } = calculateRisk({ ...defaults, filesChanged: 20 }); - expect(tier).toBe('MEDIUM'); - }); - - it('returns HIGH when filesChanged is 21 (boundary)', () => { - const { tier } = calculateRisk({ ...defaults, filesChanged: 21 }); - expect(tier).toBe('HIGH'); - }); - - it('returns HIGH when filesChanged is 50', () => { - const { tier } = calculateRisk({ ...defaults, filesChanged: 50 }); - expect(tier).toBe('HIGH'); - }); - - it('returns CRITICAL when filesChanged is 51 (boundary)', () => { - const { tier } = calculateRisk({ ...defaults, filesChanged: 51 }); - expect(tier).toBe('CRITICAL'); - }); - - // ── Modules-touched thresholds ──────────────────────────────────────── - - it('returns LOW when modulesTouched ≤ 1', () => { - const { tier } = calculateRisk({ ...defaults, modulesTouched: 1 }); - expect(tier).toBe('LOW'); - }); - - it('returns MEDIUM when modulesTouched is 2 (boundary)', () => { - const { tier } = calculateRisk({ ...defaults, modulesTouched: 2 }); - expect(tier).toBe('MEDIUM'); - }); - - it('returns HIGH when modulesTouched is 5 (boundary)', () => { - const { tier } = calculateRisk({ ...defaults, modulesTouched: 5 }); - expect(tier).toBe('HIGH'); - }); - - it('returns HIGH when modulesTouched is 8 (upper boundary)', () => { - const { tier } = calculateRisk({ ...defaults, modulesTouched: 8 }); - expect(tier).toBe('HIGH'); - }); - - it('returns CRITICAL when modulesTouched is 9 (boundary)', () => { - const { tier } = calculateRisk({ ...defaults, modulesTouched: 9 }); - expect(tier).toBe('CRITICAL'); - }); - - // ── Deletions threshold ─────────────────────────────────────────────── - - it('returns CRITICAL when filesDeleted > 10', () => { - const { tier } = calculateRisk({ ...defaults, filesDeleted: 11 }); - expect(tier).toBe('CRITICAL'); - }); - - it('stays LOW when filesDeleted is 10 (boundary, ≤ 10)', () => { - const { tier } = calculateRisk({ ...defaults, filesDeleted: 10 }); - expect(tier).toBe('LOW'); - }); - - // ── Critical files ──────────────────────────────────────────────────── - - it('bumps to at least MEDIUM when criticalFiles are present', () => { - const { tier, factors } = calculateRisk({ - ...defaults, - criticalFiles: ['package.json'], - }); - expect(tier).toBe('MEDIUM'); - expect(factors.some((f) => f.includes('Critical files touched'))).toBe(true); - }); - - // ── Factors strings ─────────────────────────────────────────────────── - - it('includes a factor string for every evaluated dimension', () => { - const { factors } = calculateRisk({ ...defaults }); - // At minimum: files changed + modules touched - expect(factors.length).toBeGreaterThanOrEqual(2); - }); - - it('includes deletion factor when filesDeleted > 0', () => { - const { factors } = calculateRisk({ ...defaults, filesDeleted: 3 }); - expect(factors.some((f) => f.includes('deleted'))).toBe(true); - }); - - // ── Highest tier wins ───────────────────────────────────────────────── - - it('returns the highest tier across all dimensions', () => { - const { tier } = calculateRisk({ - filesChanged: 51, // CRITICAL - filesDeleted: 0, - modulesTouched: 1, // LOW - criticalFiles: [], - }); - expect(tier).toBe('CRITICAL'); - }); -});