diff --git a/.changeset/ci-output-formats.md b/.changeset/ci-output-formats.md new file mode 100644 index 0000000..c19eb60 --- /dev/null +++ b/.changeset/ci-output-formats.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +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/README.md b/README.md index 9dd2733..5649c8a 100644 --- a/README.md +++ b/README.md @@ -131,17 +131,23 @@ codemap audit --base v1.0.0 --files-baseline pre-release-files # mix --base wit # non-git projects get a clean `codemap audit: --base requires a git repository.` error. # Recipes that define per-row action templates append "actions" hints (kebab-case verb + # description) in --json output; ad-hoc SQL never carries actions. Inspect via --recipes-json. -# --format — pipe results into GitHub Code Scanning -# (SARIF 2.1.0), surface findings inline on PRs (GH Actions ::notice file=…,line=…::msg), or -# render edge-shaped recipes as Mermaid `flowchart LR`, or preview edits as unified diffs. All +# --format — SARIF for GitHub +# Code Scanning; annotations for GH Actions ::notice lines; codeclimate for GitLab Code Quality; +# badge for issue-count summaries; mermaid/diff for graph and edit previews. All # formatted outputs require a flat row list -# (no --summary / --group-by / baseline). SARIF / annotations auto-detect file_path / -# path / to_path / from_path; rule.id is codemap. (or codemap.adhoc). Mermaid +# (no --summary / --group-by / baseline). SARIF / annotations / codeclimate / badge +# auto-detect file_path / path / to_path / from_path; rule.id is codemap. +# (or codemap.adhoc). codeclimate/badge skip aggregate-only rows. Mermaid # requires {from, to, label?, kind?} rows and rejects unbounded inputs (>50 edges) with a # scope-suggestion error — alias columns via SELECT col AS "from", col2 AS "to". codemap query --recipe deprecated-symbols --format sarif > findings.sarif codemap query --recipe deprecated-symbols --ci # CI shortcut: --format sarif + non-zero exit + quiet codemap query --recipe deprecated-symbols --format annotations # one ::notice per row +# GitLab Code Quality artifact (locatable rows only; flat minor severity): +codemap query --recipe boundary-violations --format codeclimate > gl-code-quality-report.json +# Badge summary for README paste or CI (counts locatable rows only): +codemap query --recipe boundary-violations --format badge +codemap query --recipe boundary-violations --format badge --badge-style json | jq -e '.status == "pass"' # Render any audit/SARIF output as a markdown PR-summary comment (for repos without # Code Scanning / aggregate audit deltas / bot-context seeding): codemap audit --base origin/main --json | codemap pr-comment - | gh pr comment -F - diff --git a/docs/architecture.md b/docs/architecture.md index d9b9854..5551561 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 + row message (`lines.begin` falls back to `1` when `line_start` absent)); **`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. @@ -192,11 +192,11 @@ Three **mutually exclusive** CLI entry shapes; all converge on `applyDiffPayload **Recipes wiring:** **`src/application/recipes-loader.ts`** (pure transport-agnostic loader) + **`src/application/query-recipes.ts`** (cache + public API — `getQueryRecipeSql` / `getQueryRecipeActions` / `getQueryRecipeParams` / `listQueryRecipeIds` / `listQueryRecipeCatalog` / `getQueryRecipeCatalogEntry`, shared by CLI + MCP). Recipes live as file pairs: **`.sql`** + optional **`.md`**. The loader reads `templates/recipes/` (bundled, ships in npm package next to `templates/agents/`) and `/recipes/` (project-local — default `.codemap/recipes/`; honors `--state-dir` / `CODEMAP_STATE_DIR`; root-only resolution per the registry plan, no walk-up). Project recipes win on id collision; entries that override a bundled id carry **`shadows: true`** in the catalog so agents reading `codemap://recipes` at session start see when a recipe behaves differently from the documented bundled version. Per-row **`actions`** templates and recipe **`params`** declarations live in YAML frontmatter on each `.md` — uniform shape across bundled + project. Param types are `string | number | boolean`; CLI passes values via repeatable `--params key=value[,key=value]`, MCP / HTTP pass nested `params: {key: value}` to `query_recipe`. Validation runs before SQL binding; missing / unknown / malformed params return the same `{error}` envelope as query failures. Hand-rolled YAML parser is scoped to block-list `actions:` and `params:` only (no `js-yaml` dep). Load-time validation rejects empty SQL and DML / DDL keywords (`INSERT` / `UPDATE` / `DELETE` / `DROP` / `CREATE` / `ALTER` / `ATTACH` / `DETACH` / `REPLACE` / `TRUNCATE` / `VACUUM` / `PRAGMA`) with recipe-aware error messages — defence in depth alongside the runtime `PRAGMA query_only=1` backstop in `query-engine.ts` (PR #35). `/index.db` is gitignored; `/recipes/` is NOT (verified via `git check-ignore`) — recipes are git-tracked source code authored for human review. -**Tool / resource handlers (transport-agnostic):** **`src/application/tool-handlers.ts`** + **`src/application/resource-handlers.ts`** — pure functions that take the args object an MCP tool / resource URI accepts and return a discriminated **`ToolResult`** (`{ok: true, format: 'json'|'sarif'|'annotations'|'mermaid'|'diff'|'diff-json', payload}` / `{ok: false, error}`) or a **`ResourcePayload`** (`{mimeType, text}`). MCP and HTTP both wrap the same handlers — MCP translates to `{content: [{type: "text", text}]}`, HTTP translates to `(status, body)` with the right `Content-Type`. Engine layer untouched; transport changes don't ripple into the SQL. +**Tool / resource handlers (transport-agnostic):** **`src/application/tool-handlers.ts`** + **`src/application/resource-handlers.ts`** — pure functions that take the args object an MCP tool / resource URI accepts and return a discriminated **`ToolResult`** (`{ok: true, format: 'json'|'sarif'|'annotations'|'mermaid'|'diff'|'diff-json'|'codeclimate'|'badge', payload}` — badge arm also carries `badgeStyle`; `{ok: false, error}`) or a **`ResourcePayload`** (`{mimeType, text}`). MCP and HTTP both wrap the same handlers — MCP translates to `{content: [{type: "text", text}]}`, HTTP translates to `(status, body)` with the right `Content-Type`. Engine layer untouched; transport changes don't ripple into the SQL. **MCP wiring:** **`src/cli/cmd-mcp.ts`** (argv — `--watch` / `--no-watch` / `--debounce` + `--help`; bootstrap absorbs `--root`/`--config`) + **`src/application/mcp-server.ts`** (transport — tool / resource registry, SDK glue). Mirrors the `cmd-audit.ts ↔ audit-engine.ts` seam — CLI parses + lifecycle; engine owns the SDK. **`runMcpServer`** bootstraps codemap once at server boot (config + resolver + DB access become module-level state), instantiates `McpServer` from **`@modelcontextprotocol/sdk`**, attaches a **`StdioServerTransport`**, and resolves on client disconnect via **`src/application/session-lifecycle.ts`** (`createStdioDisconnectMonitor` — stdin EOF, stdout EPIPE, parent-PID poll — plus SDK `transport.onclose` and SIGINT/SIGTERM). With `--watch`, **`createManagedWatchSession`** holds one client for the stdio session and **`forceStop`** drains the watcher on exit. Tool handlers reuse the existing engine entry-points: **`query`** / **`query_recipe`** call **`executeQuery`** in **`src/application/query-engine.ts`** (same `[...rows]` / `{count}` / `{group_by, groups}` envelope `--json` would print) unless **`baseline`** is set — then **`compareQueryBaseline`** in **`src/application/query-baseline.ts`** (incompatible with non-`json` **`format`** / **`group_by`**); **`ingest_coverage`** calls **`runIngestCoverageOnDb`** in **`src/application/ingest-coverage-run.ts`** (CLI twin: `codemap ingest-coverage --json`); **`query_batch`** loops per statement via **`handleQueryBatch`** → **`executeQuery`** (batch-wide defaults + per-item overrides; items are `string | {sql, summary?, changed_since?, group_by?}`); **`audit`** runs `resolveAuditBaselines` + `runAudit` from PR #33 unchanged; **`context`** / **`validate`** call `buildContextEnvelope` / `computeValidateRows` from **`src/application/context-engine.ts`** + **`src/application/validate-engine.ts`** (lifted out of `src/cli/cmd-*.ts` in PR #41 — see § Tool / resource handlers above). **`save_baseline`** is one polymorphic tool (`{name, sql? | recipe?}`) with a runtime exclusivity check — mirrors the CLI's single `--save-baseline=` verb. **Tool naming**: snake_case throughout — Codemap convention matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. **Resources** split by freshness contract: `codemap://schema`, `codemap://skill`, `codemap://rule`, and `codemap://mcp-instructions` use **lazy memoisation** — first `read_resource` populates a per-server-instance cache; constant for the server-process lifetime so eager-vs-lazy produce identical observable behavior. `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{+path}`, and `codemap://symbols/{name}` are **live read-per-call** (no cache) so inline recency fields and index mutations under `--watch` don't freeze at first-read. `codemap://schema` queries `sqlite_schema` live (on first read, then cached); `codemap://skill` / `codemap://rule` / `codemap://mcp-instructions` call `assembleAgentContent(kind)` from `application/agent-content.ts`, which concatenates section files under `templates/agent-content//` and dispatches `*.gen.md` files through `RENDERERS` (live recipe catalog, live `createTables()` DDL) — see [agents.md § Section assembler](./agents.md#section-assembler-and-genmd). Output shape: each tool returns the JSON payload its CLI counterpart would print (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `context --include-snippets`, `ingest-coverage`); MCP wraps via `content: [{type: "text", text: JSON.stringify(payload)}]`. **`tools/list` ToolAnnotations** — advisory `readOnlyHint` / `destructiveHint` / `idempotentHint` per tool from **`src/application/mcp-tool-annotations.ts`** (central map beside **`mcp-tool-allowlist.ts`**); read paths (`query`, `show`, `audit`, …) → `readOnlyHint: true`; disk-write apply tools → `destructiveHint: true` (writes still require `yes: true`); index user-data mutators (`save_baseline`, `drop_baseline`, `ingest_coverage`) → `readOnlyHint: false` without `destructiveHint`. Omitted when an older `@modelcontextprotocol/sdk` lacks annotation fields (M.6 guard). `--changed-since` git lookups are memoised per `(root, ref)` pair across batch items so a `query_batch` of N items sharing the same ref does one git invocation, not N. Per-statement errors in `query_batch` are isolated — failed statements return `{error}` in their slot while siblings still execute. -**HTTP wiring:** **`src/cli/cmd-serve.ts`** (argv — `--host` / `--port` / `--token`; bootstrap absorbs `--root`/`--config`) + **`src/application/http-server.ts`** (transport — bare `node:http`; routes `POST /tool/{name}` to `tool-handlers`, `GET /resources/{encoded-uri}` to `resource-handlers`, plus `GET /health` / `GET /tools` / `GET /resources`). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`). Optional **`--token `** requires `Authorization: Bearer ` on every request; `GET /health` is auth-exempt so liveness probes work without leaking the token. **CSRF + DNS-rebinding guard** (`csrfCheck`) runs before every route — rejects `Sec-Fetch-Site: cross-site` / `same-site` (modern-browser CSRF), any present `Origin` header (including the opaque string `null`; older-browser CSRF fallback), and `Host` header mismatch on loopback bind (DNS rebinding). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) don't send those headers and pass through. The guard runs even on `/health` so a malicious local webpage can't probe for liveness. Output shape: HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper — HTTP doesn't need that transport artifact); `query` / `query_recipe` match `codemap query --json` row arrays (or `{count}` / `{group_by,groups}` when `summary` / `group_by` is set, or baseline diff when `baseline` is set — incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); other tools match their CLI `--json` envelopes; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`, JSON otherwise. Per-request DB lifecycle: open / `PRAGMA query_only = 1` / close per call (SQLite reader concurrency); 1 MiB request-body cap rejects trivial DoS. **`GET /tools`** returns the same advisory hint fields as MCP `tools/list` (`readOnlyHint` / `destructiveHint` / `idempotentHint` per entry via **`buildHttpToolCatalogEntry`**). SIGINT / SIGTERM → graceful drain via `server.close()`. Every response carries **`X-Codemap-Version: `** so consumers can pin / detect upgrades. +**HTTP wiring:** **`src/cli/cmd-serve.ts`** (argv — `--host` / `--port` / `--token`; bootstrap absorbs `--root`/`--config`) + **`src/application/http-server.ts`** (transport — bare `node:http`; routes `POST /tool/{name}` to `tool-handlers`, `GET /resources/{encoded-uri}` to `resource-handlers`, plus `GET /health` / `GET /tools` / `GET /resources`). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`). Optional **`--token `** requires `Authorization: Bearer ` on every request; `GET /health` is auth-exempt so liveness probes work without leaking the token. **CSRF + DNS-rebinding guard** (`csrfCheck`) runs before every route — rejects `Sec-Fetch-Site: cross-site` / `same-site` (modern-browser CSRF), any present `Origin` header (including the opaque string `null`; older-browser CSRF fallback), and `Host` header mismatch on loopback bind (DNS rebinding). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) don't send those headers and pass through. The guard runs even on `/health` so a malicious local webpage can't probe for liveness. Output shape: HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper — HTTP doesn't need that transport artifact); `query` / `query_recipe` match `codemap query --json` row arrays (or `{count}` / `{group_by,groups}` when `summary` / `group_by` is set, or baseline diff when `baseline` is set — incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); other tools match their CLI `--json` envelopes; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` / `"badge"` (markdown) as `text/plain; charset=utf-8`, `format: "diff-json"` / `"codeclimate"` / `"badge"` + `badge_style: "json"` as `application/json; charset=utf-8`, JSON otherwise. Per-request DB lifecycle: open / `PRAGMA query_only = 1` / close per call (SQLite reader concurrency); 1 MiB request-body cap rejects trivial DoS. **`GET /tools`** returns the same advisory hint fields as MCP `tools/list` (`readOnlyHint` / `destructiveHint` / `idempotentHint` per entry via **`buildHttpToolCatalogEntry`**). SIGINT / SIGTERM → graceful drain via `server.close()`. Every response carries **`X-Codemap-Version: `** so consumers can pin / detect upgrades. **Watch wiring:** **`src/cli/cmd-watch.ts`** (argv — `--debounce ` / `--quiet`; bootstrap absorbs `--root`/`--config`) + **`src/application/watcher.ts`** (engine — pure debouncer + glob filter + injectable backend; production wires [chokidar v5](https://github.com/paulmillr/chokidar) selected via the 6-watcher audit in PR #46 — pure JS, runs identically on Bun + Node, ~30M repos use it). On every change/add/unlink event chokidar emits, the engine filters via `shouldIndexPath` (same indexed extensions as the indexer + project-local recipes; skips `node_modules` / `.git` / `dist`), debounces with a sliding window (default 250 ms), then calls `createReindexOnChange` which opens a DB, runs `runCodemapIndex({mode: 'files', files: [...changed]})`, closes the DB, and logs `reindex N file(s) in Mms` to stderr unless `--quiet`. SIGINT / SIGTERM drains pending edits via `flushNow()` before the watcher closes. **Default-ON for `mcp` / `serve` since 2026-05:** both transports embed the watcher via **`createManagedWatchSession`** in **`session-lifecycle.ts`** — MCP holds one client for the stdio session; HTTP acquires per request (excluding `/health`) and stops the watcher after the last client plus a 5s release grace (not an MCP idle shutdown). Opt out with `--no-watch`, `CODEMAP_WATCH=0`, or `CODEMAP_NO_WATCH=1`. **`src/application/watch-policy.ts`** disables the watcher on WSL2 Windows drive mounts (`/mnt/*`) unless `CODEMAP_FORCE_WATCH=1`; stderr points at `codemap agents init --git-hooks` for git-triggered freshness. Standalone `codemap watch` runs the watcher decoupled from a transport for users wiring it next to a separate MCP / HTTP process. **Audit prelude optimization:** module-level `watchActive` flag; `handleAudit` skips its incremental-index prelude when active (and marks the close as readonly to avoid a wasted checkpoint). Explicit `no_index: false` still forces the prelude. diff --git a/docs/glossary.md b/docs/glossary.md index 217429e..4794470 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -544,7 +544,19 @@ Long-running process that subscribes to filesystem changes via [chokidar v5](htt ### `codemap serve` / HTTP server -Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token ` requires `Authorization: Bearer ` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope, or `baseline` returns a diff envelope (incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); parity twins (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context`, `ingest-coverage`) always emit JSON on CLI without `--json`; other tools match their CLI `--json` payloads when that flag is set; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: `. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage). +Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token ` requires `Authorization: Bearer ` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope, or `baseline` returns a diff envelope (incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); parity twins (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context`, `ingest-coverage`) always emit JSON on CLI without `--json`; other tools match their CLI `--json` payloads when that flag is set; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` / `"badge"` (markdown) as `text/plain; charset=utf-8`, `format: "diff-json"` / `"codeclimate"` / `"badge"` + `badge_style: "json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: `. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage). + +### Code Climate format (`codeclimate`) + +GitLab [Code Quality](https://docs.gitlab.com/ee/ci/testing/code_quality.html) JSON array emitted by `codemap query --format codeclimate` (MCP/HTTP: `format: "codeclimate"`). One object per locatable row (`file_path` / `path` / `to_path` / `from_path` + optional `line_start`); flat `severity: "minor"` in v1; `location.lines.begin` defaults to `1` without `line_start`; stable SHA-256 fingerprints (recipe + path + line + check name + row message) for GitLab dedup. Aggregate recipes emit `[]` + stderr warning. Incompatible with `--summary` / `--group-by` / baseline. + +### Badge format (`badge`) + +Issue-count presentation derived from locatable-row count only — not a triage primitive. CLI: `codemap query --format badge` prints `codemap: N issues` / `codemap: clean`; `--badge-style json` emits `codemap-badge/v1` (`{schema, label, message, count, status}`). MCP/HTTP: `format: "badge"` + optional `badge_style`. Agents should triage via JSON query rows or `--summary`, not badge stdout. + +### `codemap-badge/v1` + +JSON schema for structured badge output (`badge_style: "json"`). Fields: `schema`, `label` (`"codemap"`), `message`, `count`, `status` (`pass` when `count === 0`). Intended for CI gates (`jq -e '.status == "pass"'`). ### SARIF diff --git a/docs/plans/ci-output-formats.md b/docs/plans/ci-output-formats.md deleted file mode 100644 index 5557496..0000000 --- a/docs/plans/ci-output-formats.md +++ /dev/null @@ -1,102 +0,0 @@ -# CI output formats (CodeClimate + badge) — plan - -> **Status:** open · **Priority:** P3 · **Effort:** S–M (~1–2 weeks) -> -> **Motivator:** `query --format` ships `sarif`, `annotations`, and `mermaid`. GitLab Code Quality ingestion expects [Code Climate JSON](https://docs.gitlab.com/ee/ci/testing/code_quality.html); README / CI summary badges need a compact pass/fail or issue-count line. Both are **output modes** on existing recipe rows — not new analysis primitives. -> -> **Roadmap:** [§ Recipe & audit enrichment](../roadmap.md#recipe--audit-enrichment) - ---- - -## Agent start here - -Copy **`formatSarif`** / **`formatAnnotations`** patterns in [`output-formatters.ts`](../../src/application/output-formatters.ts). Reuse **`detectLocationColumn`** — rows without locatable columns get same stderr warning as SARIF. Wire `codeclimate` first; `badge` second. - -### Key touchpoints - -| File | What to read | -| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| [`src/application/output-formatters.ts`](../../src/application/output-formatters.ts) | `formatSarif`, `formatAnnotations`, `detectLocationColumn`, `FormatOpts` | -| [`src/application/output-formatters.test.ts`](../../src/application/output-formatters.test.ts) | Snapshot golden patterns | -| [`src/cli/cmd-query.ts`](../../src/cli/cmd-query.ts) | `--format` validation list | -| [`src/application/query-engine.ts`](../../src/application/query-engine.ts) | Format dispatch | -| [`src/application/tool-handlers.ts`](../../src/application/tool-handlers.ts) | MCP `format` enum | - -### Architecture - -```text -query --recipe X --format codeclimate|badge - → query-engine rows - → output-formatters.formatCodeClimate | formatBadge - → stdout (GitLab artifact / README paste) -``` - -Moat A: formatters only — no new analysis. - -### Tracer bullet (slice 1) - -`formatCodeClimate` + snapshot test on `boundary-violations` fixture rows + `--format codeclimate` in CLI. Badge format in slice 2. - -### Out of scope (v1) - -`audit --format codeclimate`; shields.io network fetch; formatters reading recipe frontmatter `severity` unless Q1 resolves in slice 1. - ---- - -## 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 | - ---- - -## 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. -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. - ---- - -### Verification - -```bash -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 -# Run formatter output through GitLab Code Quality schema validator if available -``` - ---- - -## Acceptance - -- [ ] `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 -- [ ] 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 -- Independent of audit attribution / coverage recipes diff --git a/docs/roadmap.md b/docs/roadmap.md index 7a7185e..6ecb811 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -91,7 +91,6 @@ Predicate-as-API only — enrich row shape and audit deltas; no standalone pass/ - [ ] **Tiered lookup fast paths** — `show` / exact-name recipe paths hit covering indexes first; document latency expectations in MCP tool descriptions. FTS and broad scans remain explicit fallbacks. Effort: S–M. - [ ] **Graph-estimated CRAP recipe** — bundled `high-crap-score`: CRAP = `CC² × (1 - coverage/100)³ + CC` using `symbols.complexity`; **measured** `coverage` when ingested, else **graph-estimated** tiers (85% / 40% / 0% from test-file reachability over `dependencies` / `calls` / `test_suites`). Rows expose `coverage_source: measured | estimated`. Complements `high-complexity-untested` when no coverage file exists. Plan: [`plans/graph-estimated-crap.md`](./plans/graph-estimated-crap.md). Effort: M. - [ ] **Coverage-confirmed dead recipe** — bundled `coverage-confirmed-dead`: JOIN static dead-code predicate (uncalled exports, suppression-aware) with ingested `coverage` — rows carry `confidence: high` when callers = 0 and `coverage_pct = 0`, `medium` when coverage not ingested. Predicate columns only, no verdict primitive ([Moat A](./roadmap.md#moats-load-bearing)). Plan: [`plans/coverage-deletion-confidence.md`](./plans/coverage-deletion-confidence.md). Effort: L–M. -- [ ] **CI output formats (CodeClimate + badge)** — `query --format codeclimate` (GitLab Code Quality JSON array + stable fingerprints) and `query --format badge` (compact issue-count line for README/CI summary). Same location contract as SARIF/annotations. Plan: [`plans/ci-output-formats.md`](./plans/ci-output-formats.md). Effort: S–M. ### Distribution & evaluation depth diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index bb6b782..15b5ef0 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -289,6 +289,56 @@ describe("http-server — POST /tool/query", () => { expect(doc.version).toBe("2.1.0"); }); + it("format=codeclimate returns application/json", async () => { + serverHandle = await startServer(); + const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sql: "SELECT name, file_path, line_start FROM symbols WHERE name = 'bar'", + format: "codeclimate", + }), + }); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("application/json"); + const issues = (await r.json()) as unknown[]; + expect(Array.isArray(issues)).toBe(true); + expect(issues.length).toBeGreaterThan(0); + }); + + it("format=badge markdown returns text/plain", async () => { + serverHandle = await startServer(); + const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sql: "SELECT name, file_path, line_start FROM symbols WHERE name = 'bar'", + format: "badge", + }), + }); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("text/plain"); + expect(await r.text()).toBe("codemap: 1 issue"); + }); + + it("format=badge badge_style=json returns application/json", async () => { + serverHandle = await startServer(); + const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sql: "SELECT name, file_path, line_start FROM symbols WHERE name = 'bar'", + format: "badge", + badge_style: "json", + }), + }); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("application/json"); + const doc = (await r.json()) as { schema: string; count: number }; + expect(doc.schema).toBe("codemap-badge/v1"); + expect(doc.count).toBe(1); + }); + it("format=annotations returns text/plain", async () => { serverHandle = await startServer(); const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, { diff --git a/src/application/http-server.ts b/src/application/http-server.ts index a5181b9..befa787 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/mcp-server.test.ts b/src/application/mcp-server.test.ts index c80bee7..427f7ac 100644 --- a/src/application/mcp-server.test.ts +++ b/src/application/mcp-server.test.ts @@ -517,6 +517,128 @@ describe("MCP server — query_recipe tool", () => { } }); + it("returns Code Climate JSON with format=codeclimate", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, doc_comment) + VALUES ('src/a.ts', 'oldFn', 'function', 1, 5, 'function oldFn()', '/** @deprecated */')`, + ); + } finally { + closeDb(db); + } + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query_recipe", + arguments: { recipe: "deprecated-symbols", format: "codeclimate" }, + }); + const issues = readJson(r); + expect(Array.isArray(issues)).toBe(true); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]).toMatchObject({ + check_name: "deprecated-symbols", + severity: "minor", + location: { path: "src/a.ts", lines: { begin: 1 } }, + }); + } finally { + await server.close(); + } + }); + + it("returns badge markdown with format=badge", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, doc_comment) + VALUES ('src/a.ts', 'oldFn', 'function', 1, 5, 'function oldFn()', '/** @deprecated */')`, + ); + } finally { + closeDb(db); + } + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query_recipe", + arguments: { recipe: "deprecated-symbols", format: "badge" }, + }); + const text = (r as { content: { text: string }[] }).content[0]!.text; + expect(text).toBe("codemap: 1 issue"); + } finally { + await server.close(); + } + }); + + it("returns codemap-badge/v1 with format=badge badge_style=json", async () => { + const db = openDb(); + try { + db.run( + `INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, doc_comment) + VALUES ('src/a.ts', 'oldFn', 'function', 1, 5, 'function oldFn()', '/** @deprecated */')`, + ); + } finally { + closeDb(db); + } + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query_recipe", + arguments: { + recipe: "deprecated-symbols", + format: "badge", + badge_style: "json", + }, + }); + const doc = readJson(r); + expect(doc).toMatchObject({ + schema: "codemap-badge/v1", + count: 1, + status: "fail", + }); + } finally { + await server.close(); + } + }); + + it("rejects format=codeclimate combined with summary", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query_recipe", + arguments: { + recipe: "deprecated-symbols", + format: "codeclimate", + summary: true, + }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + expect(readJson(r)).toMatchObject({ + error: expect.stringContaining("summary"), + }); + } finally { + await server.close(); + } + }); + + it("rejects badge_style without format=badge", async () => { + const { client, server } = await makeClient(); + try { + const r = await client.callTool({ + name: "query", + arguments: { + sql: "SELECT 1", + badge_style: "json", + }, + }); + expect((r as { isError?: boolean }).isError).toBe(true); + expect(readJson(r)).toMatchObject({ + error: expect.stringContaining("badge_style"), + }); + } finally { + await server.close(); + } + }); + it("rejects format=sarif combined with summary", async () => { const { client, server } = await makeClient(); try { diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index e903d5b..76f1bdf 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -204,7 +204,7 @@ function registerQueryTool(server: McpServer, opts: ServerOpts): void { "query", withToolAnnotations("query", { description: - 'Run one read-only SQL statement against the codemap index (default `.codemap/index.db`). Returns the JSON envelope `codemap query --json` would print: row array by default, {count} under `summary`, {group_by, groups} under `group_by`, baseline diff under `baseline` (incompatible with non-json `format` / `group_by`). Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` to receive a formatted payload (incompatible with `summary` / `group_by` / `baseline`). Mermaid requires `{from, to, label?, kind?}` rows; diff requires `{file_path, line_start, before_pattern, after_pattern}` rows.', + 'Run one read-only SQL statement against the codemap index (default `.codemap/index.db`). Returns the JSON envelope `codemap query --json` would print: row array by default, {count} under `summary`, {group_by, groups} under `group_by`, baseline diff under `baseline` (incompatible with non-json `format` / `group_by`). Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` / `"codeclimate"` / `"badge"` to receive a formatted payload (incompatible with `summary` / `group_by` / `baseline`). With `format: "badge"`, optional `badge_style: "markdown"` (default) or `"json"` (`codemap-badge/v1`). Mermaid requires `{from, to, label?, kind?}` rows; diff requires `{file_path, line_start, before_pattern, after_pattern}` rows; codeclimate/badge require locatable rows (badge counts them; codeclimate emits one issue per locatable row).', inputSchema: queryArgsSchema, }), (args) => wrapToolResult(handleQuery(args, opts.root)), @@ -216,7 +216,7 @@ function registerQueryRecipeTool(server: McpServer, opts: ServerOpts): void { "query_recipe", withToolAnnotations("query_recipe", { description: - 'Run a recipe by id (bundled or project-local). Output rows carry per-row `actions` hints (recipe-only — `query` never adds them). Parametrised recipes accept `params: {key: value}` validated against recipe frontmatter. Compose with `summary` / `changed_since` / `group_by` / `baseline` exactly like `query` (`baseline` adds `actions` on `added` rows only). Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` to receive a formatted payload (incompatible with `summary` / `group_by` / `baseline`); SARIF rule id derives from the recipe id (`codemap.`). List available recipes via the `codemap://recipes` resource.', + 'Run a recipe by id (bundled or project-local). Output rows carry per-row `actions` hints (recipe-only — `query` never adds them). Parametrised recipes accept `params: {key: value}` validated against recipe frontmatter. Compose with `summary` / `changed_since` / `group_by` / `baseline` exactly like `query` (`baseline` adds `actions` on `added` rows only). Pass `format: "sarif"` / `"annotations"` / `"mermaid"` / `"diff"` / `"diff-json"` / `"codeclimate"` / `"badge"` to receive a formatted payload (incompatible with `summary` / `group_by` / `baseline`); SARIF rule id derives from the recipe id (`codemap.`). With `format: "badge"`, optional `badge_style: "markdown"` (default) or `"json"` (`codemap-badge/v1`). codeclimate/badge require locatable rows (badge counts them; codeclimate emits one issue per locatable row). List available recipes via the `codemap://recipes` resource.', inputSchema: queryRecipeArgsSchema, }), (args) => wrapToolResult(handleQueryRecipe(args, opts.root)), diff --git a/src/application/output-formatters.test.ts b/src/application/output-formatters.test.ts index ba241ee..41ea760 100644 --- a/src/application/output-formatters.test.ts +++ b/src/application/output-formatters.test.ts @@ -10,11 +10,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + buildBadgeSummary, + buildCodeClimateFingerprint, buildMessageText, detectLocationColumn, escapeAnnotationData, escapeAnnotationProperty, formatAuditSarif, + formatBadge, + countLocatableFindings, + formatBadgeJson, + formatCodeClimate, formatDiff, formatDiffJson, formatAnnotations, @@ -22,6 +28,7 @@ import { formatSarif, hasLocatableRows, MERMAID_MAX_EDGES, + noLocatableFindingsWarning, } from "./output-formatters"; let workDir: string; @@ -67,6 +74,32 @@ describe("detectLocationColumn", () => { }); }); +describe("noLocatableFindingsWarning", () => { + it("returns undefined for locatable rows", () => { + expect( + noLocatableFindingsWarning("badge", [{ file_path: "a.ts" }]), + ).toBeUndefined(); + }); + + it("returns message for aggregate-only rows", () => { + expect(noLocatableFindingsWarning("codeclimate", [{ count: 5 }])).toContain( + "codeclimate", + ); + }); + + it("skips mermaid", () => { + expect( + noLocatableFindingsWarning("mermaid", [{ count: 5 }]), + ).toBeUndefined(); + }); + + it("suppresses warning when ci is true", () => { + expect( + noLocatableFindingsWarning("codeclimate", [{ count: 5 }], { ci: true }), + ).toBeUndefined(); + }); +}); + describe("hasLocatableRows", () => { it("false on empty rows", () => { expect(hasLocatableRows([])).toBe(false); @@ -240,6 +273,234 @@ 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", + "rule_name=ui-cant-touch-server", + ), + ); + }); + + 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("defaults location.lines.begin to 1 when line_start is absent", () => { + const out = formatCodeClimate({ + rows: [{ file_path: "a.ts", fan_in: 17 }], + recipeId: "fan-in", + }); + const issues = JSON.parse(out); + expect(issues[0].location).toEqual({ + path: "a.ts", + lines: { begin: 1 }, + }); + expect(issues[0].fingerprint).toBe( + buildCodeClimateFingerprint( + "fan-in", + "a.ts", + undefined, + "fan-in", + issues[0].description, + ), + ); + }); + + it("fingerprints differ when only line_start changes", () => { + const base = { file_path: "a.ts", name: "foo" }; + const a = JSON.parse( + formatCodeClimate({ + rows: [{ ...base, line_start: 1 }], + recipeId: "r", + }), + )[0].fingerprint; + const b = JSON.parse( + formatCodeClimate({ + rows: [{ ...base, line_start: 2 }], + recipeId: "r", + }), + )[0].fingerprint; + expect(a).not.toBe(b); + }); + + it("treats line_start 0 as absent (begin and fingerprint use 1)", () => { + const out = formatCodeClimate({ + rows: [{ file_path: "a.ts", line_start: 0, name: "foo" }], + recipeId: "r", + }); + const issues = JSON.parse(out); + expect(issues[0].location.lines.begin).toBe(1); + }); + + it("emits begin for boundary-style rows without line_start", () => { + const out = formatCodeClimate({ + rows: [ + { + file_path: "src/ui/App.tsx", + to_path: "src/server/db.ts", + rule_name: "ui-cant-touch-server", + }, + ], + recipeId: "boundary-violations", + }); + const issues = JSON.parse(out); + expect(issues[0].location.lines.begin).toBe(1); + }); + + it("buildCodeClimateFingerprint uses adhoc when recipeId omitted", () => { + expect( + buildCodeClimateFingerprint(undefined, "a.ts", 1, "adhoc", "msg"), + ).toHaveLength(16); + expect( + buildCodeClimateFingerprint(undefined, "a.ts", 1, "adhoc", "msg"), + ).toBe(buildCodeClimateFingerprint(undefined, "a.ts", 1, "adhoc", "msg")); + }); + + it("fingerprints differ for same-file boundary rows without line_start", () => { + const rowA = { + file_path: "src/ui/App.tsx", + to_path: "src/server/a.ts", + rule_name: "rule-a", + }; + const rowB = { + file_path: "src/ui/App.tsx", + to_path: "src/server/b.ts", + rule_name: "rule-b", + }; + const fpA = JSON.parse( + formatCodeClimate({ rows: [rowA], recipeId: "boundary-violations" }), + )[0].fingerprint; + const fpB = JSON.parse( + formatCodeClimate({ rows: [rowB], recipeId: "boundary-violations" }), + )[0].fingerprint; + expect(fpA).not.toBe(fpB); + }); + + 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("json clean when no locatable rows", () => { + const doc = JSON.parse( + formatBadgeJson({ rows: [], recipeId: "index-summary" }), + ); + expect(doc).toMatchObject({ + schema: "codemap-badge/v1", + count: 0, + status: "pass", + message: "clean", + }); + }); + + it("countLocatableFindings matches locatable row semantics", () => { + const rows = [{ kind: "TODO" }, { file_path: "a.ts" }]; + expect(countLocatableFindings(rows)).toBe(1); + expect(hasLocatableRows(rows)).toBe(true); + }); + + 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 c962bb0..8683d75 100644 --- a/src/application/output-formatters.ts +++ b/src/application/output-formatters.ts @@ -10,11 +10,14 @@ * aggregates (`index-summary`, `markers-by-kind`), not findings. See * [`docs/architecture.md` § Output formatters](../../docs/architecture.md#cli-usage). * - * Both formatters are pure: take rows + recipe metadata, return a string. + * Formatters are pure: take rows + recipe metadata, return a string. * No I/O, no DB access. Same engine wired into both the CLI (`cmd-query.ts`) - * and the MCP `query` / `query_recipe` tools. + * and the MCP `query` / `query_recipe` tools. Also ships + * {@link formatCodeClimate} (GitLab Code Quality JSON) and + * {@link formatBadge} / {@link formatBadgeJson} (issue-count summary). */ +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -63,6 +66,21 @@ export function hasLocatableRows(rows: Record[]): boolean { return rows.some((r) => detectLocationColumn(r) !== null); } +/** + * Warning text when a formatted output skips aggregate rows (plan F.5). Returns + * `undefined` when no warning applies (mermaid, empty rows, or ≥1 locatable row). + */ +export function noLocatableFindingsWarning( + format: string, + rows: Record[], + opts?: { ci?: boolean }, +): string | undefined { + if (opts?.ci === true) return undefined; + if (format === "mermaid") return undefined; + if (rows.length === 0 || hasLocatableRows(rows)) return undefined; + return `codemap: --format ${format}: recipe / SQL emitted ${rows.length} row(s) with no file_path / path / to_path / from_path column — these aren't findings, skipping. (Aggregate recipes like index-summary / markers-by-kind don't map to ${format} v1.)`; +} + /** * Build a one-line message for a result row. Strips location columns and * stringifies what's left; if `name` is present, leads with it (e.g. @@ -171,6 +189,111 @@ 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, + * emitted line, check_name, row message)` — SHA-256 truncated to 16 hex chars. + * Emitted line matches `location.lines.begin` (defaults to `1` without `line_start`). + */ +export function buildCodeClimateFingerprint( + recipeId: string | undefined, + filePath: string, + lineStart: number | undefined, + checkName: string, + rowSignature: string, +): string { + const begin = lineStart !== undefined && lineStart > 0 ? lineStart : 1; + const key = `${recipeId ?? "adhoc"}\0${filePath}\0${String(begin)}\0${checkName}\0${rowSignature}`; + 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; + // GitLab requires location.lines.begin — file-level rows (e.g. boundary-violations) + // fall back to 1 when the recipe has no line_start column. + const begin = lineStart ?? 1; + const location: { path: string; lines: { begin: number } } = { + path, + lines: { begin }, + }; + const description = buildMessageText(row); + return [ + { + description, + check_name: checkName, + fingerprint: buildCodeClimateFingerprint( + opts.recipeId, + path, + lineStart, + checkName, + description, + ), + 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 dca36a0..d14e986 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { execSync } from "node:child_process"; import { mkdirSync, @@ -95,6 +95,187 @@ 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 badge_style=json when format is omitted", () => { + const result = handleQuery( + { sql: "SELECT 1", badge_style: "json" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining( + "badge_style is only valid with format=badge", + ), + }); + }); + + it("rejects format=codeclimate + summary", () => { + const result = handleQuery( + { + sql: "SELECT file_path FROM symbols", + format: "codeclimate", + summary: true, + }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("cannot be combined with summary"), + }); + }); + + it("rejects format=badge + group_by", () => { + const result = handleQuery( + { + sql: "SELECT file_path FROM symbols", + format: "badge", + group_by: "directory", + }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("cannot be combined with group_by"), + }); + }); + + it("rejects baseline + format=codeclimate", () => { + const result = handleQuery( + { sql: "SELECT 1", baseline: "pre", format: "codeclimate" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining( + "cannot be combined with format=codeclimate", + ), + }); + }); + + it("rejects baseline + format=badge", () => { + const result = handleQuery( + { sql: "SELECT 1", baseline: "pre", format: "badge" }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("cannot be combined with format=badge"), + }); + }); + + it("rejects format=codeclimate + group_by", () => { + const result = handleQuery( + { + sql: "SELECT file_path FROM symbols", + format: "codeclimate", + group_by: "directory", + }, + projectRoot, + ); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("cannot be combined with group_by"), + }); + }); + + it("emits stderr warning for aggregate rows with format=codeclimate", () => { + const stderr = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = handleQuery( + { + sql: "SELECT COUNT(*) AS count FROM symbols", + format: "codeclimate", + }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok || result.format !== "codeclimate") return; + expect(JSON.parse(result.payload)).toEqual([]); + expect(stderr.mock.calls[0]?.[0]).toContain("codeclimate"); + } finally { + stderr.mockRestore(); + } + }); + + it("handleQueryRecipe format=codeclimate uses recipe check_name", () => { + const result = handleQueryRecipe( + { recipe: "fan-in", format: "codeclimate" }, + projectRoot, + ); + expect(result.ok).toBe(true); + if (!result.ok || result.format !== "codeclimate") return; + const issues = JSON.parse(result.payload) as Array<{ check_name: string }>; + if (issues.length > 0) { + expect(issues[0]?.check_name).toBe("fan-in"); + } + }); + 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 d87a25a..79de770 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -53,12 +53,17 @@ 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, formatSarif, + noLocatableFindingsWarning, } from "./output-formatters"; import { baselineQueryIncompatibility, @@ -106,6 +111,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 +190,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 +214,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,12 +223,24 @@ 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; } export function handleQuery(args: QueryArgs, root: string): ToolResult { try { + const badgeIncompat = badgeStyleIncompatibility(args.format, args); + if (badgeIncompat !== undefined) return err(badgeIncompat); + const baselineIncompat = baselineQueryIncompatibility(args); if (baselineIncompat !== undefined) return err(baselineIncompat); @@ -235,13 +259,7 @@ 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 incompat = formatToolIncompatibility(args.format, args); if (incompat !== undefined) return err(incompat); return runFormattedQuery({ @@ -250,6 +268,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 +294,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 +306,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; } @@ -296,6 +325,9 @@ export function handleQueryRecipe( root: string, ): ToolResult { try { + const badgeIncompat = badgeStyleIncompatibility(args.format, args); + if (badgeIncompat !== undefined) return err(badgeIncompat); + const baselineIncompat = baselineQueryIncompatibility(args); if (baselineIncompat !== undefined) return err(baselineIncompat); @@ -334,13 +366,7 @@ 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 incompat = formatToolIncompatibility(args.format, args); if (incompat !== undefined) return err(incompat); const result = runFormattedQuery({ @@ -350,6 +376,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 +1279,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"] | undefined, + 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 +1316,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({ @@ -1284,6 +1339,8 @@ function runFormattedQuery(args: { return err("codemap: internal — formatted output requires flat row list."); } const rows = payload as Record[]; + const locWarning = noLocatableFindingsWarning(args.format, rows); + if (locWarning !== undefined) console.error(locWarning); if (args.format === "sarif") { const catalog = args.recipeId !== undefined @@ -1313,6 +1370,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 11d3361..1d2890d 100644 --- a/src/cli/aliases.ts +++ b/src/cli/aliases.ts @@ -30,8 +30,8 @@ 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, ---ci, --summary, --changed-since , --group-by owner|directory|package, +\`codemap query\` passes through (--json, --format sarif|annotations|mermaid|diff|diff-json|codeclimate|badge, +--badge-style markdown|json, --ci, --summary, --changed-since , --group-by owner|directory|package, --params key=value, --save-baseline[=name], --baseline[=name]). Run \`codemap query --help\` for the full flag reference, or diff --git a/src/cli/cmd-query.test.ts b/src/cli/cmd-query.test.ts index db1a52b..fa2f079 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,155 @@ describe("parseQueryRest — --format flag", () => { ]); expect(r.kind).toBe("run"); }); + + it("rejects --format badge + --summary", () => { + const r = parseQueryRest([ + "query", + "--format", + "badge", + "--summary", + "-r", + "boundary-violations", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("badge"); + expect(r.message).toContain("--summary"); + } + }); + + it("rejects --format badge + --group-by", () => { + const r = parseQueryRest([ + "query", + "--format", + "badge", + "--group-by", + "directory", + "-r", + "fan-in", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("badge"); + expect(r.message).toContain("--group-by"); + } + }); + + 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("rejects --format codeclimate + --group-by", () => { + const r = parseQueryRest([ + "query", + "--format", + "codeclimate", + "--group-by", + "directory", + "-r", + "fan-in", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("codeclimate"); + expect(r.message).toContain("--group-by"); + } + }); + + it("rejects --format codeclimate + --baseline=", () => { + const r = parseQueryRest([ + "query", + "--format", + "codeclimate", + "--baseline=base", + "SELECT 1", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") { + expect(r.message).toContain("codeclimate"); + expect(r.message).toContain("--baseline"); + } + }); + + it("rejects --format badge + --baseline on a recipe", () => { + const r = parseQueryRest([ + "query", + "--format", + "badge", + "--baseline", + "-r", + "boundary-violations", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("badge"); + }); + }); + + 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 unknown --badge-style", () => { + const r = parseQueryRest([ + "query", + "--format", + "badge", + "--badge-style", + "xml", + "-r", + "boundary-violations", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("xml"); + }); + + it("accepts --badge-style=json equals form", () => { + const r = parseQueryRest([ + "query", + "--format=badge", + "--badge-style=json", + "-r", + "boundary-violations", + ]); + if (r.kind !== "run") throw new Error("expected run"); + 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 28b8ce4..4e0c851 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -5,13 +5,17 @@ import { printQueryResult, queryRows, } from "../application/index-engine"; +import type { BadgeStyle } from "../application/output-formatters"; import { formatAnnotations, + formatBadge, + formatBadgeJson, + formatCodeClimate, formatDiff, formatDiffJson, formatMermaid, formatSarif, - hasLocatableRows, + noLocatableFindingsWarning, } from "../application/output-formatters"; import { compareQueryBaseline } from "../application/query-baseline"; import { attachActions } from "../application/query-engine"; @@ -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, @@ -590,9 +638,9 @@ function resolveFormat( } /** - * Reject combinations of `--format sarif|annotations` with flags that change - * the output shape away from "flat row list" (group-by buckets, summary - * counts, baseline diffs). Returns an error message or `undefined`. + * Reject combinations of non-`text`/`json` `--format` values with flags that + * change the output shape away from "flat row list" (group-by buckets, + * summary counts, baseline diffs). Returns an error message or `undefined`. * * Trade-off: keeps SARIF / annotations on the cleanest row → finding mapping * for v1; aggregate/diff shapes can be re-introduced if a real consumer asks. @@ -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,13 @@ 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 from locatable rows only + (codemap: N issues / codemap: clean — same contract as SARIF). Formatted outputs require a flat row list — incompatible with --summary, --group-by, --save-baseline, --baseline (parser rejects at parse time). + --badge-style