From e22d07e9d61bea7311d7538093bd2c841f512463 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Fri, 5 Jun 2026 16:14:22 +0300 Subject: [PATCH 01/10] feat(query): add CodeClimate and badge CI output formats. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitLab Code Quality ingestion and README/CI issue-count summaries are output modes on existing recipe rows — flat minor severity and codemap-badge/v1 per plan F.7/F.8. --- .changeset/ci-output-formats.md | 5 + docs/architecture.md | 2 +- docs/plans/ci-output-formats.md | 41 ++++---- src/application/http-server.ts | 11 +- src/application/output-formatters.test.ts | 123 ++++++++++++++++++++++ src/application/output-formatters.ts | 100 ++++++++++++++++++ src/application/tool-handlers.test.ts | 67 ++++++++++++ src/application/tool-handlers.ts | 99 +++++++++++++---- src/cli/aliases.ts | 2 +- src/cli/cmd-query.test.ts | 70 +++++++++++- src/cli/cmd-query.ts | 82 ++++++++++++++- src/cli/main.ts | 1 + 12 files changed, 552 insertions(+), 51 deletions(-) create mode 100644 .changeset/ci-output-formats.md diff --git a/.changeset/ci-output-formats.md b/.changeset/ci-output-formats.md new file mode 100644 index 00000000..2d469df2 --- /dev/null +++ b/.changeset/ci-output-formats.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": minor +--- + +Add `query --format codeclimate` (GitLab Code Quality JSON) and `query --format badge` (markdown issue-count line or `codemap-badge/v1` JSON via `--badge-style json`). Available on CLI, MCP, and HTTP query tools. diff --git a/docs/architecture.md b/docs/architecture.md index d9b98544..d5db48a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,7 +122,7 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store **Query wiring:** Ad-hoc and recipe CLI SQL runs through **`printQueryResult`** in **`src/application/index-engine.ts`**, which sets **`PRAGMA query_only = 1`** before execute (parity with **`queryRows`** / **`executeQuery`**). **`src/cli/cmd-query.ts`** (argv, `--recipe` / `-r` alias, **`--summary`**, **`--changed-since`**, **`--group-by`**, **`--save-baseline`** / **`--baseline`** / **`--baselines`** / **`--drop-baseline`**, **`--ci`** (aliases `--format sarif` + non-zero exit on findings + quiet)), **`src/application/query-recipes.ts`** (**`QUERY_RECIPES`** — recipe registry proxy over bundled + project-local recipes; optional **`actions: RecipeAction[]`** per recipe), **`src/cli/main.ts`** (**`--recipes-json`** / **`--print-sql`** exit before config/DB). With **`--json`**, errors use **`{"error":"…"}`** on stdout for SQL failures, DB open, and bootstrap (same shape); **`runQueryCmd`** sets **`process.exitCode`** instead of **`process.exit`**. Friendlier "no `.codemap/index.db`" — `no such table: ` and `no such column: ` errors are rewritten in **`enrichQueryError`** to point at `codemap` / `codemap --full`. **`--summary`** filters output only — the SQL still executes against the index; output collapses to `{"count": N}` (with `--json`) or `count: N`. **`--changed-since `** post-filters result rows by `path` / `file_path` / `from_path` / `to_path` / `resolved_path` against `git diff --name-only ...HEAD ∪ git status --porcelain` (helper: **`src/git-changed.ts`** — `getFilesChangedSince`, `filterRowsByChangedFiles`, `PATH_COLUMNS`); rows with no recognised path column pass through. **`--group-by `** (`owner` | `directory` | `package`) routes through **`runGroupedQuery`** in `cmd-query.ts` and emits `{"group_by": "", "groups": [{key, count, rows}]}` (or `[{key, count}]` with `--summary`); helpers in **`src/group-by.ts`** (`groupRowsBy`, `firstDirectory`, `loadCodeowners`, `discoverWorkspaceRoots`, `makePackageBucketizer`, `codeownersGlobToRegex`). CODEOWNERS lookup is last-match-wins (GitHub semantics); workspace discovery reads `package.json` `workspaces` and `pnpm-workspace.yaml` `packages:`. **`--save-baseline[=]`** snapshots the result to the **`query_baselines`** table inside `/index.db` (default `.codemap/index.db`; no parallel JSON files; survives `--full` / SCHEMA bumps because the table is intentionally absent from `dropAll()`); name defaults to `--recipe` id, ad-hoc SQL needs an explicit name. **`--baseline[=]`** replays the SQL, fetches the saved row set, and emits `{baseline:{...}, current_row_count, added: [...], removed: [...]}` (or `{baseline:{...}, current_row_count, added: N, removed: N}` with `--summary`); identity is per-row multiset equality (canonical `JSON.stringify` keyed frequency map — duplicate rows are tracked, not collapsed). No fuzzy "changed" category in v1. **`--group-by` is mutually exclusive** with both `--save-baseline` and `--baseline` (different output shapes). **`--baselines`** (read-only list) and **`--drop-baseline `** complete the surface; helpers in **`src/db.ts`** (`upsertQueryBaseline`, `getQueryBaseline`, `listQueryBaselines`, `deleteQueryBaseline`). **Per-row recipe `actions`** are appended only when the user runs **`--recipe `** with **`--json`** AND the recipe defines an `actions` template — programmatic `cm.query(sql)` and ad-hoc CLI SQL never carry actions; under `--baseline`, actions attach to `added` rows only (the rows the agent should act on). The **`components-by-hooks`** recipe ranks by hook count with a **comma-based tally** on **`hooks_used`** (no SQLite JSON1). Shipped **`templates/agents/`** documents **`codemap query --json`** as the primary agent example ([README § CLI](../README.md#cli)). -**Output formatters:** **`src/application/output-formatters.ts`** — pure transport-agnostic; **`formatSarif`** emits SARIF 2.1.0 (auto-detected location columns: `file_path` / `path` / `to_path` / `from_path` priority + optional `line_start` / `line_end` region; `rule.id = codemap.` for `--recipe`, `codemap.adhoc` for ad-hoc SQL; aggregate recipes without locations → `results: []` + stderr warning); **`formatAuditSarif`** emits the audit-shaped variant — one rule per delta key (`codemap.audit.-added`), one result per `added` row at severity `warning`; `removed` rows excluded (SARIF surfaces findings, not cleanups); location-only rows fall back to `"new : "` messages; **`formatAnnotations`** emits `::notice file=…,line=…::msg` GitHub Actions workflow commands (one line per locatable row; messages collapsed to a single line because the GH parser stops at the first newline); **`formatMermaid`** emits a `flowchart LR` from `{from, to, label?, kind?}` rows with a hard `MERMAID_MAX_EDGES = 50` ceiling — unbounded inputs reject with a scope-suggestion error naming the recipe + count + `LIMIT` / `--via` / `WHERE` knobs (auto-truncation deliberately out of scope; would be a verdict masquerading as output mode); **`formatDiff`** emits read-only unified diff text from `{file_path, line_start, before_pattern, after_pattern}` rows; **`formatDiffJson`** emits structured `{files, warnings, summary}` hunks for agents. Diff formatters read source files at format time and surface `stale` / `missing` flags when the indexed line no longer matches. Wired into both **`src/cli/cmd-query.ts`** (`--format `; `--format` overrides `--json`; formatted outputs reject `--summary` / `--group-by` / baseline at parse time) and the MCP **`query`** / **`query_recipe`** tools (`format: "sarif" | "annotations" | "mermaid" | "diff" | "diff-json"` with the same incompatibility guard). Per-recipe `sarifLevel` / `sarifMessage` / `sarifRuleId` overrides via frontmatter on `.md` deferred to v1.x. +**Output formatters:** **`src/application/output-formatters.ts`** — pure transport-agnostic; **`formatSarif`** emits SARIF 2.1.0 (auto-detected location columns: `file_path` / `path` / `to_path` / `from_path` priority + optional `line_start` / `line_end` region; `rule.id = codemap.` for `--recipe`, `codemap.adhoc` for ad-hoc SQL; aggregate recipes without locations → `results: []` + stderr warning); **`formatAuditSarif`** emits the audit-shaped variant — one rule per delta key (`codemap.audit.-added`), one result per `added` row at severity `warning`; `removed` rows excluded (SARIF surfaces findings, not cleanups); location-only rows fall back to `"new : "` messages; **`formatAnnotations`** emits `::notice file=…,line=…::msg` GitHub Actions workflow commands (one line per locatable row; messages collapsed to a single line because the GH parser stops at the first newline); **`formatCodeClimate`** emits a GitLab Code Quality JSON array (`severity: minor` flat in v1; stable SHA-256 fingerprints from recipe id + path + line + check name); **`formatBadge`** / **`formatBadgeJson`** emit a single-line markdown summary (`codemap: N issues` / `codemap: clean`) or `codemap-badge/v1` JSON (`--badge-style json` / MCP `badge_style`) from locatable-row count — agents triage via JSON rows, not badge severity; **`formatMermaid`** emits a `flowchart LR` from `{from, to, label?, kind?}` rows with a hard `MERMAID_MAX_EDGES = 50` ceiling — unbounded inputs reject with a scope-suggestion error naming the recipe + count + `LIMIT` / `--via` / `WHERE` knobs (auto-truncation deliberately out of scope; would be a verdict masquerading as output mode); **`formatDiff`** emits read-only unified diff text from `{file_path, line_start, before_pattern, after_pattern}` rows; **`formatDiffJson`** emits structured `{files, warnings, summary}` hunks for agents. Diff formatters read source files at format time and surface `stale` / `missing` flags when the indexed line no longer matches. Wired into both **`src/cli/cmd-query.ts`** (`--format `; `--format` overrides `--json`; formatted outputs reject `--summary` / `--group-by` / baseline at parse time) and the MCP **`query`** / **`query_recipe`** tools (`format: "sarif" | "annotations" | "mermaid" | "diff" | "diff-json" | "codeclimate" | "badge"` with the same incompatibility guard). Per-recipe `sarifLevel` / `sarifMessage` / `sarifRuleId` overrides via frontmatter on `.md` deferred to v1.x. **Validate wiring:** **`src/cli/cmd-validate.ts`** (argv + render) + **`src/application/validate-engine.ts`** (engine — **`computeValidateRows`** + **`toProjectRelative`**). `computeValidateRows` is a pure function over `(db, projectRoot, paths)` returning `{path, status}` rows where `status ∈ stale | missing | unindexed`. CLI wraps it with read-once-and-print + exits **1** on any drift (git-status semantics). Path normalization: **`toProjectRelative`** converts CLI input to POSIX-style relative keys matching the `files.path` storage format (Windows backslash → forward slash); same convention as `lint-staged.config.js`. Also reused by `cmd-show.ts` / `cmd-snippet.ts` and the MCP show/snippet handlers — single canonical implementation. diff --git a/docs/plans/ci-output-formats.md b/docs/plans/ci-output-formats.md index 5557496b..dddccb68 100644 --- a/docs/plans/ci-output-formats.md +++ b/docs/plans/ci-output-formats.md @@ -39,31 +39,33 @@ Moat A: formatters only — no new analysis. ### Out of scope (v1) -`audit --format codeclimate`; shields.io network fetch; formatters reading recipe frontmatter `severity` unless Q1 resolves in slice 1. +`audit --format codeclimate`; shields.io network fetch; HTTP `/badge` endpoint; recipe frontmatter `severity:` (see F.7). --- ## Pre-locked decisions -| # | Decision | Source | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | -| F.1 | **Two new format ids** — `codeclimate` and `badge` on `codemap query` / MCP `query` / `query_recipe` (same `output-formatters.ts` home as SARIF). | [Moat A](../roadmap.md#moats-load-bearing) — output mode only | -| F.2 | **Code Climate shape** — JSON array of objects: `description`, `check_name`, `fingerprint`, `location.path`, `location.lines.begin`, `severity` (`info`\|`minor`\|`major`\|`critical`\|`blocker`). | [GitLab Code Quality format](https://docs.gitlab.com/ee/ci/testing/code_quality.html) | -| F.3 | **Fingerprint** — stable hash from `(recipe_id, file_path, line_start, check_name)` (FNV-1a or SHA-256 truncated) so GitLab dedupes across runs. | GitLab dedup semantics | -| F.4 | **Badge format** — single-line markdown or shields-compatible JSON snippet: `codemap: N issues` derived from row count (or `--summary` count when composed). No network fetch in core — consumers paste into README workflows. | Output formatter only | -| F.5 | **Location contract** — reuse `detectLocationColumn` from SARIF/annotations; skip rows without locatable columns (same stderr warning as SARIF aggregates). | `output-formatters.ts` | -| F.6 | **Audit parity deferred** — v1 on `query`/`query_recipe` only; `audit --format codeclimate` follows if consumer asks. | Tracer bullet | +| # | Decision | Source | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| F.1 | **Two new format ids** — `codeclimate` and `badge` on `codemap query` / MCP `query` / `query_recipe` (same `output-formatters.ts` home as SARIF). | [Moat A](../roadmap.md#moats-load-bearing) — output mode only | +| F.2 | **Code Climate shape** — JSON array of objects: `description`, `check_name`, `fingerprint`, `location.path`, `location.lines.begin`, `severity` (`info`\|`minor`\|`major`\|`critical`\|`blocker`). | [GitLab Code Quality format](https://docs.gitlab.com/ee/ci/testing/code_quality.html) | +| F.3 | **Fingerprint** — stable hash from `(recipe_id, file_path, line_start, check_name)` (FNV-1a or SHA-256 truncated) so GitLab dedupes across runs. | GitLab dedup semantics | +| F.4 | **Badge count source** — row count after location filtering (same rows Code Climate would emit); no network fetch in core. | Output formatter only | +| F.5 | **Location contract** — reuse `detectLocationColumn` from SARIF/annotations; skip rows without locatable columns (same stderr warning as SARIF aggregates). | `output-formatters.ts` | +| F.6 | **Audit parity deferred** — v1 on `query`/`query_recipe` only; `audit --format codeclimate` follows if consumer asks. | Tracer bullet | +| F.7 | **Code Climate severity — flat `minor`** — every row gets `severity: "minor"` in v1; no recipe frontmatter `severity:` parsing. Matches SARIF's flat `level: "note"` (Moat A — presentation, not verdict). Agents triage via recipe id + `actions:` + JSON rows, not CI severity bands. `info` avoided — GitLab default widgets often hide it. Frontmatter `severity:` deferred to v1.x when a recipe needs differentiated GitLab sorting and the field is exposed in recipe catalog too. | Grill-me Q1 | +| F.8 | **Badge — B-lite (`BadgeSummary` + dual serializers)** — internal `BadgeSummary` `{ label, message, count, status }` where `status` is `pass` when `count === 0` else `fail`, `message` is `clean` when zero else `` `${count} issue(s)` ``. **Default stdout:** markdown `codemap: ` (README / PR paste). **Opt-in:** `--badge-style json` emits `codemap-badge/v1`: `{ schema, label, message, count, status }`. Agents: triage via `query_recipe` JSON / `--summary`; paste markdown; CI gates read JSON `.count` / `.status`. Shields colors + HTTP `/badge` reuse this schema later. | Grill-me Q2 | --- ## Implementation steps 1. `formatCodeClimate(opts: FormatOpts): string` in `output-formatters.ts`. -2. `formatBadge(opts: FormatOpts): string` — param `badge_style: markdown|json` optional on query flags. +2. `buildBadgeSummary(opts)` + `formatBadge` (markdown default) + `formatBadgeJson` (`codemap-badge/v1`; see F.8). CLI/MCP flag `--badge-style markdown|json` (default `markdown`). 3. Wire `--format codeclimate|badge` in `cmd-query.ts` + `query-engine.ts` validation list. -4. MCP/HTTP `format` enum extension + tool description one-liner. -5. Snapshot tests in `output-formatters.test.ts` (fixture rows → golden JSON). -6. Docs — `architecture.md` output formatters §; README one example for GitLab CI artifact upload. +4. MCP/HTTP `format` enum extension + `badge_style` on query tools when `format=badge`. +5. Snapshot tests in `output-formatters.test.ts` — Code Climate golden JSON; badge markdown + `codemap-badge/v1` JSON goldens. +6. Docs — `architecture.md` output formatters §; README GitLab CI artifact example; agent note: badge is presentation — use JSON rows for triage. --- @@ -73,6 +75,7 @@ Moat A: formatters only — no new analysis. bun test src/application/output-formatters.test.ts bun src/index.ts query --recipe boundary-violations --format codeclimate bun src/index.ts query --recipe boundary-violations --format badge +bun src/index.ts query --recipe boundary-violations --format badge --badge-style json # Run formatter output through GitLab Code Quality schema validator if available ``` @@ -82,20 +85,12 @@ bun src/index.ts query --recipe boundary-violations --format badge - [ ] `codemap query --recipe boundary-violations --format codeclimate` emits valid GitLab-ingestible JSON - [ ] Fingerprints stable across two runs with identical rows -- [ ] `badge` format returns deterministic single-line summary for N>0 and N=0 +- [ ] `badge` markdown: `codemap: N issues` / `codemap: clean` for N>0 and N=0 +- [ ] `badge --badge-style json` emits stable `codemap-badge/v1` with matching `count` / `status` - [ ] Incompatible with `summary` / `group_by` / `baseline` (same rules as SARIF) --- -## Open decisions (impl PR) - -| # | Question | -| --- | ---------------------------------------------------------------------- | -| Q1 | Map recipe severity from frontmatter `severity:` field when present? | -| Q2 | `badge` as markdown only, or also `codemap-badge/v1` JSON for shields? | - ---- - ## Dependencies - Shipped: `formatSarif`, `formatAnnotations`, location detection diff --git a/src/application/http-server.ts b/src/application/http-server.ts index a5181b97..befa787e 100644 --- a/src/application/http-server.ts +++ b/src/application/http-server.ts @@ -441,14 +441,15 @@ function writeToolResult( return writeJson(res, 200, result.payload, version); } res.statusCode = 200; - res.setHeader( - "Content-Type", + const jsonPayload = result.format === "sarif" ? "application/sarif+json" - : result.format === "diff-json" + : result.format === "diff-json" || + result.format === "codeclimate" || + (result.format === "badge" && result.badgeStyle === "json") ? "application/json; charset=utf-8" - : "text/plain; charset=utf-8", - ); + : "text/plain; charset=utf-8"; + res.setHeader("Content-Type", jsonPayload); res.setHeader("X-Codemap-Version", version); res.end(result.payload); } diff --git a/src/application/output-formatters.test.ts b/src/application/output-formatters.test.ts index ba241ee8..da35b7a0 100644 --- a/src/application/output-formatters.test.ts +++ b/src/application/output-formatters.test.ts @@ -10,11 +10,16 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + buildBadgeSummary, + buildCodeClimateFingerprint, buildMessageText, detectLocationColumn, escapeAnnotationData, escapeAnnotationProperty, formatAuditSarif, + formatBadge, + formatBadgeJson, + formatCodeClimate, formatDiff, formatDiffJson, formatAnnotations, @@ -240,6 +245,124 @@ describe("formatSarif", () => { }); }); +describe("formatCodeClimate", () => { + it("emits [] for empty rows", () => { + expect( + formatCodeClimate({ rows: [], recipeId: "boundary-violations" }), + ).toBe("[]"); + }); + + it("emits one issue per locatable row with flat minor severity", () => { + const out = formatCodeClimate({ + rows: [ + { + from_path: "src/ui/App.tsx", + line_start: 3, + rule_name: "ui-cant-touch-server", + }, + ], + recipeId: "boundary-violations", + }); + const issues = JSON.parse(out); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + check_name: "boundary-violations", + severity: "minor", + location: { + path: "src/ui/App.tsx", + lines: { begin: 3 }, + }, + description: "rule_name=ui-cant-touch-server", + }); + expect(issues[0].fingerprint).toBe( + buildCodeClimateFingerprint( + "boundary-violations", + "src/ui/App.tsx", + 3, + "boundary-violations", + ), + ); + }); + + it("skips rows without a location column", () => { + const out = formatCodeClimate({ + rows: [ + { kind: "TODO", count: 5 }, + { file_path: "a.ts", name: "foo" }, + ], + recipeId: "mixed", + }); + expect(JSON.parse(out)).toHaveLength(1); + }); + + it("fingerprints are stable across identical inputs", () => { + const row = { file_path: "a.ts", line_start: 1, name: "foo" }; + const a = JSON.parse( + formatCodeClimate({ rows: [row], recipeId: "deprecated-symbols" }), + )[0].fingerprint; + const b = JSON.parse( + formatCodeClimate({ rows: [row], recipeId: "deprecated-symbols" }), + )[0].fingerprint; + expect(a).toBe(b); + }); +}); + +describe("formatBadge", () => { + it("markdown clean when no locatable rows", () => { + expect( + formatBadge({ rows: [{ kind: "TODO" }], recipeId: "index-summary" }), + ).toBe("codemap: clean"); + }); + + it("markdown pluralizes issue count from locatable rows only", () => { + expect( + formatBadge({ + rows: [ + { file_path: "a.ts", name: "foo" }, + { kind: "noise" }, + { file_path: "b.ts", name: "bar" }, + ], + recipeId: "deprecated-symbols", + }), + ).toBe("codemap: 2 issues"); + }); + + it("markdown singular for one issue", () => { + expect( + formatBadge({ + rows: [{ file_path: "a.ts", name: "foo" }], + recipeId: "deprecated-symbols", + }), + ).toBe("codemap: 1 issue"); + }); + + it("json emits codemap-badge/v1", () => { + const doc = JSON.parse( + formatBadgeJson({ + rows: [{ file_path: "a.ts", name: "foo" }], + recipeId: "deprecated-symbols", + }), + ); + expect(doc).toEqual({ + schema: "codemap-badge/v1", + label: "codemap", + message: "1 issue", + count: 1, + status: "fail", + }); + }); + + it("buildBadgeSummary matches markdown/json count", () => { + const rows = [{ file_path: "a.ts" }, { file_path: "b.ts" }]; + const summary = buildBadgeSummary({ rows, recipeId: "fan-in" }); + expect(summary).toMatchObject({ + count: 2, + status: "fail", + message: "2 issues", + }); + }); +}); + describe("formatAnnotations", () => { it("emits ::notice file=…,line=…::msg per row", () => { const out = formatAnnotations({ diff --git a/src/application/output-formatters.ts b/src/application/output-formatters.ts index c962bb08..1a4a3214 100644 --- a/src/application/output-formatters.ts +++ b/src/application/output-formatters.ts @@ -15,6 +15,7 @@ * and the MCP `query` / `query_recipe` tools. */ +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -171,6 +172,105 @@ export function formatSarif(opts: FormatOpts): string { return JSON.stringify(sarif, null, 2); } +/** Rows with a detectable file-path column — same set Code Climate / badge count. */ +export function countLocatableFindings( + rows: Record[], +): number { + return rows.filter((row) => detectLocationColumn(row) !== null).length; +} + +/** + * Stable GitLab Code Quality fingerprint from `(recipe_id, file_path, + * line_start, check_name)` per plan F.3 — SHA-256 truncated to 16 hex chars. + */ +export function buildCodeClimateFingerprint( + recipeId: string | undefined, + filePath: string, + lineStart: number | undefined, + checkName: string, +): string { + const line = + lineStart !== undefined && lineStart > 0 ? String(lineStart) : ""; + const key = `${recipeId ?? "adhoc"}\0${filePath}\0${line}\0${checkName}`; + return createHash("sha256").update(key).digest("hex").slice(0, 16); +} + +/** + * Format the row-set as a GitLab Code Quality JSON array (plan F.2 / F.7). + * Rows without a location column are skipped; empty input → `[]`. + */ +export function formatCodeClimate(opts: FormatOpts): string { + const checkName = opts.recipeId ?? "adhoc"; + const issues = opts.rows.flatMap((row) => { + const locCol = detectLocationColumn(row); + if (locCol === null) return []; + const path = row[locCol] as string; + const lineStartRaw = row["line_start"]; + const lineStart = + typeof lineStartRaw === "number" && lineStartRaw > 0 + ? lineStartRaw + : undefined; + const location: { path: string; lines?: { begin: number } } = { path }; + if (lineStart !== undefined) { + location.lines = { begin: lineStart }; + } + return [ + { + description: buildMessageText(row), + check_name: checkName, + fingerprint: buildCodeClimateFingerprint( + opts.recipeId, + path, + lineStart, + checkName, + ), + severity: "minor" as const, + location, + }, + ]; + }); + return JSON.stringify(issues, null, 2); +} + +export type BadgeStyle = "markdown" | "json"; + +export interface BadgeSummary { + label: "codemap"; + message: string; + count: number; + status: "pass" | "fail"; +} + +export interface CodemapBadgeV1 extends BadgeSummary { + schema: "codemap-badge/v1"; +} + +/** Issue count from locatable rows only (plan F.4 / F.8). */ +export function buildBadgeSummary(opts: FormatOpts): BadgeSummary { + const count = countLocatableFindings(opts.rows); + const message = + count === 0 ? "clean" : count === 1 ? "1 issue" : `${count} issues`; + return { + label: "codemap", + message, + count, + status: count === 0 ? "pass" : "fail", + }; +} + +/** Single-line markdown badge: `codemap: N issues` / `codemap: clean`. */ +export function formatBadge(opts: FormatOpts): string { + const summary = buildBadgeSummary(opts); + return `codemap: ${summary.message}`; +} + +/** `codemap-badge/v1` JSON document (plan F.8). */ +export function formatBadgeJson(opts: FormatOpts): string { + const summary = buildBadgeSummary(opts); + const doc: CodemapBadgeV1 = { schema: "codemap-badge/v1", ...summary }; + return JSON.stringify(doc, null, 2); +} + /** Removed rows intentionally excluded — SARIF surfaces findings to act on, not cleanups. */ export interface AuditSarifDelta { key: string; diff --git a/src/application/tool-handlers.test.ts b/src/application/tool-handlers.test.ts index dca36a02..392fe7cd 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -95,6 +95,73 @@ describe("handleQuery baseline", () => { }); }); + it("format=codeclimate returns GitLab-shaped JSON array", () => { + const result = handleQuery( + { + sql: "SELECT file_path, line_start, name FROM symbols", + format: "codeclimate", + }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok || result.format !== "codeclimate") return; + const issues = JSON.parse(result.payload); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + check_name: "adhoc", + severity: "minor", + location: { path: "src/query.ts", lines: { begin: 1 } }, + }); + }); + + it("format=badge returns markdown summary", () => { + const result = handleQuery( + { + sql: "SELECT file_path, line_start, name FROM symbols", + format: "badge", + }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: true, + format: "badge", + payload: "codemap: 1 issue", + badgeStyle: "markdown", + }); + }); + + it("format=badge badge_style=json returns codemap-badge/v1", () => { + const result = handleQuery( + { + sql: "SELECT file_path, line_start, name FROM symbols", + format: "badge", + badge_style: "json", + }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok || result.format !== "badge") return; + expect(result.badgeStyle).toBe("json"); + expect(JSON.parse(result.payload)).toMatchObject({ + schema: "codemap-badge/v1", + count: 1, + status: "fail", + }); + }); + + it("rejects badge_style without format=badge", () => { + const result = handleQuery( + { sql: "SELECT 1", format: "sarif", badge_style: "json" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining( + "badge_style is only valid with format=badge", + ), + }); + }); + it("rejects baseline + group_by", () => { const result = handleQuery( { sql: "SELECT 1", baseline: "pre", group_by: "directory" }, diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index d87a25ae..85d1cf05 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -53,8 +53,12 @@ import { findImpact } from "./impact-engine"; import type { ImpactBackend, ImpactDirection } from "./impact-engine"; import { getCurrentCommit } from "./index-engine"; import { runIngestCoverageOnDb } from "./ingest-coverage-run"; +import type { BadgeStyle } from "./output-formatters"; import { formatAnnotations, + formatBadge, + formatBadgeJson, + formatCodeClimate, formatDiff, formatDiffJson, formatMermaid, @@ -106,6 +110,8 @@ export type ToolResult = | { ok: true; format: "mermaid"; payload: string } | { ok: true; format: "diff"; payload: string } | { ok: true; format: "diff-json"; payload: string } + | { ok: true; format: "codeclimate"; payload: string } + | { ok: true; format: "badge"; payload: string; badgeStyle: BadgeStyle } | { ok: false; error: string; status?: 400 | 404 | 500 }; const ok = (payload: unknown): ToolResult => ({ @@ -183,8 +189,12 @@ export const formatEnum = z.enum([ "mermaid", "diff", "diff-json", + "codeclimate", + "badge", ]); +export const badgeStyleEnum = z.enum(["markdown", "json"]); + export const batchItemSchema = z.union([ z.string().min(1, "sql must be a non-empty string"), z.object({ @@ -203,6 +213,7 @@ export const queryArgsSchema = { changed_since: z.string().optional(), group_by: groupByEnum.optional(), format: formatEnum.optional(), + badge_style: badgeStyleEnum.optional(), baseline: z.string().min(1).optional(), }; @@ -211,7 +222,16 @@ export interface QueryArgs { summary?: boolean; changed_since?: string; group_by?: GroupByMode; - format?: "json" | "sarif" | "annotations" | "mermaid" | "diff" | "diff-json"; + format?: + | "json" + | "sarif" + | "annotations" + | "mermaid" + | "diff" + | "diff-json" + | "codeclimate" + | "badge"; + badge_style?: BadgeStyle; baseline?: string; } @@ -235,13 +255,9 @@ export function handleQuery(args: QueryArgs, root: string): ToolResult { if ("error" in payload) return baselineCompareErr(payload.error); return ok(payload); } - if ( - args.format === "sarif" || - args.format === "annotations" || - args.format === "mermaid" || - args.format === "diff" || - args.format === "diff-json" - ) { + if (args.format !== undefined && args.format !== "json") { + const badgeIncompat = badgeStyleIncompatibility(args.format, args); + if (badgeIncompat !== undefined) return err(badgeIncompat); const incompat = formatToolIncompatibility(args.format, args); if (incompat !== undefined) return err(incompat); return runFormattedQuery({ @@ -250,6 +266,7 @@ export function handleQuery(args: QueryArgs, root: string): ToolResult { recipeActions: undefined, changedFiles: changed as Set | undefined, format: args.format, + badgeStyle: args.badge_style, root, }); } @@ -275,6 +292,7 @@ export const queryRecipeArgsSchema = { changed_since: z.string().optional(), group_by: groupByEnum.optional(), format: formatEnum.optional(), + badge_style: badgeStyleEnum.optional(), baseline: z.string().min(1).optional(), params: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) @@ -286,7 +304,16 @@ export interface QueryRecipeArgs { summary?: boolean; changed_since?: string; group_by?: GroupByMode; - format?: "json" | "sarif" | "annotations" | "mermaid" | "diff" | "diff-json"; + format?: + | "json" + | "sarif" + | "annotations" + | "mermaid" + | "diff" + | "diff-json" + | "codeclimate" + | "badge"; + badge_style?: BadgeStyle; baseline?: string; params?: RecipeParamValues; } @@ -334,13 +361,9 @@ export function handleQueryRecipe( tryRecordRecipeRun(args.recipe); return ok(payload); } - if ( - args.format === "sarif" || - args.format === "annotations" || - args.format === "mermaid" || - args.format === "diff" || - args.format === "diff-json" - ) { + if (args.format !== undefined && args.format !== "json") { + const badgeIncompat = badgeStyleIncompatibility(args.format, args); + if (badgeIncompat !== undefined) return err(badgeIncompat); const incompat = formatToolIncompatibility(args.format, args); if (incompat !== undefined) return err(incompat); const result = runFormattedQuery({ @@ -350,6 +373,7 @@ export function handleQueryRecipe( changedFiles: changed as Set | undefined, bindValues: resolvedParams.values, format: args.format, + badgeStyle: args.badge_style, root, }); // Successful runs only; failure-isolated inside the helper. @@ -1252,8 +1276,28 @@ export async function handleIngestCoverage( * change the output shape away from a flat row list. Mirrors the CLI * parser's `formatIncompatibility` for the tool wrapper layer. */ +function badgeStyleIncompatibility( + fmt: QueryArgs["format"], + args: { badge_style?: BadgeStyle }, +): string | undefined { + if (args.badge_style === undefined || args.badge_style === "markdown") { + return undefined; + } + if (fmt !== "badge") { + return "codemap: badge_style is only valid with format=badge."; + } + return undefined; +} + function formatToolIncompatibility( - fmt: "sarif" | "annotations" | "mermaid" | "diff" | "diff-json", + fmt: + | "sarif" + | "annotations" + | "mermaid" + | "diff" + | "diff-json" + | "codeclimate" + | "badge", args: { summary?: boolean; group_by?: GroupByMode }, ): string | undefined { const offenders: string[] = []; @@ -1269,7 +1313,15 @@ function runFormattedQuery(args: { recipeActions: ReadonlyArray | undefined; changedFiles: Set | undefined; bindValues?: RecipeParamValue[] | undefined; - format: "sarif" | "annotations" | "mermaid" | "diff" | "diff-json"; + format: + | "sarif" + | "annotations" + | "mermaid" + | "diff" + | "diff-json" + | "codeclimate" + | "badge"; + badgeStyle?: BadgeStyle | undefined; root: string; }): ToolResult { const payload = executeQuery({ @@ -1313,6 +1365,17 @@ function runFormattedQuery(args: { const text = formatDiffJson({ rows, projectRoot: args.root }); return { ok: true, format: "diff-json", payload: text }; } + if (args.format === "codeclimate") { + const text = formatCodeClimate({ rows, recipeId: args.recipeId }); + return { ok: true, format: "codeclimate", payload: text }; + } + if (args.format === "badge") { + const formatOpts = { rows, recipeId: args.recipeId }; + const style = args.badgeStyle ?? "markdown"; + const text = + style === "json" ? formatBadgeJson(formatOpts) : formatBadge(formatOpts); + return { ok: true, format: "badge", payload: text, badgeStyle: style }; + } const text = formatAnnotations({ rows, recipeId: args.recipeId, diff --git a/src/cli/aliases.ts b/src/cli/aliases.ts index 11d33613..42df9173 100644 --- a/src/cli/aliases.ts +++ b/src/cli/aliases.ts @@ -30,7 +30,7 @@ export function printOutcomeAliasHelp(alias: OutcomeAlias): void { console.log(`Usage: codemap ${alias} [query flags...] Alias for \`codemap query --recipe ${recipeId}\` — every flag accepted by -\`codemap query\` passes through (--json, --format sarif|annotations|mermaid|diff|diff-json, +\`codemap query\` passes through (--json, --format sarif|annotations|mermaid|diff|diff-json|codeclimate|badge, --ci, --summary, --changed-since , --group-by owner|directory|package, --params key=value, --save-baseline[=name], --baseline[=name]). diff --git a/src/cli/cmd-query.test.ts b/src/cli/cmd-query.test.ts index db1a52b4..c439deb2 100644 --- a/src/cli/cmd-query.test.ts +++ b/src/cli/cmd-query.test.ts @@ -23,6 +23,7 @@ describe("parseQueryRest", () => { it("parses SQL after query", () => { const r = parseQueryRest(["query", "SELECT", "1"]); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: "SELECT 1", json: false, @@ -40,6 +41,7 @@ describe("parseQueryRest", () => { it("parses --json and SQL", () => { const r = parseQueryRest(["query", "--json", "SELECT", "1"]); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: "SELECT 1", json: true, @@ -57,6 +59,7 @@ describe("parseQueryRest", () => { it("parses --summary and SQL", () => { const r = parseQueryRest(["query", "--summary", "SELECT", "1"]); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: "SELECT 1", json: false, @@ -74,6 +77,7 @@ describe("parseQueryRest", () => { it("parses --json --summary and SQL", () => { const r = parseQueryRest(["query", "--json", "--summary", "SELECT", "1"]); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: "SELECT 1", json: true, @@ -93,6 +97,7 @@ describe("parseQueryRest", () => { const sql = getQueryRecipeSql("fan-out"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: false, @@ -115,6 +120,7 @@ describe("parseQueryRest", () => { "SELECT 1", ]); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: "SELECT 1", json: false, @@ -141,6 +147,7 @@ describe("parseQueryRest", () => { const sql = getQueryRecipeSql("fan-out"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: true, @@ -164,6 +171,7 @@ describe("parseQueryRest", () => { "SELECT * FROM symbols", ]); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: "SELECT * FROM symbols", json: true, @@ -183,6 +191,7 @@ describe("parseQueryRest", () => { const sql = getQueryRecipeSql("fan-in"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: false, @@ -452,6 +461,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( const sql = getQueryRecipeSql("fan-out-sample-json"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: false, @@ -471,6 +481,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( const sql = getQueryRecipeSql("fan-out"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: false, @@ -537,6 +548,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( const sql = getQueryRecipeSql("fan-out-sample"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: true, @@ -556,6 +568,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( const sql = getQueryRecipeSql("fan-out"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: true, @@ -575,6 +588,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( const sql = getQueryRecipeSql("fan-out"); expect(sql).toBeDefined(); expect(r).toEqual({ + badgeStyle: "markdown", kind: "run", sql: sql!, json: true, @@ -679,8 +693,15 @@ describe("parseQueryRest — --format flag", () => { expect(r.format).toBe("json"); }); - it("accepts --format text|json|sarif|annotations", () => { - for (const fmt of ["text", "json", "sarif", "annotations"] as const) { + it("accepts --format text|json|sarif|annotations|codeclimate|badge", () => { + for (const fmt of [ + "text", + "json", + "sarif", + "annotations", + "codeclimate", + "badge", + ] as const) { const r = parseQueryRest(["query", "--format", fmt, "SELECT 1"]); if (r.kind !== "run") throw new Error(`expected run for ${fmt}`); expect(r.format).toBe(fmt); @@ -848,6 +869,51 @@ describe("parseQueryRest — --format flag", () => { ]); expect(r.kind).toBe("run"); }); + + it("rejects --format codeclimate + --summary", () => { + const r = parseQueryRest([ + "query", + "--format", + "codeclimate", + "--summary", + "-r", + "boundary-violations", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("codeclimate"); + expect(r.message).toContain("--summary"); + } + }); + }); + + it("parses --badge-style json with --format badge", () => { + const r = parseQueryRest([ + "query", + "--format", + "badge", + "--badge-style", + "json", + "-r", + "boundary-violations", + ]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("badge"); + expect(r.badgeStyle).toBe("json"); + }); + + it("rejects --badge-style without --format badge", () => { + const r = parseQueryRest([ + "query", + "--badge-style", + "json", + "-r", + "boundary-violations", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("--badge-style"); + } }); }); diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index 28b8ce48..69ae9983 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -5,8 +5,12 @@ import { printQueryResult, queryRows, } from "../application/index-engine"; +import type { BadgeStyle } from "../application/output-formatters"; import { formatAnnotations, + formatBadge, + formatBadgeJson, + formatCodeClimate, formatDiff, formatDiffJson, formatMermaid, @@ -80,13 +84,21 @@ export const OUTPUT_FORMATS = [ "mermaid", "diff", "diff-json", + "codeclimate", + "badge", ] as const; export type OutputFormat = (typeof OUTPUT_FORMATS)[number]; +export const BADGE_STYLES = ["markdown", "json"] as const; + export function isOutputFormat(s: string): s is OutputFormat { return (OUTPUT_FORMATS as readonly string[]).includes(s); } +export function isBadgeStyle(s: string): s is BadgeStyle { + return (BADGE_STYLES as readonly string[]).includes(s); +} + export function parseQueryRest(rest: string[]): | { kind: "help" } | { kind: "error"; message: string } @@ -95,6 +107,8 @@ export function parseQueryRest(rest: string[]): sql: string; json: boolean; format: OutputFormat; + /** `--badge-style` when `--format badge` (default `markdown`). */ + badgeStyle: BadgeStyle; /** `--ci` aliases `--format sarif` + non-zero exit + quiet. */ ci: boolean; summary: boolean; @@ -136,6 +150,7 @@ export function parseQueryRest(rest: string[]): let saveBaseline: string | true | undefined; let baseline: string | true | undefined; let recipeParams: RecipeParamValues | undefined; + let badgeStyle: BadgeStyle = "markdown"; while (i < rest.length) { const a = rest[i]; @@ -152,6 +167,25 @@ export function parseQueryRest(rest: string[]): i++; continue; } + if (a === "--badge-style" || a.startsWith("--badge-style=")) { + const eq = a.indexOf("="); + const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1]; + if (v === undefined || v === "" || v.startsWith("-")) { + return { + kind: "error", + message: `codemap: "--badge-style" requires a value (${BADGE_STYLES.join(" | ")}).`, + }; + } + if (!isBadgeStyle(v)) { + return { + kind: "error", + message: `codemap: unknown --badge-style "${v}". Known styles: ${BADGE_STYLES.join(", ")}.`, + }; + } + badgeStyle = v; + i += eq !== -1 ? 1 : 2; + continue; + } if (a === "--format" || a.startsWith("--format=")) { const eq = a.indexOf("="); const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1]; @@ -491,11 +525,18 @@ export function parseQueryRest(rest: string[]): baseline, }); if (incompat !== undefined) return { kind: "error", message: incompat }; + if (badgeStyle !== "markdown" && resolved !== "badge") { + return { + kind: "error", + message: 'codemap: "--badge-style" is only valid with --format badge.', + }; + } return { kind: "run", sql, json, format: resolved, + badgeStyle, ci, summary, changedSince, @@ -547,11 +588,18 @@ export function parseQueryRest(rest: string[]): baseline, }); if (incompat !== undefined) return { kind: "error", message: incompat }; + if (badgeStyle !== "markdown" && resolved !== "badge") { + return { + kind: "error", + message: 'codemap: "--badge-style" is only valid with --format badge.', + }; + } return { kind: "run", sql, json, format: resolved, + badgeStyle, ci, summary, changedSince, @@ -611,7 +659,9 @@ function formatIncompatibility( fmt !== "annotations" && fmt !== "mermaid" && fmt !== "diff" && - fmt !== "diff-json" + fmt !== "diff-json" && + fmt !== "codeclimate" && + fmt !== "badge" ) return undefined; const offenders: string[] = []; @@ -710,8 +760,12 @@ Flags: diff Unified diff from rows shaped as {file_path, line_start, before_pattern, after_pattern}. diff-json Structured diff envelope for agents. + codeclimate GitLab Code Quality JSON array (severity minor; stable fingerprints). + badge Single-line issue count (codemap: N issues / codemap: clean). Formatted outputs require a flat row list — incompatible with --summary, --group-by, --save-baseline, --baseline (parser rejects at parse time). + --badge-style