Skip to content

Commit d02069b

Browse files
committed
fix(ci-formats): address PR review — docs, tests, warning parity.
Hoist noLocatableFindingsWarning for CLI/MCP/HTTP; extend consumer surfaces (README, agent-content, MCP descriptions); add HTTP/MCP/formatter tests; reject badge_style without format=badge on all transports.
1 parent 1736e04 commit d02069b

13 files changed

Lines changed: 320 additions & 36 deletions

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ codemap audit --base v1.0.0 --files-baseline pre-release-files # mix --base wit
131131
# non-git projects get a clean `codemap audit: --base requires a git repository.` error.
132132
# Recipes that define per-row action templates append "actions" hints (kebab-case verb +
133133
# description) in --json output; ad-hoc SQL never carries actions. Inspect via --recipes-json.
134-
# --format <text|json|sarif|annotations|mermaid|diff|diff-json> — pipe results into GitHub Code Scanning
134+
# --format <text|json|sarif|annotations|mermaid|diff|diff-json|codeclimate|badge> — pipe results into GitHub Code Scanning
135135
# (SARIF 2.1.0), surface findings inline on PRs (GH Actions ::notice file=…,line=…::msg), or
136136
# render edge-shaped recipes as Mermaid `flowchart LR`, or preview edits as unified diffs. All
137137
# formatted outputs require a flat row list
@@ -142,6 +142,11 @@ codemap audit --base v1.0.0 --files-baseline pre-release-files # mix --base wit
142142
codemap query --recipe deprecated-symbols --format sarif > findings.sarif
143143
codemap query --recipe deprecated-symbols --ci # CI shortcut: --format sarif + non-zero exit + quiet
144144
codemap query --recipe deprecated-symbols --format annotations # one ::notice per row
145+
# GitLab Code Quality artifact (locatable rows only; flat minor severity):
146+
codemap query --recipe boundary-violations --format codeclimate > gl-code-quality-report.json
147+
# Badge summary for README paste or CI (counts locatable rows only):
148+
codemap query --recipe boundary-violations --format badge
149+
codemap query --recipe boundary-violations --format badge --badge-style json | jq -e '.status == "pass"'
145150
# Render any audit/SARIF output as a markdown PR-summary comment (for repos without
146151
# Code Scanning / aggregate audit deltas / bot-context seeding):
147152
codemap audit --base origin/main --json | codemap pr-comment - | gh pr comment <PR> -F -

docs/plans/ci-output-formats.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Moat A: formatters only — no new analysis.
6262

6363
1. `formatCodeClimate(opts: FormatOpts): string` in `output-formatters.ts`.
6464
2. `buildBadgeSummary(opts)` + `formatBadge` (markdown default) + `formatBadgeJson` (`codemap-badge/v1`; see F.8). CLI/MCP flag `--badge-style markdown|json` (default `markdown`).
65-
3. Wire `--format codeclimate|badge` in `cmd-query.ts` + `query-engine.ts` validation list.
65+
3. Wire `--format codeclimate|badge` in `cmd-query.ts` + `tool-handlers.ts` (MCP/HTTP dispatch).
6666
4. MCP/HTTP `format` enum extension + `badge_style` on query tools when `format=badge`.
6767
5. Snapshot tests in `output-formatters.test.ts` — Code Climate golden JSON; badge markdown + `codemap-badge/v1` JSON goldens.
6868
6. Docs — `architecture.md` output formatters §; README GitLab CI artifact example; agent note: badge is presentation — use JSON rows for triage.
@@ -83,11 +83,11 @@ bun src/index.ts query --recipe boundary-violations --format badge --badge-style
8383

8484
## Acceptance
8585

86-
- [ ] `codemap query --recipe boundary-violations --format codeclimate` emits valid GitLab-ingestible JSON
87-
- [ ] Fingerprints stable across two runs with identical rows
88-
- [ ] `badge` markdown: `codemap: N issues` / `codemap: clean` for N>0 and N=0
89-
- [ ] `badge --badge-style json` emits stable `codemap-badge/v1` with matching `count` / `status`
90-
- [ ] Incompatible with `summary` / `group_by` / `baseline` (same rules as SARIF)
86+
- [x] `codemap query --recipe boundary-violations --format codeclimate` emits valid GitLab-ingestible JSON
87+
- [x] Fingerprints stable across two runs with identical rows
88+
- [x] `badge` markdown: `codemap: N issues` / `codemap: clean` for N>0 and N=0
89+
- [x] `badge --badge-style json` emits stable `codemap-badge/v1` with matching `count` / `status`
90+
- [x] Incompatible with `summary` / `group_by` / `baseline` (same rules as SARIF)
9191

