Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,37 +116,60 @@ 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
# bumps them via the standard npm config. `npx` here resolves
# 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

- name: Publish to VS Code Marketplace
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
npx vsce publish \
npx vsce publish $PRERELEASE_FLAG \
--packagePath "$VSIX_PATH" \
--pat "$VSCE_PAT"

- name: Publish to Open VSX
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
env:
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)
21 changes: 21 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
114 changes: 114 additions & 0 deletions src/codeLens.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
117 changes: 117 additions & 0 deletions src/codeLens.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
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",
}),
];
}
}
33 changes: 29 additions & 4 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string>("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<string[]>("disabledProviders", []) as ProviderId[],
);
const provider = providerForPath(uri.fsPath);
if (provider && disabled.has(provider)) {
next(uri, []);
return;
}
const threshold = config.get<string>("severityThreshold", "low");
next(uri, filterByThreshold(diagnostics, threshold));
},
},
Expand Down Expand Up @@ -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", () =>
Expand Down
Loading