diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5a13ec8..679b2e6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,13 @@ name: Publish # VS Code Marketplace and Open VSX, then attaches the .vsix to a # GitHub release. # +# Tag-naming convention: +# - `v0.1.0` (stable) → stable marketplace channel. +# - `v0.1.0-rc.1` (pre-release) → pre-release marketplace channel, +# GitHub release marked `prerelease`. +# Detection is by the presence of a `-` after the semver core; see +# the "Detect pre-release tag" step below. +# # Required repo secrets: # - VSCE_PAT — Azure DevOps Personal Access Token, scope # `Marketplace > Acquire and Manage`. Bound to the @@ -109,6 +116,26 @@ jobs: - name: Bundle smoke run: npm run smoke + - name: Detect pre-release tag + # A tag like `v0.2.0-rc.1` (anything with a `-` after the + # semver core) ships to the marketplace's pre-release channel + # and the GitHub release is marked prerelease. Stable tags + # (`v0.2.0`) ship to the stable channel. Detection is purely + # by the version string — keeps the convention discoverable + # via `git tag`. + run: | + set -euo pipefail + version=$(node -p "require('./package.json').version") + if [[ "$version" == *-* ]]; then + echo "Pre-release tag detected: $version" + echo "PRERELEASE_FLAG=--pre-release" >> "$GITHUB_ENV" + echo "GH_PRERELEASE=--prerelease" >> "$GITHUB_ENV" + else + echo "Stable tag: $version" + echo "PRERELEASE_FLAG=" >> "$GITHUB_ENV" + echo "GH_PRERELEASE=" >> "$GITHUB_ENV" + fi + - name: Package vsix # vsce and ovsx are pinned devDependencies in package.json, so # `npm ci` above installed the exact versions and Dependabot @@ -116,7 +143,7 @@ jobs: # the local binary — no fresh fetch with PATs in env. run: | version=$(node -p "require('./package.json').version") - npx vsce package --out "pipeline-check-${version}.vsix" + npx vsce package $PRERELEASE_FLAG --out "pipeline-check-${version}.vsix" ls -lh "pipeline-check-${version}.vsix" echo "VSIX_PATH=pipeline-check-${version}.vsix" >> $GITHUB_ENV @@ -124,7 +151,7 @@ jobs: env: VSCE_PAT: ${{ secrets.VSCE_PAT }} run: | - npx vsce publish \ + npx vsce publish $PRERELEASE_FLAG \ --packagePath "$VSIX_PATH" \ --pat "$VSCE_PAT" @@ -132,7 +159,10 @@ jobs: env: OVSX_PAT: ${{ secrets.OVSX_PAT }} run: | - npx ovsx publish "$VSIX_PATH" \ + # Open VSX (ovsx >= 0.10) honours --pre-release the same + # way vsce does. Older versions ignore the flag silently, + # so this stays safe across minor bumps. + npx ovsx publish $PRERELEASE_FLAG "$VSIX_PATH" \ --pat "$OVSX_PAT" - name: Create GitHub release @@ -140,6 +170,6 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version=$(node -p "require('./package.json').version") - gh release create "v${version}" "$VSIX_PATH" \ + gh release create "v${version}" "$VSIX_PATH" $GH_PRERELEASE \ --title "v${version}" \ --notes-file <(awk '/^## \[/{n++} n==2{exit} n==1{print}' CHANGELOG.md) diff --git a/package.json b/package.json index f633392..05cfd3b 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,27 @@ "scope": "machine-overridable", "markdownDescription": "Arguments passed to the server command. Default invokes the module via `python -m pipeline_check.lsp`. Scope is `machine-overridable`: workspace overrides require an explicit prompt, since arbitrary args can execute arbitrary code (e.g. `-c \"...\"`)." }, + "pipelineCheck.disabledProviders": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "github-actions", + "gitlab", + "azure", + "bitbucket", + "circleci", + "cloud-build", + "buildkite", + "drone", + "jenkins", + "dockerfile" + ] + }, + "uniqueItems": true, + "default": [], + "markdownDescription": "Providers to silence. Diagnostics for files matching a disabled provider's path glob are dropped before they reach the editor — useful in a monorepo where Pipeline-Check would otherwise lint a Dockerfile from a sub-project that has its own lint pipeline. Empty by default; everything scans. `dockerfile` covers both `Dockerfile` and `Containerfile`." + }, "pipelineCheck.severityThreshold": { "type": "string", "enum": [ diff --git a/src/codeLens.test.ts b/src/codeLens.test.ts new file mode 100644 index 0000000..5c09adc --- /dev/null +++ b/src/codeLens.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("vscode", async () => { + const { vscodeStub } = await import("./__testStubs__/vscode"); + return vscodeStub(); +}); + +import { composeLensTitle, summariseCounts } from "./codeLens"; + +const diag = (severity?: string, source = "pipeline-check") => + ({ + source, + message: "", + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + severity: 0, + data: severity ? { severity } : undefined, + }) as unknown as import("vscode").Diagnostic; + +describe("summariseCounts", () => { + it("ignores diagnostics whose source is not pipeline-check", () => { + expect(summariseCounts([diag("HIGH", "eslint")])).toEqual({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }); + }); + + it("tallies pipeline-check diagnostics by severity", () => { + expect( + summariseCounts([ + diag("CRITICAL"), + diag("HIGH"), + diag("HIGH"), + diag("LOW"), + ]), + ).toEqual({ CRITICAL: 1, HIGH: 2, MEDIUM: 0, LOW: 1, INFO: 0 }); + }); + + it("falls back to INFO for missing/unknown severity", () => { + expect(summariseCounts([diag(), diag("BOGUS")])).toEqual({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 2, + }); + }); + + it("normalises lowercase severity", () => { + expect(summariseCounts([diag("high")])).toEqual({ + CRITICAL: 0, + HIGH: 1, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }); + }); +}); + +describe("composeLensTitle", () => { + it("returns null on an empty tally so the lens is omitted", () => { + expect( + composeLensTitle({ + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }), + ).toBeNull(); + }); + + it("lists only nonzero buckets, in severity order", () => { + expect( + composeLensTitle({ + CRITICAL: 2, + HIGH: 0, + MEDIUM: 0, + LOW: 3, + INFO: 0, + }), + ).toBe("Pipeline-Check: 2 critical · 3 low"); + }); + + it("renders a single-bucket tally without separators", () => { + expect( + composeLensTitle({ + CRITICAL: 0, + HIGH: 4, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }), + ).toBe("Pipeline-Check: 4 high"); + }); + + it("lowercases the severity name in the title", () => { + const t = composeLensTitle({ + CRITICAL: 1, + HIGH: 1, + MEDIUM: 1, + LOW: 1, + INFO: 1, + }); + expect(t).toBe( + "Pipeline-Check: 1 critical · 1 high · 1 medium · 1 low · 1 info", + ); + }); +}); diff --git a/src/codeLens.ts b/src/codeLens.ts new file mode 100644 index 0000000..a75e646 --- /dev/null +++ b/src/codeLens.ts @@ -0,0 +1,117 @@ +// File-level CodeLens summarising Pipeline-Check findings at the top +// of each scanned document. Pinned to line 1 so it's visible the +// moment the file opens — same surface that test runners use for +// "Run | Debug" above a test function. +// +// Reads strictly from already-published diagnostics (the LSP's +// stream); never triggers its own scan. The lens command opens the +// Findings panel so the user can drill in. +// +// `summariseCounts` and the lens-text composer are exported as pure +// functions so the test suite can pin the copy without booting the +// editor. + +import * as vscode from "vscode"; + +const DIAGNOSTIC_SOURCE = "pipeline-check"; + +const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] as const; +type SeverityName = (typeof SEVERITY_ORDER)[number]; + +export interface SeverityCounts { + readonly CRITICAL: number; + readonly HIGH: number; + readonly MEDIUM: number; + readonly LOW: number; + readonly INFO: number; +} + +/** + * Tally the per-severity counts of pipeline-check diagnostics in + * `diags`. Falls back to INFO for missing or unknown severity names, + * matching the policy in findingsView.ts. + */ +export function summariseCounts( + diags: readonly vscode.Diagnostic[], +): SeverityCounts { + const counts: { -readonly [K in SeverityName]: number } = { + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }; + for (const d of diags) { + if (d.source !== DIAGNOSTIC_SOURCE) continue; + counts[readSeverity(d)] += 1; + } + return counts; +} + +function readSeverity(diag: vscode.Diagnostic): SeverityName { + const data = (diag as vscode.Diagnostic & { + data?: { severity?: string }; + }).data; + const name = (data?.severity ?? "").toUpperCase(); + return (SEVERITY_ORDER as readonly string[]).includes(name) + ? (name as SeverityName) + : "INFO"; +} + +/** + * Render the lens title from per-severity counts. Examples: + * + * { CRITICAL: 2 } → "Pipeline-Check: 2 critical" + * { CRITICAL: 2, HIGH: 1 } → "Pipeline-Check: 2 critical · 1 high" + * { LOW: 5 } → "Pipeline-Check: 5 low" + * {} → null (caller omits the lens) + * + * Lists only nonzero buckets in severity order so the lens text reads + * top-to-bottom like the Findings tree. + */ +export function composeLensTitle(c: SeverityCounts): string | null { + const parts: string[] = []; + for (const sev of SEVERITY_ORDER) { + if (c[sev] > 0) { + parts.push(`${c[sev]} ${sev.toLowerCase()}`); + } + } + if (parts.length === 0) return null; + return `Pipeline-Check: ${parts.join(" · ")}`; +} + +/** + * CodeLens provider for scanned-document file-level summaries. The + * lens sits at the top of the file (line 0, col 0) and clicking it + * reveals the Findings panel. + * + * Re-emits on every onDidChangeDiagnostics so the lens text tracks + * the latest LSP publish. The vscode runtime debounces lens fetches, + * so we don't have to worry about thrash. + */ +export class FindingsCodeLensProvider implements vscode.CodeLensProvider { + private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; + + constructor(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.languages.onDidChangeDiagnostics(() => + this._onDidChangeCodeLenses.fire(), + ), + ); + } + + provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + const counts = summariseCounts( + vscode.languages.getDiagnostics(document.uri), + ); + const title = composeLensTitle(counts); + if (!title) return []; + return [ + new vscode.CodeLens(new vscode.Range(0, 0, 0, 0), { + title, + command: "pipelineCheck.findings.focus", + }), + ]; + } +} diff --git a/src/extension.ts b/src/extension.ts index 2cf26d7..7098310 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,10 +18,15 @@ import { ServerOptions, TransportKind, } from "vscode-languageclient/node"; +import { FindingsCodeLensProvider } from "./codeLens"; import { FindingsTreeProvider, GroupMode } from "./findingsView"; import * as clientLog from "./log"; import { goToFinding } from "./navigate"; -import { TRIGGER_DOCUMENT_SELECTOR } from "./providers"; +import { + providerForPath, + type ProviderId, + TRIGGER_DOCUMENT_SELECTOR, +} from "./providers"; import { filterByThreshold } from "./severityFilter"; import { registerStatusBar } from "./statusBar"; @@ -117,9 +122,20 @@ function buildClient(): LanguageClient { // pass through unconditionally so the filter never hides // legitimate signal when the metadata is absent. handleDiagnostics: (uri, diagnostics, next) => { - const threshold = vscode.workspace - .getConfiguration("pipelineCheck") - .get("severityThreshold", "low"); + const config = vscode.workspace.getConfiguration("pipelineCheck"); + // Per-provider toggle: if this URI maps to a provider the + // user has disabled, drop every diagnostic for it. We still + // accept the publish (so a future "unset disable" causes a + // fresh publish to reach us), we just blank the list. + const disabled = new Set( + config.get("disabledProviders", []) as ProviderId[], + ); + const provider = providerForPath(uri.fsPath); + if (provider && disabled.has(provider)) { + next(uri, []); + return; + } + const threshold = config.get("severityThreshold", "low"); next(uri, filterByThreshold(diagnostics, threshold)); }, }, @@ -233,6 +249,15 @@ export async function activate( // severity tally. Click reveals the Findings panel. registerStatusBar // pushes the item onto context.subscriptions internally. registerStatusBar(context); + // CodeLens summary at the top of every scanned file. Reads from the + // same diagnostic stream the tree does; click navigates to the + // Findings panel for drill-down. + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + [...TRIGGER_DOCUMENT_SELECTOR], + new FindingsCodeLensProvider(context), + ), + ); context.subscriptions.push( findingsView, vscode.commands.registerCommand("pipelineCheck.findings.refresh", () => diff --git a/src/providers.test.ts b/src/providers.test.ts index 8bcda5e..4cdb625 100644 --- a/src/providers.test.ts +++ b/src/providers.test.ts @@ -4,7 +4,13 @@ import { resolve } from "node:path"; vi.mock("vscode", () => ({})); -import { TRIGGER_PATTERNS, TRIGGER_DOCUMENT_SELECTOR } from "./providers"; +import { + PROVIDER_IDS, + PROVIDERS, + TRIGGER_PATTERNS, + TRIGGER_DOCUMENT_SELECTOR, + providerForPath, +} from "./providers"; describe("TRIGGER_PATTERNS", () => { it("derives a `file`-scoped DocumentFilter for each pattern", () => { @@ -52,3 +58,59 @@ function expandBraces(pattern: string): string[] { const [, head, body, tail] = match; return body.split(",").map((alt) => `${head}${alt}${tail}`); } + +describe("PROVIDERS map", () => { + it("covers every entry in PROVIDER_IDS", () => { + for (const id of PROVIDER_IDS) { + expect(PROVIDERS[id]).toBeDefined(); + expect(PROVIDERS[id].length).toBeGreaterThan(0); + } + }); + + it("TRIGGER_PATTERNS is the union of every provider's patterns", () => { + const flattened = PROVIDER_IDS.flatMap((id) => PROVIDERS[id]); + expect([...TRIGGER_PATTERNS].sort()).toEqual([...flattened].sort()); + }); +}); + +describe("providerForPath", () => { + it("maps GitHub Actions workflow paths", () => { + expect(providerForPath("/repo/.github/workflows/release.yml")).toBe( + "github-actions", + ); + expect(providerForPath("/repo/.github/workflows/ci.yaml")).toBe( + "github-actions", + ); + }); + + it("maps the single-file providers", () => { + expect(providerForPath("/repo/.gitlab-ci.yml")).toBe("gitlab"); + expect(providerForPath("/repo/azure-pipelines.yml")).toBe("azure"); + expect(providerForPath("/repo/bitbucket-pipelines.yml")).toBe("bitbucket"); + expect(providerForPath("/repo/.circleci/config.yml")).toBe("circleci"); + expect(providerForPath("/repo/cloudbuild.yaml")).toBe("cloud-build"); + expect(providerForPath("/repo/.buildkite/pipeline.yml")).toBe("buildkite"); + expect(providerForPath("/repo/.drone.yml")).toBe("drone"); + expect(providerForPath("/repo/.drone.yaml")).toBe("drone"); + expect(providerForPath("/repo/Jenkinsfile")).toBe("jenkins"); + }); + + it("groups Dockerfile and Containerfile under the same id", () => { + expect(providerForPath("/repo/Dockerfile")).toBe("dockerfile"); + expect(providerForPath("/repo/Containerfile")).toBe("dockerfile"); + expect(providerForPath("/repo/build/Dockerfile")).toBe("dockerfile"); + }); + + it("normalises Windows backslashes before matching", () => { + expect(providerForPath("C:\\repo\\.github\\workflows\\ci.yml")).toBe( + "github-actions", + ); + expect(providerForPath("C:\\repo\\Dockerfile")).toBe("dockerfile"); + }); + + it("returns undefined for unmatched paths", () => { + expect(providerForPath("/repo/package.json")).toBeUndefined(); + expect(providerForPath("/repo/mkdocs.yml")).toBeUndefined(); + expect(providerForPath("/repo/values.yaml")).toBeUndefined(); + }); +}); diff --git a/src/providers.ts b/src/providers.ts index 8060ffb..cede47f 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -25,23 +25,49 @@ export interface TriggerSelector { readonly pattern: string; } +export type ProviderId = + | "github-actions" + | "gitlab" + | "azure" + | "bitbucket" + | "circleci" + | "cloud-build" + | "buildkite" + | "drone" + | "jenkins" + | "dockerfile"; + +/** + * Glob patterns indexed by provider id. A provider may map to more + * than one pattern (Dockerfile and Containerfile share the same + * syntax, so they live under "dockerfile"). The keys are what the + * `pipelineCheck.disabledProviders` setting accepts; spelling them + * out as a `Record` keeps the setting's enum in + * lockstep with the patterns. + */ +export const PROVIDERS: Readonly> = { + "github-actions": ["**/.github/workflows/*.{yml,yaml}"], + gitlab: ["**/.gitlab-ci.yml"], + azure: ["**/azure-pipelines.yml"], + bitbucket: ["**/bitbucket-pipelines.yml"], + circleci: ["**/.circleci/config.yml"], + "cloud-build": ["**/cloudbuild.yaml"], + buildkite: ["**/.buildkite/pipeline.yml"], + drone: ["**/.drone.{yml,yaml}"], + jenkins: ["**/Jenkinsfile"], + dockerfile: ["**/Dockerfile", "**/Containerfile"], +}; + +export const PROVIDER_IDS = Object.keys(PROVIDERS) as readonly ProviderId[]; + /** * Glob patterns matching every file the upstream `pipeline_check` - * rule registry knows how to analyse. Order is not load-bearing. + * rule registry knows how to analyse. Derived from `PROVIDERS` so the + * two stay in sync automatically. */ -export const TRIGGER_PATTERNS: readonly string[] = [ - "**/.github/workflows/*.{yml,yaml}", - "**/.gitlab-ci.yml", - "**/azure-pipelines.yml", - "**/bitbucket-pipelines.yml", - "**/.circleci/config.yml", - "**/cloudbuild.yaml", - "**/.buildkite/pipeline.yml", - "**/.drone.{yml,yaml}", - "**/Jenkinsfile", - "**/Dockerfile", - "**/Containerfile", -]; +export const TRIGGER_PATTERNS: readonly string[] = PROVIDER_IDS.flatMap( + (id) => PROVIDERS[id], +); /** * Document-selector form of `TRIGGER_PATTERNS`, suitable for the @@ -51,3 +77,72 @@ export const TRIGGER_PATTERNS: readonly string[] = [ */ export const TRIGGER_DOCUMENT_SELECTOR: readonly TriggerSelector[] = TRIGGER_PATTERNS.map((pattern) => ({ scheme: "file" as const, pattern })); + +/** + * Maps a workspace-relative path to the provider that handles it, or + * `undefined` if no provider matches. Used by the middleware to drop + * diagnostics for files whose provider has been disabled in settings. + * + * Matching is the same minimatch dialect VS Code's `findFiles` and + * `documentSelector` use. Implemented locally with a small glob + * matcher so the function works in both the editor and the unit + * test environment. + */ +export function providerForPath(path: string): ProviderId | undefined { + // Normalise Windows backslashes — globs are POSIX-shaped. + const normalised = path.replace(/\\/g, "/"); + for (const id of PROVIDER_IDS) { + for (const pattern of PROVIDERS[id]) { + if (globMatch(pattern, normalised)) { + return id; + } + } + } + return undefined; +} + +/** + * Tiny glob matcher covering exactly the dialect our patterns use: + * `**` (any number of path segments), `*` (anything but `/`), and + * brace alternatives `{a,b}`. Sufficient for `**\/.github/workflows/*.{yml,yaml}` + * and similar; not a general-purpose minimatch replacement. + */ +function globMatch(pattern: string, path: string): boolean { + // Expand brace alternatives into a list of plain globs. + const branches = expandBraces(pattern); + for (const branch of branches) { + if (toRegex(branch).test(path)) return true; + } + return false; +} + +function expandBraces(pattern: string): string[] { + const match = /^(.*)\{([^{}]+)\}(.*)$/.exec(pattern); + if (!match) return [pattern]; + const [, head, body, tail] = match; + return body + .split(",") + .flatMap((alt) => expandBraces(`${head}${alt}${tail}`)); +} + +function toRegex(pattern: string): RegExp { + // Walk the pattern char by char to translate `**`, `*`, and + // everything else (escaped). `**` matches zero-or-more path + // segments; `*` matches anything but `/`. + let re = ""; + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] === "*" && pattern[i + 1] === "*") { + re += ".*"; + i++; + // Eat an immediately-following `/` so `**/x` matches `x` too. + if (pattern[i + 1] === "/") i++; + } else if (pattern[i] === "*") { + re += "[^/]*"; + } else if (/[.+?^${}()|[\]\\]/.test(pattern[i])) { + re += "\\" + pattern[i]; + } else { + re += pattern[i]; + } + } + return new RegExp(`^${re}$`); +}