9292
---
9393

src/application/http-server.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,56 @@ describe("http-server — POST /tool/query", () => {
289289
expect(doc.version).toBe("2.1.0");
290290
});
291291

292+
it("format=codeclimate returns application/json", async () => {
293+
serverHandle = await startServer();
294+
const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, {
295+
method: "POST",
296+
headers: { "Content-Type": "application/json" },
297+
body: JSON.stringify({
298+
sql: "SELECT name, file_path, line_start FROM symbols WHERE name = 'bar'",
299+
format: "codeclimate",
300+
}),
301+
});
302+
expect(r.status).toBe(200);
303+
expect(r.headers.get("content-type")).toContain("application/json");
304+
const issues = (await r.json()) as unknown[];
305+
expect(Array.isArray(issues)).toBe(true);
306+
expect(issues.length).toBeGreaterThan(0);
307+
});
308+
309+
it("format=badge markdown returns text/plain", async () => {
310+
serverHandle = await startServer();
311+
const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, {
312+
method: "POST",
313+
headers: { "Content-Type": "application/json" },
314+
body: JSON.stringify({
315+
sql: "SELECT name, file_path, line_start FROM symbols WHERE name = 'bar'",
316+
format: "badge",
317+
}),
318+
});
319+
expect(r.status).toBe(200);
320+
expect(r.headers.get("content-type")).toContain("text/plain");
321+
expect(await r.text()).toBe("codemap: 1 issue");
322+
});
323+
324+
it("format=badge badge_style=json returns application/json", async () => {
325+
serverHandle = await startServer();
326+
const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, {
327+
method: "POST",
328+
headers: { "Content-Type": "application/json" },
329+
body: JSON.stringify({
330+
sql: "SELECT name, file_path, line_start FROM symbols WHERE name = 'bar'",
331+
format: "badge",
332+
badge_style: "json",
333+
}),
334+
});
335+
expect(r.status).toBe(200);
336+
expect(r.headers.get("content-type")).toContain("application/json");
337+
const doc = (await r.json()) as { schema: string; count: number };
338+
expect(doc.schema).toBe("codemap-badge/v1");
339+
expect(doc.count).toBe(1);
340+
});
341+
292342
it("format=annotations returns text/plain", async () => {
293343
serverHandle = await startServer();
294344
const r = await fetch(`http://127.0.0.1:${serverHandle.port}/tool/query`, {

src/application/mcp-server.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,45 @@ describe("MCP server — query_recipe tool", () => {
517517
}
518518
});
519519

520+
it("rejects format=codeclimate combined with summary", async () => {
521+
const { client, server } = await makeClient();
522+
try {
523+
const r = await client.callTool({
524+
name: "query_recipe",
525+
arguments: {
526+
recipe: "deprecated-symbols",
527+
format: "codeclimate",
528+
summary: true,
529+
},
530+
});
531+
expect((r as { isError?: boolean }).isError).toBe(true);
532+
expect(readJson(r)).toMatchObject({
533+
error: expect.stringContaining("summary"),
534+
});
535+
} finally {
536+
await server.close();
537+
}
538+
});
539+
540+
it("rejects badge_style without format=badge", async () => {
541+
const { client, server } = await makeClient();
542+
try {
543+
const r = await client.callTool({
544+
name: "query",
545+
arguments: {
546+
sql: "SELECT 1",
547+
badge_style: "json",
548+
},
549+
});
550+
expect((r as { isError?: boolean }).isError).toBe(true);
551+
expect(readJson(r)).toMatchObject({
552+
error: expect.stringContaining("badge_style"),
553+
});
554+
} finally {
555+
await server.close();
556+
}
557+
});
558+
520559
it("rejects format=sarif combined with summary", async () => {
521560
const { client, server } = await makeClient();
522561
try {

src/application/mcp-server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ function registerQueryTool(server: McpServer, opts: ServerOpts): void {
204204
"query",
205205
withToolAnnotations("query", {
206206
description:
207-
'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.',
207+
'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 count locatable rows only.',
208208
inputSchema: queryArgsSchema,
209209
}),
210210
(args) => wrapToolResult(handleQuery(args, opts.root)),
@@ -216,7 +216,7 @@ function registerQueryRecipeTool(server: McpServer, opts: ServerOpts): void {
216216
"query_recipe",
217217
withToolAnnotations("query_recipe", {
218218
description:
219-
'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.<recipe>`). List available recipes via the `codemap://recipes` resource.',
219+
'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.<recipe>`). With `format: "badge"`, optional `badge_style: "markdown"` or `"json"`. List available recipes via the `codemap://recipes` resource.',
220220
inputSchema: queryRecipeArgsSchema,
221221
}),
222222
(args) => wrapToolResult(handleQueryRecipe(args, opts.root)),

src/application/output-formatters.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
escapeAnnotationProperty,
1919
formatAuditSarif,
2020
formatBadge,
21+
countLocatableFindings,
2122
formatBadgeJson,
2223
formatCodeClimate,
2324
formatDiff,
@@ -27,6 +28,7 @@ import {
2728
formatSarif,
2829
hasLocatableRows,
2930
MERMAID_MAX_EDGES,
31+
noLocatableFindingsWarning,
3032
} from "./output-formatters";
3133

3234
let workDir: string;
@@ -72,6 +74,26 @@ describe("detectLocationColumn", () => {
7274
});
7375
});
7476

77+
describe("noLocatableFindingsWarning", () => {
78+
it("returns undefined for locatable rows", () => {
79+
expect(
80+
noLocatableFindingsWarning("badge", [{ file_path: "a.ts" }]),
81+
).toBeUndefined();
82+
});
83+
84+
it("returns message for aggregate-only rows", () => {
85+
expect(noLocatableFindingsWarning("codeclimate", [{ count: 5 }])).toContain(
86+
"codeclimate",
87+
);
88+
});
89+
90+
it("skips mermaid", () => {
91+
expect(
92+
noLocatableFindingsWarning("mermaid", [{ count: 5 }]),
93+
).toBeUndefined();
94+
});
95+
});
96+
7597
describe("hasLocatableRows", () => {
7698
it("false on empty rows", () => {
7799
expect(hasLocatableRows([])).toBe(false);
@@ -295,6 +317,24 @@ describe("formatCodeClimate", () => {
295317
expect(JSON.parse(out)).toHaveLength(1);
296318
});
297319

320+
it("omits location.lines when line_start is absent", () => {
321+
const out = formatCodeClimate({
322+
rows: [{ file_path: "a.ts", fan_in: 17 }],
323+
recipeId: "fan-in",
324+
});
325+
const issues = JSON.parse(out);
326+
expect(issues[0].location).toEqual({ path: "a.ts" });
327+
});
328+
329+
it("buildCodeClimateFingerprint uses adhoc when recipeId omitted", () => {
330+
expect(
331+
buildCodeClimateFingerprint(undefined, "a.ts", 1, "adhoc"),
332+
).toHaveLength(16);
333+
expect(buildCodeClimateFingerprint(undefined, "a.ts", 1, "adhoc")).toBe(
334+
buildCodeClimateFingerprint(undefined, "a.ts", 1, "adhoc"),
335+
);
336+
});
337+
298338
it("fingerprints are stable across identical inputs", () => {
299339
const row = { file_path: "a.ts", line_start: 1, name: "foo" };
300340
const a = JSON.parse(
@@ -352,6 +392,24 @@ describe("formatBadge", () => {
352392
});
353393
});
354394

395+
it("json clean when no locatable rows", () => {
396+
const doc = JSON.parse(
397+
formatBadgeJson({ rows: [], recipeId: "index-summary" }),
398+
);
399+
expect(doc).toMatchObject({
400+
schema: "codemap-badge/v1",
401+
count: 0,
402+
status: "pass",
403+
message: "clean",
404+
});
405+
});
406+
407+
it("countLocatableFindings matches locatable row semantics", () => {
408+
const rows = [{ kind: "TODO" }, { file_path: "a.ts" }];
409+
expect(countLocatableFindings(rows)).toBe(1);
410+
expect(hasLocatableRows(rows)).toBe(true);
411+
});
412+
355413
it("buildBadgeSummary matches markdown/json count", () => {
356414
const rows = [{ file_path: "a.ts" }, { file_path: "b.ts" }];
357415
const summary = buildBadgeSummary({ rows, recipeId: "fan-in" });

src/application/output-formatters.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
* aggregates (`index-summary`, `markers-by-kind`), not findings. See
1111
* [`docs/architecture.md` § Output formatters](../../docs/architecture.md#cli-usage).
1212
*
13-
* Both formatters are pure: take rows + recipe metadata, return a string.
13+
* Formatters are pure: take rows + recipe metadata, return a string.
1414
* No I/O, no DB access. Same engine wired into both the CLI (`cmd-query.ts`)
15-
* and the MCP `query` / `query_recipe` tools.
15+
* and the MCP `query` / `query_recipe` tools. Also ships
16+
* {@link formatCodeClimate} (GitLab Code Quality JSON) and
17+
* {@link formatBadge} / {@link formatBadgeJson} (issue-count summary).
1618
*/
1719

1820
import { createHash } from "node:crypto";
@@ -64,6 +66,21 @@ export function hasLocatableRows(rows: Record<string, unknown>[]): boolean {
6466
return rows.some((r) => detectLocationColumn(r) !== null);
6567
}
6668

69+
/**
70+
* Warning text when a formatted output skips aggregate rows (plan F.5). Returns
71+
* `undefined` when no warning applies (mermaid, empty rows, or ≥1 locatable row).
72+
*/
73+
export function noLocatableFindingsWarning(
74+
format: string,
75+
rows: Record<string, unknown>[],
76+
opts?: { ci?: boolean },
77+
): string | undefined {
78+
if (opts?.ci === true) return undefined;
79+
if (format === "mermaid") return undefined;
80+
if (rows.length === 0 || hasLocatableRows(rows)) return undefined;
81+
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.)`;
82+
}
83+
6784
/**
6885
* Build a one-line message for a result row. Strips location columns and
6986
* stringifies what's left; if `name` is present, leads with it (e.g.

src/application/tool-handlers.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,62 @@ describe("handleQuery baseline", () => {
162162
});
163163
});
164164

165+
it("rejects badge_style=json when format is omitted", () => {
166+
const result = handleQuery(
167+
{ sql: "SELECT 1", badge_style: "json" },
168+
projectRoot,
169+
);
170+
expect(result).toMatchObject({
171+
ok: false,
172+
error: expect.stringContaining(
173+
"badge_style is only valid with format=badge",
174+
),
175+
});
176+
});
177+
178+
it("rejects format=codeclimate + summary", () => {
179+
const result = handleQuery(
180+
{
181+
sql: "SELECT file_path FROM symbols",
182+
format: "codeclimate",
183+
summary: true,
184+
},
185+
projectRoot,
186+
);
187+
expect(result).toMatchObject({
188+
ok: false,
189+
error: expect.stringContaining("cannot be combined with summary"),
190+
});
191+
});
192+
193+
it("rejects format=badge + group_by", () => {
194+
const result = handleQuery(
195+
{
196+
sql: "SELECT file_path FROM symbols",
197+
format: "badge",
198+
group_by: "directory",
199+
},
200+
projectRoot,
201+
);
202+
expect(result).toMatchObject({
203+
ok: false,
204+
error: expect.stringContaining("cannot be combined with group_by"),
205+
});
206+
});
207+
208+
it("handleQueryRecipe format=codeclimate uses recipe check_name", () => {
209+
const result = handleQueryRecipe(
210+
{ recipe: "fan-in", format: "codeclimate" },
211+
projectRoot,
212+
);
213+
expect(result.ok).toBe(true);
214+
if (!result.ok || result.format !== "codeclimate") return;
215+
const issues = JSON.parse(result.payload) as Array<{ check_name: string }>;
216+
if (issues.length > 0) {
217+
expect(issues[0]?.check_name).toBe("fan-in");
218+
}
219+
});
220+
165221
it("rejects baseline + group_by", () => {
166222
const result = handleQuery(
167223
{ sql: "SELECT 1", baseline: "pre", group_by: "directory" },

0 commit comments

Comments
 (0)