From 3b55786af63febaf03fc2a0bb96a63f0414929d9 Mon Sep 17 00:00:00 2001 From: Will-thom <116388885+Will-thom@users.noreply.github.com> Date: Tue, 26 May 2026 13:05:09 -0300 Subject: [PATCH] feat(scanner): detect GitLab CI and CircleCI files --- README.md | 2 +- docs/detectors.md | 2 +- src/generator.mjs | 13 +++++++++-- src/linter.mjs | 12 ++++++++-- src/reporter.mjs | 12 ++++++++++ src/scanner.mjs | 10 ++++++++ .../multi-ci-app/.circleci/config.yaml | 9 ++++++++ .../multi-ci-app/.circleci/config.yml | 9 ++++++++ .../multi-ci-app/.github/workflows/ci.yml | 12 ++++++++++ test/fixtures/multi-ci-app/.gitlab-ci.yaml | 3 +++ test/fixtures/multi-ci-app/.gitlab-ci.yml | 3 +++ test/fixtures/multi-ci-app/package.json | 7 ++++++ test/scanner.test.mjs | 23 +++++++++++++++---- 13 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/multi-ci-app/.circleci/config.yaml create mode 100644 test/fixtures/multi-ci-app/.circleci/config.yml create mode 100644 test/fixtures/multi-ci-app/.github/workflows/ci.yml create mode 100644 test/fixtures/multi-ci-app/.gitlab-ci.yaml create mode 100644 test/fixtures/multi-ci-app/.gitlab-ci.yml create mode 100644 test/fixtures/multi-ci-app/package.json diff --git a/README.md b/README.md index c3d6406..199df13 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ Current detectors cover common JavaScript/TypeScript, Python, Ruby, PHP, C#, Jav - package manager: npm, pnpm, yarn, bun, pip, bundler, composer, dotnet, maven, gradle, cargo, go - commands: install, dev, start, build, test, lint, format, backend install, local services - docs: README, architecture docs, ADR directories, existing agent docs -- CI: GitHub Actions workflows +- CI: GitHub Actions, GitLab CI, and CircleCI workflow files - monorepos: npm/pnpm workspaces, Turborepo, Nx, Lerna, Rush - frameworks/tools: React, Vite, Next.js, Next.js App Router, Vue, Nuxt, Astro, Svelte, SvelteKit, Express, NestJS, Playwright, Storybook, FastAPI, Django, Flask, Pytest, Rails, Laravel, .NET, Spring Boot, Rust web frameworks, Gin, Docker, Docker Compose - framework signals: package dependencies, framework config files, and common route/component conventions such as `app/`, `pages/`, `src/routes/`, and `.astro` files diff --git a/docs/detectors.md b/docs/detectors.md index 85f9eb8..f232590 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -18,7 +18,7 @@ This page summarizes the current detector coverage. Claims here should stay alig | Go | `go.mod` | Gin | `go build ./...`, `go test ./...`, `go vet ./...`, `gofmt -w .` | | Containers | `Dockerfile`, `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, `compose.yaml` | Docker, Docker Compose | `docker compose up -d`, `docker compose down` | | Monorepos | `workspaces`, `pnpm-workspace.yaml`, `turbo.json`, `nx.json`, `lerna.json`, `rush.json` | npm workspaces, pnpm workspaces, Turborepo, Nx, Lerna, Rush | root package manager scripts when present | -| CI and docs | `.github/workflows/*.yml`, `README.md`, architecture docs, `docs/adr/`, existing agent docs | GitHub Actions, AGENTS.md, Claude, Cursor, Gemini, Copilot shims | readiness reporting and generated instructions reference detected paths | +| CI and docs | `.github/workflows/*.yml`, `.github/workflows/*.yaml`, `.gitlab-ci.yml`, `.gitlab-ci.yaml`, `.circleci/config.yml`, `.circleci/config.yaml`, `README.md`, architecture docs, `docs/adr/`, existing agent docs | GitHub Actions, GitLab CI, CircleCI, AGENTS.md, Claude, Cursor, Gemini, Copilot shims | readiness reporting and generated instructions reference detected paths | ## Framework Signals diff --git a/src/generator.mjs b/src/generator.mjs index cf66519..13a26c1 100644 --- a/src/generator.mjs +++ b/src/generator.mjs @@ -125,7 +125,7 @@ export function buildAgentsMd(profile) { const languages = profile.languages.length ? profile.languages.map((language) => `${language.name} (${language.files} files)`).join(", ") : "No source files detected"; - const ci = profile.ci.githubActions.length ? profile.ci.githubActions.join(", ") : "No GitHub Actions workflows detected"; + const ci = formatCi(profile.ci); const architecture = profile.docs.architecture || "Not found"; const adr = profile.docs.adrDirectory || "Not found"; const localServices = localServicesSection(profile); @@ -285,7 +285,7 @@ ${profile.name} is primarily a ${profile.primaryLanguage} repository. This docum - Frameworks/tools: ${frameworks} - Package manager: ${profile.packageManager} - Monorepo: ${profile.monorepo?.detected ? "yes" : "no"} -- CI: ${profile.ci.githubActions.length ? profile.ci.githubActions.join(", ") : "No GitHub Actions workflows detected"} +- CI: ${formatCi(profile.ci)} ## Repository Layout @@ -423,6 +423,15 @@ function commandLines(commands) { .join("\n"); } +function formatCi(ci) { + const files = [ + ...(ci?.githubActions || []), + ...(ci?.gitlabCi || []), + ...(ci?.circleCi || []), + ]; + return files.length ? files.join(", ") : "No CI workflows detected"; +} + function verificationChecklist(profile) { const ordered = ["test", "lint", "build", "format"]; const lines = ordered diff --git a/src/linter.mjs b/src/linter.mjs index b6d0715..5049282 100644 --- a/src/linter.mjs +++ b/src/linter.mjs @@ -23,8 +23,8 @@ export async function lintRepo(profile) { if (!profile.docs.readme) { findings.push(finding("warning", "missing-readme", "README.md is missing.", "README.md", "Add a README with install, usage, and contribution basics.")); } - if (!profile.ci.githubActions.length) { - findings.push(finding("info", "missing-ci", "No GitHub Actions workflow was detected.", ".github/workflows", "Add CI so agents can trust the verification path.")); + if (!ciFiles(profile.ci).length) { + findings.push(finding("info", "missing-ci", "No CI workflow was detected.", ".github/workflows", "Add CI so agents can trust the verification path.")); } findings.push(...await validatePackageScripts(profile)); @@ -154,6 +154,14 @@ function packageScriptName(command) { return match?.[1] || ""; } +function ciFiles(ci) { + return [ + ...(ci?.githubActions || []), + ...(ci?.gitlabCi || []), + ...(ci?.circleCi || []), + ]; +} + function finding(severity, ruleId, message, file, fixSuggestion) { return { severity, ruleId, message, file, fixSuggestion }; } diff --git a/src/reporter.mjs b/src/reporter.mjs index 36191ff..5d31140 100644 --- a/src/reporter.mjs +++ b/src/reporter.mjs @@ -8,6 +8,7 @@ export function renderScanSummary(profile) { `Monorepo: ${formatMonorepo(profile.monorepo)}`, `Package manager: ${profile.packageManager}`, `Commands: ${Object.entries(profile.commands).filter(([, value]) => value).map(([key, value]) => `${key}=${value}`).join("; ") || "none"}`, + `CI: ${formatCi(profile.ci)}`, `Agent docs: ${profile.agentDocs.map((doc) => doc.file).join(", ") || "none"}`, ].join("\n"); } @@ -425,6 +426,7 @@ export function renderMarkdownReport(profile, findings, score) { - Frameworks: ${profile.frameworks.join(", ") || "none"} - Monorepo: ${formatMonorepo(profile.monorepo)} - Package manager: ${profile.packageManager} +- CI: ${formatCi(profile.ci)} ${score.summary} @@ -469,6 +471,7 @@ export function renderSnapshot(snapshot) { - Primary language: ${profile.primaryLanguage} - Package manager: ${profile.packageManager} - Monorepo: ${formatMonorepo(profile.monorepo)} +- CI: ${formatCi(profile.ci)} ${score.summary} @@ -539,6 +542,15 @@ function formatMonorepo(monorepo) { return `${tools}${workspaces}`; } +function formatCi(ci) { + const files = [ + ...(ci?.githubActions || []), + ...(ci?.gitlabCi || []), + ...(ci?.circleCi || []), + ]; + return files.length ? files.join(", ") : "none"; +} + function badgeColor(score) { if (score >= 90) return "brightgreen"; if (score >= 75) return "green"; diff --git a/src/scanner.mjs b/src/scanner.mjs index 0dab02e..e2f8733 100644 --- a/src/scanner.mjs +++ b/src/scanner.mjs @@ -457,9 +457,19 @@ async function detectCi(root) { .sort(); return { githubActions: workflows, + gitlabCi: await detectExistingFiles(root, [".gitlab-ci.yml", ".gitlab-ci.yaml"]), + circleCi: await detectExistingFiles(root, [".circleci/config.yml", ".circleci/config.yaml"]), }; } +async function detectExistingFiles(root, candidates) { + const results = []; + for (const candidate of candidates) { + if (await pathExists(path.join(root, candidate))) results.push(candidate); + } + return results; +} + function parseTomlValue(text, key) { if (!text) return ""; const match = text.match(new RegExp(`^\\s*${escapeRegExp(key)}\\s*=\\s*["']([^"']+)["']`, "m")); diff --git a/test/fixtures/multi-ci-app/.circleci/config.yaml b/test/fixtures/multi-ci-app/.circleci/config.yaml new file mode 100644 index 0000000..0b8274d --- /dev/null +++ b/test/fixtures/multi-ci-app/.circleci/config.yaml @@ -0,0 +1,9 @@ +version: 2.1 + +jobs: + lint: + docker: + - image: cimg/node:20.0 + steps: + - checkout + - run: npm run lint diff --git a/test/fixtures/multi-ci-app/.circleci/config.yml b/test/fixtures/multi-ci-app/.circleci/config.yml new file mode 100644 index 0000000..164e1ca --- /dev/null +++ b/test/fixtures/multi-ci-app/.circleci/config.yml @@ -0,0 +1,9 @@ +version: 2.1 + +jobs: + test: + docker: + - image: cimg/node:20.0 + steps: + - checkout + - run: npm test diff --git a/test/fixtures/multi-ci-app/.github/workflows/ci.yml b/test/fixtures/multi-ci-app/.github/workflows/ci.yml new file mode 100644 index 0000000..13f5f9c --- /dev/null +++ b/test/fixtures/multi-ci-app/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm test diff --git a/test/fixtures/multi-ci-app/.gitlab-ci.yaml b/test/fixtures/multi-ci-app/.gitlab-ci.yaml new file mode 100644 index 0000000..e18615c --- /dev/null +++ b/test/fixtures/multi-ci-app/.gitlab-ci.yaml @@ -0,0 +1,3 @@ +lint: + script: + - npm run lint diff --git a/test/fixtures/multi-ci-app/.gitlab-ci.yml b/test/fixtures/multi-ci-app/.gitlab-ci.yml new file mode 100644 index 0000000..be707dc --- /dev/null +++ b/test/fixtures/multi-ci-app/.gitlab-ci.yml @@ -0,0 +1,3 @@ +test: + script: + - npm test diff --git a/test/fixtures/multi-ci-app/package.json b/test/fixtures/multi-ci-app/package.json new file mode 100644 index 0000000..728729c --- /dev/null +++ b/test/fixtures/multi-ci-app/package.json @@ -0,0 +1,7 @@ +{ + "name": "fixture-multi-ci-app", + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/test/scanner.test.mjs b/test/scanner.test.mjs index 1b11830..8305ded 100644 --- a/test/scanner.test.mjs +++ b/test/scanner.test.mjs @@ -13,7 +13,7 @@ import { improveRepo } from "../src/improver.mjs"; import { lintRepo, scoreRepo } from "../src/linter.mjs"; import { buildAgentMatrix } from "../src/matrix.mjs"; import { resolvePreset } from "../src/presets.mjs"; -import { renderAgentMatrix, renderAnnotations, renderBenchmarkReport, renderComparison, renderDoctor, renderExamplesCatalog, renderExplanation, renderImprovement, renderImprovementIssue, renderLeaderboard, renderMarkdownReport, renderRoadmap, renderShareComment } from "../src/reporter.mjs"; +import { renderAgentMatrix, renderAnnotations, renderBenchmarkReport, renderComparison, renderDoctor, renderExamplesCatalog, renderExplanation, renderImprovement, renderImprovementIssue, renderLeaderboard, renderMarkdownReport, renderRoadmap, renderShareComment, renderSnapshot } from "../src/reporter.mjs"; import { scanRepo } from "../src/scanner.mjs"; import { snapshotRepo } from "../src/snapshot.mjs"; import { renderCiWorkflow, writeCiWorkflow } from "../src/workflow.mjs"; @@ -35,6 +35,14 @@ test("scan detects a Node app profile", async () => { assert.deepEqual(profile.ci.githubActions, [".github/workflows/ci.yml"]); }); +test("scan detects GitHub Actions, GitLab CI, and CircleCI files", async () => { + const profile = await scanRepo(fixture("multi-ci-app")); + + assert.deepEqual(profile.ci.githubActions, [".github/workflows/ci.yml"]); + assert.deepEqual(profile.ci.gitlabCi, [".gitlab-ci.yml", ".gitlab-ci.yaml"]); + assert.deepEqual(profile.ci.circleCi, [".circleci/config.yml", ".circleci/config.yaml"]); +}); + test("scan detects Python, Rust, and Go repositories", async () => { const python = await scanRepo(fixture("python-app")); const rust = await scanRepo(fixture("rust-app")); @@ -306,23 +314,28 @@ test("score is explainable and bounded", async () => { }); test("markdown report includes commands, docs, and findings", async () => { - const profile = await scanRepo(fixture("node-app")); + const profile = await scanRepo(fixture("multi-ci-app")); const findings = await lintRepo(profile); const score = scoreRepo(profile, findings); const report = renderMarkdownReport(profile, findings, score); assert.match(report, /Agent Readiness Report/); - assert.match(report, /fixture-node-app/); + assert.match(report, /fixture-multi-ci-app/); assert.match(report, /npm run test/); + assert.match(report, /\.gitlab-ci\.yml/); + assert.match(report, /\.circleci\/config\.yml/); }); test("snapshot summarizes score, compatibility, and findings", async () => { - const snapshot = await snapshotRepo(fixture("node-app")); + const snapshot = await snapshotRepo(fixture("multi-ci-app")); + const report = renderSnapshot(snapshot); - assert.equal(snapshot.repository.name, "fixture-node-app"); + assert.equal(snapshot.repository.name, "fixture-multi-ci-app"); assert.equal(snapshot.matrix.summary.total, 5); assert.equal(typeof snapshot.score.score, "number"); assert.equal(snapshot.summary.findings, snapshot.findings.length); + assert.match(report, /\.gitlab-ci\.yml/); + assert.match(report, /\.circleci\/config\.yml/); }); test("examples catalog links copy-ready sample files", () => {