diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml index 25ea4b8d4a..aef311bae6 100644 --- a/.github/workflows/ci-integration.yml +++ b/.github/workflows/ci-integration.yml @@ -58,7 +58,6 @@ jobs: test/integration/cli-e2e.test.ts test/integration/hooks-e2e.test.ts test/integration/skills-e2e.test.ts - test/integration/ignore-and-skip-e2e.test.ts - test-group: standalone test-glob: >- test/integration/filesystem-walker.test.ts diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 04795bc18f..9d949bbc96 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,22 +1,17 @@ name: Claude Code Review -# Uses pull_request_target so the workflow runs as defined on the default branch, -# which allows access to secrets for posting review comments on fork PRs. -# SECURITY: The checkout below uses the PR head SHA to review the correct code. -# The claude-code-action sandboxes execution — it does NOT run arbitrary code -# from the checked-out source. +# Uses pull_request_target so the workflow runs in base repo context with +# access to secrets, even for fork PRs. The luccabb/claude-code-action fork +# handles fork branch checkout internally via pull/{N}/head refs. on: - # Trigger only when explicitly requested: - # - Add the "claude-review" label to a PR, OR - # - Comment "@claude" or "/review" on a PR + # Label a PR with "claude-review" to trigger, or comment @claude / /review pull_request_target: types: [labeled] issue_comment: types: [created] -# Serialize per-PR so concurrent @claude comments don't race on the -# temporary fork branch push/delete. +# Serialize per-PR so concurrent triggers don't race concurrency: group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }} cancel-in-progress: false @@ -47,77 +42,23 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: - contents: write # needed to create fork branch ref via API + contents: read pull-requests: write - issues: read + issues: write id-token: write steps: - # For issue_comment triggers, resolve the PR number, head SHA, and branch name - - name: Resolve PR context - id: pr - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - name: Checkout repository + uses: actions/checkout@v4 with: - script: | - let pr; - if (context.eventName === 'issue_comment') { - const resp = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.issue.number, - }); - pr = resp.data; - } else { - pr = context.payload.pull_request; - } - core.setOutput('number', pr.number); - core.setOutput('sha', pr.head.sha); - core.setOutput('branch', pr.head.ref); - core.setOutput('is_fork', String(pr.head.repo.full_name !== pr.base.repo.full_name)); - - - name: Checkout PR head - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ steps.pr.outputs.sha }} fetch-depth: 1 - # claude-code-action fetches branches by name from origin, which fails - # for fork PRs. Create a temporary branch ref via the API so the action - # can find it. Using the API (not git push) avoids the GITHUB_TOKEN - # restriction that blocks pushing commits containing workflow file changes. - # Use a prefixed temporary branch name to avoid overwriting real branches - # (e.g. a fork branch named "main" would overwrite origin/main). - - name: Create fork branch ref on origin - id: push-fork - if: steps.pr.outputs.is_fork == 'true' - env: - FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }} - FORK_SHA: ${{ steps.pr.outputs.sha }} - GH_TOKEN: ${{ github.token }} - run: | - echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV" - gh api "repos/${{ github.repository }}/git/refs" \ - --method POST \ - -f ref="refs/heads/$FORK_BRANCH" \ - -f sha="$FORK_SHA" \ - || gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" \ - --method PATCH \ - -f sha="$FORK_SHA" \ - -F force=true - - name: Run Claude Code Review id: claude-review - uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1 + # SHA-pinned to luccabb fork for fork PR support (see github.com/anthropics/claude-code-action/issues/223) + uses: luccabb/claude-code-action@7f39722b8a782471258f32e1d5a9a531b2b68056 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}' - - # Clean up the temporary branch ref we created for fork PRs. - # Only delete if the create step actually succeeded. - - name: Delete fork branch ref from origin - if: always() && steps.push-fork.outcome == 'success' - env: - GH_TOKEN: ${{ github.token }} - run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number || github.event.issue.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 12fc0ccaa7..ed91e9c378 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,8 +10,7 @@ on: pull_request_review: types: [submitted] -# Serialize per-PR so concurrent @claude comments don't race on the -# temporary fork branch push/delete. +# Serialize per-PR so concurrent @claude comments don't race concurrency: group: claude-code-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }} cancel-in-progress: false @@ -26,93 +25,22 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 permissions: - contents: write # needed to create fork branch ref via API + contents: read pull-requests: write issues: write id-token: write actions: read # required for Claude to read CI results on PRs steps: - # For PR-related triggers, resolve fork context so we can create a - # temporary branch ref (claude-code-action fetches by branch name). - - name: Resolve PR context - id: pr - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - // Determine if this event is PR-related - let prNumber = null; - if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) { - prNumber = context.payload.issue.number; - } else if (context.eventName === 'pull_request_review_comment') { - prNumber = context.payload.pull_request.number; - } else if (context.eventName === 'pull_request_review') { - prNumber = context.payload.pull_request.number; - } - - if (!prNumber) { - core.setOutput('is_pr', 'false'); - core.setOutput('is_fork', 'false'); - return; - } - - const resp = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); - const pr = resp.data; - const isFork = pr.head.repo.full_name !== pr.base.repo.full_name; - - core.setOutput('is_pr', 'true'); - core.setOutput('number', String(prNumber)); - core.setOutput('is_fork', String(isFork)); - core.setOutput('branch', pr.head.ref); - core.setOutput('sha', pr.head.sha); - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@v4 with: - ref: ${{ steps.pr.outputs.is_fork == 'true' && steps.pr.outputs.sha || '' }} fetch-depth: 1 - # claude-code-action fetches branches by name from origin, which fails - # for fork PRs. Create a temporary branch ref via the API so the action - # can find it. Using the API (not git push) avoids the GITHUB_TOKEN - # restriction that blocks pushing commits containing workflow file changes. - # Use a prefixed temporary branch name to avoid overwriting real branches - # (e.g. a fork branch named "main" would overwrite origin/main). - - name: Create fork branch ref on origin - id: push-fork - if: steps.pr.outputs.is_fork == 'true' - env: - FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }} - FORK_SHA: ${{ steps.pr.outputs.sha }} - GH_TOKEN: ${{ github.token }} - run: | - echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV" - gh api "repos/${{ github.repository }}/git/refs" \ - --method POST \ - -f ref="refs/heads/$FORK_BRANCH" \ - -f sha="$FORK_SHA" \ - || gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" \ - --method PATCH \ - -f sha="$FORK_SHA" \ - -F force=true - - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1 + # SHA-pinned to luccabb fork for fork PR support (see github.com/anthropics/claude-code-action/issues/223) + uses: luccabb/claude-code-action@7f39722b8a782471258f32e1d5a9a531b2b68056 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read - - # Clean up the temporary branch ref we created for fork PRs. - # Only delete if the create step actually succeeded. - - name: Delete fork branch ref from origin - if: always() && steps.push-fork.outcome == 'success' - env: - GH_TOKEN: ${{ github.token }} - run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true diff --git a/.gitignore b/.gitignore index 509bdbb1e8..b9275e9a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ coverage/ # Claude Code worktrees .claude/worktrees/ +.claude/skills/generated/ + +# Claude code skills +.claude/skills/generated/ # Claude code skills .claude/skills/generated/ diff --git a/.mcp.json b/.mcp.json index 9b0916bd32..83370ebfba 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,8 +2,8 @@ "mcpServers": { "gitnexus": { "type": "stdio", - "command": "npx", - "args": ["-y", "gitnexus@latest", "mcp"] + "command": "cmd", + "args": ["/c", "npx", "-y", "gitnexus@latest", "mcp"] } } } diff --git a/AGENTS.md b/AGENTS.md index 713800cb95..49ca1a281b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1683 symbols, 4407 relationships, 127 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -97,5 +97,25 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | +| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` | +| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` | +| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | +| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` | +| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | +| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` | +| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` | +| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` | +| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` | +| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` | +| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` | +| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` | +| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` | +| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` | +| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` | +| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` | +| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` | +| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` | +| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` | diff --git a/CLAUDE.md b/CLAUDE.md index 713800cb95..49ca1a281b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1683 symbols, 4407 relationships, 127 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -97,5 +97,25 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | +| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` | +| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` | +| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | +| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` | +| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | +| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` | +| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` | +| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` | +| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` | +| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` | +| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` | +| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` | +| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` | +| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` | +| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` | +| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` | +| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` | +| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` | +| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` | diff --git a/eval/.gitignore b/eval/.gitignore index d1ac9f241a..d5b13cfffd 100644 --- a/eval/.gitignore +++ b/eval/.gitignore @@ -14,3 +14,4 @@ build/ # Environment .env .venv/ +.claude/skills/generated \ No newline at end of file diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 276eb00e26..4d74ce24b7 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -28,7 +28,6 @@ program .option('--embeddings', 'Enable embedding generation for semantic search (off by default)') .option('--skills', 'Generate repo-specific skill files from detected communities') .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') - .addHelpText('after', '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)') .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand')); program diff --git a/gitnexus/test/unit/workflows.test.ts b/gitnexus/test/unit/workflows.test.ts new file mode 100644 index 0000000000..8302c1f6d0 --- /dev/null +++ b/gitnexus/test/unit/workflows.test.ts @@ -0,0 +1,218 @@ +/** + * CI Workflow Tests: GitHub Actions YAML validation + * + * Validates the two Claude-powered workflow files: + * - .github/workflows/claude.yml (interactive @claude mentions) + * - .github/workflows/claude-code-review.yml (auto-review on PRs) + * + * Checks YAML structure, SHA-pinned action refs, permissions, + * trigger events, fork PR security guard, and step structure. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const WORKFLOW_DIR = resolve(__dirname, '../../../.github/workflows'); + +const WORKFLOW_FILES = [ + { name: 'claude.yml', path: resolve(WORKFLOW_DIR, 'claude.yml') }, + { name: 'claude-code-review.yml', path: resolve(WORKFLOW_DIR, 'claude-code-review.yml') }, +] as const; + +const EXPECTED_SHA_REF = 'luccabb/claude-code-action@7f39722b8a782471258f32e1d5a9a531b2b68056'; + +/** Read a workflow file and return its raw content. */ +function readWorkflow(filePath: string): string { + return readFileSync(filePath, 'utf-8'); +} + +// ─── YAML validity ────────────────────────────────────────────────── + +describe('YAML validity', () => { + for (const wf of WORKFLOW_FILES) { + describe(wf.name, () => { + it('reads without error', () => { + expect(() => readWorkflow(wf.path)).not.toThrow(); + }); + + it('has top-level "name" key', () => { + const content = readWorkflow(wf.path); + expect(content).toMatch(/^name:\s/m); + }); + + it('has top-level "on" key', () => { + const content = readWorkflow(wf.path); + expect(content).toMatch(/^on:\s/m); + }); + + it('has top-level "jobs" key', () => { + const content = readWorkflow(wf.path); + expect(content).toMatch(/^jobs:\s/m); + }); + }); + } +}); + +// ─── Action SHA pinning ───────────────────────────────────────────── + +describe('Action SHA pinning', () => { + for (const wf of WORKFLOW_FILES) { + describe(wf.name, () => { + const content = readWorkflow(wf.path); + // Extract all `uses:` lines that reference claude-code-action + const claudeActionLines = content + .split('\n') + .filter((line) => line.includes('uses:') && line.includes('claude-code-action')); + + it('has at least one claude-code-action reference', () => { + expect(claudeActionLines.length).toBeGreaterThanOrEqual(1); + }); + + it(`pins claude-code-action to exact SHA: ${EXPECTED_SHA_REF}`, () => { + for (const line of claudeActionLines) { + expect(line).toContain(EXPECTED_SHA_REF); + } + }); + + it('does not use tag-style refs (@v1, @main, @latest)', () => { + for (const line of claudeActionLines) { + // After extracting the ref, ensure it's not a short tag + expect(line).not.toMatch(/claude-code-action@v\d/); + expect(line).not.toMatch(/claude-code-action@main/); + expect(line).not.toMatch(/claude-code-action@latest/); + } + }); + }); + } +}); + +// ─── Permissions ──────────────────────────────────────────────────── + +describe('Permissions', () => { + for (const wf of WORKFLOW_FILES) { + describe(wf.name, () => { + const content = readWorkflow(wf.path); + + it('grants pull-requests: write', () => { + expect(content).toMatch(/pull-requests:\s*write/); + }); + + it('grants issues: write', () => { + expect(content).toMatch(/issues:\s*write/); + }); + + it('grants id-token: write', () => { + expect(content).toMatch(/id-token:\s*write/); + }); + + it('keeps contents: read (not write)', () => { + expect(content).toMatch(/contents:\s*read/); + // Ensure no contents: write exists + expect(content).not.toMatch(/contents:\s*write/); + }); + }); + } +}); + +// ─── Trigger events ───────────────────────────────────────────────── + +describe('Trigger events', () => { + describe('claude.yml', () => { + const content = readWorkflow(WORKFLOW_FILES[0].path); + + const expectedTriggers = [ + 'issue_comment', + 'pull_request_review_comment', + 'issues', + 'pull_request_review', + ]; + + for (const trigger of expectedTriggers) { + it(`triggers on ${trigger}`, () => { + // Match as a top-level key under `on:` (2-space indented) + expect(content).toMatch(new RegExp(`^\\s{2}${trigger}:`, 'm')); + }); + } + }); + + describe('claude-code-review.yml', () => { + const content = readWorkflow(WORKFLOW_FILES[1].path); + + it('triggers on pull_request_target (label-based)', () => { + expect(content).toMatch(/^\s{2}pull_request_target:/m); + }); + + it('triggers on issue_comment (comment-based review)', () => { + expect(content).toMatch(/^\s{2}issue_comment:/m); + }); + + it('does not use pull_request trigger (avoids double-fire and secrets issues on forks)', () => { + expect(content).not.toMatch(/^\s{2}pull_request:/m); + }); + }); +}); + +// ─── Fork PR security guard ──────────────────────────────────────── + +describe('Fork PR security guard', () => { + describe('claude-code-review.yml', () => { + const content = readWorkflow(WORKFLOW_FILES[1].path); + + it('has an active (non-commented) if: condition on the job', () => { + // The `if:` must appear at the job level (4-space indent), not commented out + expect(content).toMatch(/^\s{4}if:\s*\|/m); + }); + + it('references author_association in the condition', () => { + expect(content).toContain('author_association'); + }); + + it('allows OWNER, MEMBER, and COLLABORATOR', () => { + expect(content).toContain("'OWNER'"); + expect(content).toContain("'MEMBER'"); + expect(content).toContain("'COLLABORATOR'"); + }); + + it('guards against untrusted authors via author_association check', () => { + // The if: condition must check author_association directly since + // pull_request_target is now the only trigger — no event_name branching needed + expect(content).toMatch(/author_association\s*==\s*'OWNER'/); + expect(content).toMatch(/author_association\s*==\s*'MEMBER'/); + expect(content).toMatch(/author_association\s*==\s*'COLLABORATOR'/); + }); + }); +}); + +// ─── Step structure ───────────────────────────────────────────────── + +describe('Step structure', () => { + for (const wf of WORKFLOW_FILES) { + describe(wf.name, () => { + const content = readWorkflow(wf.path); + + // Count steps by matching `- name:` lines under `steps:` + const stepMatches = content.match(/^\s{6}- name:/gm) || []; + + it('has exactly 2 steps', () => { + expect(stepMatches.length).toBe(2); + }); + + it('first step uses actions/checkout', () => { + // Find first `uses:` line after `steps:` + const stepsIndex = content.indexOf('steps:'); + const afterSteps = content.slice(stepsIndex); + const firstUsesMatch = afterSteps.match(/uses:\s*(\S+)/); + expect(firstUsesMatch).not.toBeNull(); + expect(firstUsesMatch![1]).toMatch(/^actions\/checkout@/); + }); + + it('no step references git/refs API (old broken pattern)', () => { + expect(content).not.toContain('git/refs'); + }); + }); + } +});