From b65df6a2671c5439de3767509427724c24f976f7 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 13:19:47 +0300 Subject: [PATCH 01/10] feat(audit): --format sarif on `codemap audit` (Slice 1a of #73 plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of Slice 1 from the GitHub Marketplace Action plan. `audit` today only emits `--json`; this adds SARIF 2.1.0 output directly, no JSON→SARIF transform step needed in CI workflows. CLI: - New `--format ` flag (default `text`). - `--json` stays as backward-compat shortcut for `--format json`. - `--json` + `--format ` rejected as a contradiction with a helpful error message. - `--summary` is a no-op with `--format sarif` (SARIF results are per-row, not counts) and surfaces a stderr warning. - `runAuditCmd` signature updated: `json: boolean` → `format: AuditOutputFormat` (`"text" | "json" | "sarif"`). SARIF shape: - One rule per delta key (`codemap.audit.files-added`, `codemap.audit.dependencies-added`, `codemap.audit.deprecated-added`). - One result per `added` row (severity = `warning`; audit deltas are more actionable than per-recipe `note`). - Locations auto-detected via the existing `file_path`/`path`/`to_path`/`from_path` priority list (same as `query --format sarif`); `line_start`/`line_end` populate the SARIF `region`. - `removed` rows intentionally excluded — SARIF surfaces findings to act on, not cleanups. - Location-only rows (e.g. files-added has only `path`) get a "new files: src/foo.ts" message, not the generic "(no message)" fallback. Tests: - 4 new SARIF-formatter unit tests (rules/results shape; empty deltas; missing location column; line range). - 4 new audit-CLI parser tests (--format sarif/json/=json, unknown format value, --json + --format contradiction, --json + --format json reconcilable). - All existing audit / output-formatters tests updated for the `json: bool` → `format: AuditOutputFormat` field rename. Lockstep updates: - `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` audit-vs-git-ref row gains `--format sarif` mention. - New changeset (.changeset/audit-format-sarif.md, minor). Slice 1b (`--ci` aggregate flag on `query` + `audit`) lands in the follow-up PR. --- .agents/rules/codemap.md | 56 +++++------ .changeset/audit-format-sarif.md | 11 +++ src/application/output-formatters.test.ts | 99 +++++++++++++++++++ src/application/output-formatters.ts | 92 +++++++++++++++++ src/cli/cmd-audit.test.ts | 70 +++++++++++-- src/cli/cmd-audit.ts | 114 ++++++++++++++++++---- src/cli/main.ts | 2 +- templates/agents/rules/codemap.md | 58 +++++------ 8 files changed, 418 insertions(+), 84 deletions(-) create mode 100644 .changeset/audit-format-sarif.md diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md index 9648606..95884ef 100644 --- a/.agents/rules/codemap.md +++ b/.agents/rules/codemap.md @@ -12,34 +12,34 @@ A local database (default **`.codemap/index.db`**) indexes structure: symbols, i ## CLI (this repository) -| Context | Incremental index | Query | -| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Default** — from this clone | `bun src/index.ts` | `bun src/index.ts query --json ""` | -| Same entry | `bun run dev` | (same as first row) | -| Query (ASCII table — optional) | — | `bun src/index.ts query ""` | -| Recipe | — | `bun src/index.ts query --json --recipe fan-out` (see **`bun src/index.ts query --help`**) | -| Parametrised recipe | — | `bun src/index.ts query --json --recipe find-symbol-by-kind --params kind=function,name_pattern=%Query%` — params declared in recipe `.md` frontmatter and validated before SQL binding. | -| Boundary violations | — | `bun src/index.ts query --json --recipe boundary-violations` — joins `dependencies` × `boundary_rules` (config-driven) via SQLite `GLOB`. `.codemap/config.ts` `boundaries: [{name, from_glob, to_glob, action?}]`; default `action: "deny"`. SARIF / annotations work via the `file_path` alias. | -| Rename preview | — | `bun src/index.ts query --recipe rename-preview --params old=usePermissions,new=useAccess,kind=function --format diff` — read-only unified diff; codemap never writes files. | -| Recipe catalog / SQL | — | `bun src/index.ts query --recipes-json` · `bun src/index.ts query --print-sql fan-out` | -| Counts only | — | `bun src/index.ts query --json --summary -r deprecated-symbols` | -| PR-scoped rows | — | `bun src/index.ts query --json --changed-since origin/main -r fan-out` | -| Bucket by owner / dir / pkg | — | `bun src/index.ts query --json --group-by directory -r fan-in` | -| Save / diff a baseline | — | `bun src/index.ts query --save-baseline -r visibility-tags` then `… --json --baseline -r visibility-tags` | -| List / drop baselines | — | `bun src/index.ts query --baselines` · `bun src/index.ts query --drop-baseline ` | -| Per-delta audit | — | `bun src/index.ts audit --json --baseline base` (auto-resolves `base-files` / `base-dependencies` / `base-deprecated`) | -| Audit vs git ref | — | `bun src/index.ts audit --base origin/main --json` — worktree+reindex against any committish; sub-100ms second run via sha-keyed cache. Mutually exclusive with `--baseline`; per-delta overrides compose. | -| MCP server (for agent hosts) | — | `bun src/index.ts mcp [--no-watch] [--debounce ]` — JSON-RPC on stdio; one tool per CLI verb. Watcher default-ON since 2026-05. See **MCP** section below. | -| HTTP server (for non-MCP) | — | `bun src/index.ts serve [--host 127.0.0.1] [--port 7878] [--token ] [--no-watch] [--debounce ]` — same tool taxonomy over POST /tool/{name}. Watcher default-ON since 2026-05. | -| Watch mode (live reindex) | — | `bun src/index.ts watch [--debounce 250] [--quiet]` — standalone long-running process; debounced reindex on file changes. `mcp` / `serve` boot the watcher in-process by default — pass `--no-watch` (or `CODEMAP_WATCH=0`) to opt out. | -| Targeted read (metadata) | — | `bun src/index.ts show [--kind ] [--in ] [--json]` — file:line + signature | -| Targeted read (source text) | — | `bun src/index.ts snippet [--kind ] [--in ] [--json]` — same lookup + source from disk + stale flag | -| Impact (blast-radius walker) | — | `bun src/index.ts impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | -| Coverage ingest | — | `bun src/index.ts ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | -| SARIF / GH annotations | — | `bun src/index.ts query --recipe deprecated-symbols --format sarif` · `… --format annotations` | -| Mermaid graph (≤50 edges) | — | `bun src/index.ts query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | -| Diff preview | — | `bun src/index.ts query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | -| FTS5 full-text (opt-in) | `--with-fts` | `bun src/index.ts --with-fts --full` enables `source_fts` virtual table; `query --recipe text-in-deprecated-functions` demos JOINs. | +| Context | Incremental index | Query | +| ------------------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Default** — from this clone | `bun src/index.ts` | `bun src/index.ts query --json ""` | +| Same entry | `bun run dev` | (same as first row) | +| Query (ASCII table — optional) | — | `bun src/index.ts query ""` | +| Recipe | — | `bun src/index.ts query --json --recipe fan-out` (see **`bun src/index.ts query --help`**) | +| Parametrised recipe | — | `bun src/index.ts query --json --recipe find-symbol-by-kind --params kind=function,name_pattern=%Query%` — params declared in recipe `.md` frontmatter and validated before SQL binding. | +| Boundary violations | — | `bun src/index.ts query --json --recipe boundary-violations` — joins `dependencies` × `boundary_rules` (config-driven) via SQLite `GLOB`. `.codemap/config.ts` `boundaries: [{name, from_glob, to_glob, action?}]`; default `action: "deny"`. SARIF / annotations work via the `file_path` alias. | +| Rename preview | — | `bun src/index.ts query --recipe rename-preview --params old=usePermissions,new=useAccess,kind=function --format diff` — read-only unified diff; codemap never writes files. | +| Recipe catalog / SQL | — | `bun src/index.ts query --recipes-json` · `bun src/index.ts query --print-sql fan-out` | +| Counts only | — | `bun src/index.ts query --json --summary -r deprecated-symbols` | +| PR-scoped rows | — | `bun src/index.ts query --json --changed-since origin/main -r fan-out` | +| Bucket by owner / dir / pkg | — | `bun src/index.ts query --json --group-by directory -r fan-in` | +| Save / diff a baseline | — | `bun src/index.ts query --save-baseline -r visibility-tags` then `… --json --baseline -r visibility-tags` | +| List / drop baselines | — | `bun src/index.ts query --baselines` · `bun src/index.ts query --drop-baseline ` | +| Per-delta audit | — | `bun src/index.ts audit --json --baseline base` (auto-resolves `base-files` / `base-dependencies` / `base-deprecated`) | +| Audit vs git ref | — | `bun src/index.ts audit --base origin/main --json` — worktree+reindex against any committish; sub-100ms second run via sha-keyed cache. Mutually exclusive with `--baseline`; per-delta overrides compose. Add `--format sarif` to emit SARIF 2.1.0 directly (one rule per delta key; severity `warning`). | +| MCP server (for agent hosts) | — | `bun src/index.ts mcp [--no-watch] [--debounce ]` — JSON-RPC on stdio; one tool per CLI verb. Watcher default-ON since 2026-05. See **MCP** section below. | +| HTTP server (for non-MCP) | — | `bun src/index.ts serve [--host 127.0.0.1] [--port 7878] [--token ] [--no-watch] [--debounce ]` — same tool taxonomy over POST /tool/{name}. Watcher default-ON since 2026-05. | +| Watch mode (live reindex) | — | `bun src/index.ts watch [--debounce 250] [--quiet]` — standalone long-running process; debounced reindex on file changes. `mcp` / `serve` boot the watcher in-process by default — pass `--no-watch` (or `CODEMAP_WATCH=0`) to opt out. | +| Targeted read (metadata) | — | `bun src/index.ts show [--kind ] [--in ] [--json]` — file:line + signature | +| Targeted read (source text) | — | `bun src/index.ts snippet [--kind ] [--in ] [--json]` — same lookup + source from disk + stale flag | +| Impact (blast-radius walker) | — | `bun src/index.ts impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | +| Coverage ingest | — | `bun src/index.ts ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | +| SARIF / GH annotations | — | `bun src/index.ts query --recipe deprecated-symbols --format sarif` · `… --format annotations` | +| Mermaid graph (≤50 edges) | — | `bun src/index.ts query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | +| Diff preview | — | `bun src/index.ts query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | +| FTS5 full-text (opt-in) | `--with-fts` | `bun src/index.ts --with-fts --full` enables `source_fts` virtual table; `query --recipe text-in-deprecated-functions` demos JOINs. | **Recipe metadata:** with **`--json`**, recipes that define an `actions` template append it to every row (kebab-case verb + description — e.g. `fan-out` → `review-coupling`). Under `--baseline`, actions attach to the **`added`** rows only. Parametrised recipes declare `params` in `.md` frontmatter; pass values with `--params key=value[,key=value]` (repeatable; last value wins). Inspect both via **`--recipes-json`**. Ad-hoc SQL never carries actions or params. diff --git a/.changeset/audit-format-sarif.md b/.changeset/audit-format-sarif.md new file mode 100644 index 0000000..dd5f63e --- /dev/null +++ b/.changeset/audit-format-sarif.md @@ -0,0 +1,11 @@ +--- +"@stainless-code/codemap": minor +--- + +`codemap audit --format ` — emit a SARIF 2.1.0 doc directly from the audit envelope, no JSON→SARIF transform step needed. One rule per delta key (`codemap.audit.files-added`, `codemap.audit.dependencies-added`, `codemap.audit.deprecated-added`); one result per `added` row; severity = `warning` (audit deltas are more actionable than per-recipe `note`). Locations auto-detected via the same `file_path` / `path` / `to_path` / `from_path` priority list that `query --format sarif` uses; line ranges (`line_start` / `line_end`) populate the SARIF `region`. Pure output-formatter addition on top of the existing audit envelope; no schema impact. + +`--json` stays as the shortcut for `--format json` (backward-compatible). `--json` + `--format ` rejected as a contradiction. `--summary` is a no-op with `--format sarif` (SARIF results are per-row, not counts) and surfaces a stderr warning. + +`removed` rows are intentionally excluded from SARIF output — SARIF surfaces findings to act on, not cleanups. Location-only rows (e.g. files-added has only `path`) get a "new files: src/foo.ts" message instead of the generic "(no message)" fallback. + +This is the first half of Slice 1 from the [GitHub Marketplace Action plan](../docs/plans/github-marketplace-action.md) — independently useful for any CI consumer running `codemap audit` who wants Code Scanning surface without a translation layer; required for the upcoming Marketplace Action's headline default command. diff --git a/src/application/output-formatters.test.ts b/src/application/output-formatters.test.ts index d5433ec..3f425bc 100644 --- a/src/application/output-formatters.test.ts +++ b/src/application/output-formatters.test.ts @@ -8,6 +8,7 @@ import { detectLocationColumn, escapeAnnotationData, escapeAnnotationProperty, + formatAuditSarif, formatDiff, formatDiffJson, formatAnnotations, @@ -583,6 +584,104 @@ describe("formatDiff / formatDiffJson", () => { }); }); +describe("formatAuditSarif", () => { + it("emits one rule per delta key + one result per added row", () => { + const sarif = JSON.parse( + formatAuditSarif([ + { key: "files", added: [{ path: "src/new.ts" }] }, + { + key: "dependencies", + added: [{ from_path: "src/a.ts", to_path: "src/b.ts" }], + }, + { + key: "deprecated", + added: [{ name: "oldFn", kind: "function", file_path: "src/x.ts" }], + }, + ]), + ); + expect(sarif.version).toBe("2.1.0"); + const run = sarif.runs[0]; + expect(run.tool.driver.name).toBe("codemap"); + const ruleIds = run.tool.driver.rules.map((r: { id: string }) => r.id); + expect(ruleIds).toEqual([ + "codemap.audit.files-added", + "codemap.audit.dependencies-added", + "codemap.audit.deprecated-added", + ]); + expect(run.results).toHaveLength(3); + // Severity = warning (audit deltas are more actionable than per-recipe `note`) + expect(run.results.every((r: { level: string }) => r.level === "warning")); + // Locations auto-detected per row + expect( + run.results[0].locations[0].physicalLocation.artifactLocation.uri, + ).toBe("src/new.ts"); + expect( + run.results[1].locations[0].physicalLocation.artifactLocation.uri, + ).toBe("src/b.ts"); // to_path wins per LOCATION_COLUMNS priority + expect( + run.results[2].locations[0].physicalLocation.artifactLocation.uri, + ).toBe("src/x.ts"); + }); + + it("emits empty results array when all deltas are empty", () => { + const sarif = JSON.parse( + formatAuditSarif([ + { key: "files", added: [] }, + { key: "dependencies", added: [] }, + ]), + ); + expect(sarif.runs[0].results).toEqual([]); + // Rules are still declared even when no findings hit them — Code Scanning + // expects rule registration to be stable across runs. + expect(sarif.runs[0].tool.driver.rules).toHaveLength(2); + }); + + it("omits locations field for rows without a location column", () => { + const sarif = JSON.parse( + formatAuditSarif([ + { key: "files", added: [{ unrelated_column: "foo", count: 5 }] }, + ]), + ); + const result = sarif.runs[0].results[0]; + expect(result.ruleId).toBe("codemap.audit.files-added"); + expect(result.locations).toBeUndefined(); + // Message still has the row data via buildMessageText + expect(result.message.text).toContain("count=5"); + }); + + it("falls back to 'new : ' message for location-only rows (e.g. files-added)", () => { + // Files-added rows have only `path` — buildMessageText returns "(no + // message)" because `path` sits in the location-skip set. Audit-SARIF + // catches this and produces a meaningful message. + const sarif = JSON.parse( + formatAuditSarif([{ key: "files", added: [{ path: "src/new.ts" }] }]), + ); + expect(sarif.runs[0].results[0].message.text).toBe("new files: src/new.ts"); + }); + + it("includes line_start / line_end region when present", () => { + const sarif = JSON.parse( + formatAuditSarif([ + { + key: "deprecated", + added: [ + { + name: "oldFn", + file_path: "src/x.ts", + line_start: 12, + line_end: 18, + }, + ], + }, + ]), + ); + const region = + sarif.runs[0].results[0].locations[0].physicalLocation.region; + expect(region.startLine).toBe(12); + expect(region.endLine).toBe(18); + }); +}); + describe("escapeAnnotationData / escapeAnnotationProperty", () => { it("data: percent-encodes %, CR, LF only", () => { expect(escapeAnnotationData("a%b\rc\nd")).toBe("a%25b%0Dc%0Ad"); diff --git a/src/application/output-formatters.ts b/src/application/output-formatters.ts index 91134e7..84c0305 100644 --- a/src/application/output-formatters.ts +++ b/src/application/output-formatters.ts @@ -167,6 +167,98 @@ export function formatSarif(opts: FormatOpts): string { return JSON.stringify(sarif, null, 2); } +/** + * One delta's added rows + the delta key (`files` / `dependencies` / + * `deprecated` in v1; arbitrary string for forward-compat). Removed rows + * are intentionally excluded — SARIF is for findings to act on, not + * cleanups. + */ +export interface AuditSarifDelta { + key: string; + added: Record[]; +} + +/** + * Format an audit envelope as a SARIF 2.1.0 document. One rule per delta + * key (id `codemap.audit.-added`); one result per `added` row across + * all deltas. Severity = `warning` (audit deltas are more actionable than + * a single recipe finding — a new dependency edge or `@deprecated` symbol + * landing in a PR is a structural change worth flagging). Locations + * auto-detected via {@link detectLocationColumn} from the existing + * priority list (`file_path` / `path` / `to_path` / `from_path`); aggregate + * rows without a location column emit a result with no `locations` field + * (SARIF spec allows this). + */ +export function formatAuditSarif(deltas: AuditSarifDelta[]): string { + const rules = deltas.map((d) => ({ + id: `codemap.audit.${d.key}-added`, + name: `audit-${d.key}-added`, + shortDescription: { text: `New ${d.key} since baseline` }, + defaultConfiguration: { level: "warning" }, + })); + + const results = deltas.flatMap((d) => + d.added.map((row) => { + const ruleId = `codemap.audit.${d.key}-added`; + const locCol = detectLocationColumn(row); + // For audit deltas, location-only rows (e.g. files: `{path: "..."}`) + // produce "(no message)" via the generic builder because every column + // sits in the location-skip set. Fall back to a message that names the + // delta + URI so SARIF consumers see something actionable. + const builtText = buildMessageText(row); + const messageText = + builtText === "(no message)" && locCol !== null + ? `new ${d.key}: ${row[locCol] as string}` + : builtText; + const result: Record = { + ruleId, + level: "warning", + message: { text: messageText }, + }; + if (locCol !== null) { + const uri = row[locCol] as string; + const lineStartRaw = row["line_start"]; + const lineEndRaw = row["line_end"]; + const region: Record = {}; + if (typeof lineStartRaw === "number" && lineStartRaw > 0) { + region["startLine"] = lineStartRaw; + } + if (typeof lineEndRaw === "number" && lineEndRaw > 0) { + region["endLine"] = lineEndRaw; + } + const physicalLocation: Record = { + artifactLocation: { uri }, + }; + if (Object.keys(region).length > 0) { + physicalLocation["region"] = region; + } + result["locations"] = [{ physicalLocation }]; + } + return result; + }), + ); + + const sarif = { + $schema: + "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/Schemata/sarif-schema-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "codemap", + informationUri: "https://github.com/stainless-code/codemap", + version: CODEMAP_VERSION, + rules, + }, + }, + results, + }, + ], + }; + return JSON.stringify(sarif, null, 2); +} + export interface AnnotationsOpts { rows: Record[]; /** Same `recipeId` shape as {@link FormatOpts}; not currently rendered (annotation lines don't carry rule id). */ diff --git a/src/cli/cmd-audit.test.ts b/src/cli/cmd-audit.test.ts index b83bad8..74f4f14 100644 --- a/src/cli/cmd-audit.test.ts +++ b/src/cli/cmd-audit.test.ts @@ -44,7 +44,7 @@ describe("parseAuditRest", () => { baselinePrefix: "base", base: undefined, perDelta: {}, - json: false, + format: "text", summary: false, noIndex: false, }); @@ -57,7 +57,7 @@ describe("parseAuditRest", () => { baselinePrefix: "base", base: undefined, perDelta: {}, - json: false, + format: "text", summary: false, noIndex: false, }); @@ -78,7 +78,7 @@ describe("parseAuditRest", () => { baselinePrefix: undefined, base: undefined, perDelta: { files: "X", dependencies: "Y", deprecated: "Z" }, - json: false, + format: "text", summary: false, noIndex: false, }); @@ -97,13 +97,13 @@ describe("parseAuditRest", () => { baselinePrefix: "base", base: undefined, perDelta: { dependencies: "experimental-deps" }, - json: false, + format: "text", summary: false, noIndex: false, }); }); - it("parses --json --summary --no-index alongside baseline flags", () => { + it("parses --json as shortcut for --format json", () => { const r = parseAuditRest([ "audit", "--json", @@ -113,12 +113,68 @@ describe("parseAuditRest", () => { "base", ]); if (r.kind !== "run") throw new Error("expected run"); - expect(r.json).toBe(true); + expect(r.format).toBe("json"); expect(r.summary).toBe(true); expect(r.noIndex).toBe(true); expect(r.baselinePrefix).toBe("base"); }); + it("parses --format sarif", () => { + const r = parseAuditRest([ + "audit", + "--format", + "sarif", + "--baseline", + "base", + ]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("sarif"); + }); + + it("parses --format=json (equals form)", () => { + const r = parseAuditRest(["audit", "--format=json", "--baseline", "base"]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("json"); + }); + + it("rejects unknown --format value", () => { + const r = parseAuditRest([ + "audit", + "--format", + "yaml", + "--baseline", + "base", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("yaml"); + }); + + it("rejects --json + --format sarif (contradiction)", () => { + const r = parseAuditRest([ + "audit", + "--json", + "--format", + "sarif", + "--baseline", + "base", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--json"); + }); + + it("accepts --json + --format json (redundant but consistent)", () => { + const r = parseAuditRest([ + "audit", + "--json", + "--format", + "json", + "--baseline", + "base", + ]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("json"); + }); + it("errors when --baseline has no value", () => { const r = parseAuditRest(["audit", "--baseline"]); expect(r.kind).toBe("error"); @@ -162,7 +218,7 @@ describe("parseAuditRest", () => { baselinePrefix: undefined, base: "origin/main", perDelta: {}, - json: false, + format: "text", summary: false, noIndex: false, }); diff --git a/src/cli/cmd-audit.ts b/src/cli/cmd-audit.ts index daa67dc..fe96658 100644 --- a/src/cli/cmd-audit.ts +++ b/src/cli/cmd-audit.ts @@ -6,11 +6,23 @@ import { V1_DELTAS, } from "../application/audit-engine"; import type { AuditEnvelope } from "../application/audit-engine"; +import { formatAuditSarif } from "../application/output-formatters"; +import type { AuditSarifDelta } from "../application/output-formatters"; import { runCodemapIndex } from "../application/run-index"; import { closeDb, openDb } from "../db"; import { getProjectRoot } from "../runtime"; import { bootstrapCodemap } from "./bootstrap-codemap"; +/** + * Output formats supported by `codemap audit`. `text` is the default human + * terminal renderer; `json` matches the legacy `--json` flag's envelope; + * `sarif` emits a SARIF 2.1.0 doc per {@link formatAuditSarif} for GitHub + * Code Scanning + any SARIF-aware viewer. `--json` and `--format json` are + * equivalent; mixing `--json` with `--format ` is a parse error. + */ +export const AUDIT_OUTPUT_FORMATS = ["text", "json", "sarif"] as const; +export type AuditOutputFormat = (typeof AUDIT_OUTPUT_FORMATS)[number]; + // Per-delta CLI flag → delta key. Generated from V1_DELTAS so adding a delta // in the engine surfaces a `---baseline` flag automatically. const PER_DELTA_FLAGS: Record = Object.fromEntries( @@ -21,7 +33,7 @@ const PER_DELTA_FLAGS: Record = Object.fromEntries( * Parse `argv` after the global bootstrap: `rest[0]` must be `"audit"`. * v1 supports `--baseline ` (auto-resolve sugar), per-delta * `---baseline ` flags (explicit), `--json`, `--summary`, - * `--no-index`. + * `--format `, `--no-index`. */ export function parseAuditRest(rest: string[]): | { kind: "help" } @@ -31,7 +43,7 @@ export function parseAuditRest(rest: string[]): baselinePrefix: string | undefined; base: string | undefined; perDelta: Record; - json: boolean; + format: AuditOutputFormat; summary: boolean; noIndex: boolean; } { @@ -40,7 +52,10 @@ export function parseAuditRest(rest: string[]): } let i = 1; - let json = false; + // `--json` and `--format json` are equivalent; track whether the user passed + // `--json` so we can reject `--json --format sarif` as a contradiction. + let jsonShortcut = false; + let format: AuditOutputFormat | undefined; let summary = false; let noIndex = false; let baselinePrefix: string | undefined; @@ -51,10 +66,23 @@ export function parseAuditRest(rest: string[]): const a = rest[i]; if (a === "--help" || a === "-h") return { kind: "help" }; if (a === "--json") { - json = true; + jsonShortcut = true; i++; continue; } + if (a === "--format" || a.startsWith("--format=")) { + const value = consumeFlagValue(rest, i, "--format"); + if (value.kind === "error") return value; + if (!(AUDIT_OUTPUT_FORMATS as readonly string[]).includes(value.value)) { + return { + kind: "error", + message: `codemap audit: --format must be one of ${AUDIT_OUTPUT_FORMATS.join(" / ")}; got "${value.value}".`, + }; + } + format = value.value as AuditOutputFormat; + i = value.next; + continue; + } if (a === "--summary") { summary = true; i++; @@ -125,12 +153,29 @@ export function parseAuditRest(rest: string[]): }; } + // Reconcile --json shortcut with --format. Both → must agree on `json`. + // Neither → default to `text`. + let resolvedFormat: AuditOutputFormat; + if (jsonShortcut && format !== undefined) { + if (format !== "json") { + return { + kind: "error", + message: `codemap audit: --json is shorthand for --format json; cannot combine with --format ${format}.`, + }; + } + resolvedFormat = "json"; + } else if (jsonShortcut) { + resolvedFormat = "json"; + } else { + resolvedFormat = format ?? "text"; + } + return { kind: "run", baselinePrefix, base, perDelta, - json, + format: resolvedFormat, summary, noIndex, }; @@ -212,10 +257,14 @@ ${perDeltaLines} composes with both --base and --baseline. Other flags: - --json Emit the {head, deltas} envelope as JSON to stdout - (default for agents). On error: {"error":""}. - --summary Collapse rows to counts. With --json: deltas..{added: N, removed: N}. - Without: a single line "drift: files +1/-0, dependencies +3/-2, ...". + --format Output format: text | json | sarif. Default: text. + sarif emits a SARIF 2.1.0 doc (one rule per delta key, + one result per added row) for GitHub Code Scanning. + --json Shortcut for --format json. Cannot combine with --format + . Emits {head, deltas} envelope; on error: {"error":""}. + --summary Collapse rows to counts. With --format json: deltas..{added: N, removed: N}. + With --format text: a single line "drift: files +1/-0, dependencies +3/-2, ...". + No-op with --format sarif (results are per-row). --no-index Skip the auto-incremental-index prelude. Default: re-index first so 'head' reflects the current source tree. --help, -h Show this help. @@ -262,7 +311,7 @@ export async function runAuditCmd(opts: { baselinePrefix: string | undefined; base: string | undefined; perDelta: Record; - json: boolean; + format: AuditOutputFormat; summary: boolean; noIndex: boolean; }): Promise { @@ -298,32 +347,57 @@ export async function runAuditCmd(opts: { }); if ("error" in result) { - emitAuditError(result.error, opts.json); + emitAuditError(result.error, opts.format); return; } - renderAudit(result, { json: opts.json, summary: opts.summary }); + renderAudit(result, { format: opts.format, summary: opts.summary }); } finally { closeDb(db, { readonly: opts.noIndex }); } } catch (err) { - emitAuditError(err instanceof Error ? err.message : String(err), opts.json); + emitAuditError( + err instanceof Error ? err.message : String(err), + opts.format, + ); } } -function emitAuditError(message: string, json: boolean) { - if (json) { - console.log(JSON.stringify({ error: message })); - } else { +// Errors are JSON-shaped for any structured format (`json` / `sarif`) so +// programmatic consumers always parse the same envelope; text-mode errors +// stay on stderr for terminal users. +function emitAuditError(message: string, format: AuditOutputFormat) { + if (format === "text") { console.error(message); + } else { + console.log(JSON.stringify({ error: message })); } process.exitCode = 1; } function renderAudit( envelope: AuditEnvelope, - opts: { json: boolean; summary: boolean }, + opts: { format: AuditOutputFormat; summary: boolean }, ): void { - if (opts.json) { + if (opts.format === "sarif") { + // SARIF flattens added rows across deltas. `--summary` is a no-op here: + // SARIF results are individual rows, not counts. Document this in + // --help; surface a stderr warning if both are set. + if (opts.summary) { + console.error( + "codemap audit: --summary has no effect with --format sarif (SARIF emits one result per added row, not counts).", + ); + } + const sarifDeltas: AuditSarifDelta[] = Object.entries(envelope.deltas).map( + ([key, delta]) => ({ + key, + added: delta.added as Record[], + }), + ); + console.log(formatAuditSarif(sarifDeltas)); + return; + } + + if (opts.format === "json") { if (opts.summary) { const counts: Record< string, @@ -346,6 +420,8 @@ function renderAudit( } return; } + + // format === "text" renderAuditTerminal(envelope, opts.summary); } diff --git a/src/cli/main.ts b/src/cli/main.ts index 1fa38ae..e4942b8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -274,7 +274,7 @@ Copies bundled agent templates into .agents/ under the project root. baselinePrefix: parsed.baselinePrefix, base: parsed.base, perDelta: parsed.perDelta, - json: parsed.json, + format: parsed.format, summary: parsed.summary, noIndex: parsed.noIndex, }); diff --git a/templates/agents/rules/codemap.md b/templates/agents/rules/codemap.md index 2b39fd9..be44d17 100644 --- a/templates/agents/rules/codemap.md +++ b/templates/agents/rules/codemap.md @@ -18,35 +18,35 @@ Install **[@stainless-code/codemap](https://www.npmjs.com/package/@stainless-cod **Examples below use `codemap`** — prefix with **`npx @stainless-code/codemap`** (or **`pnpm dlx`**, **`yarn dlx`**, **`bunx`**) when the CLI is not on your **`PATH`**. -| Action | Command | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Incremental index | `codemap` | -| Query (JSON — default for agents) | `codemap query --json ""` | -| Query (ASCII table — optional) | `codemap query ""` | -| Query (recipe) | `codemap query --json --recipe fan-out` (see **`codemap query --help`**) | -| Parametrised recipe | `codemap query --json --recipe find-symbol-by-kind --params kind=function,name_pattern=%Query%` — params declared in recipe `.md` frontmatter and validated before SQL binding. | -| Boundary violations | `codemap query --json --recipe boundary-violations` — joins `dependencies` × `boundary_rules` (config-driven) via SQLite `GLOB`. `.codemap/config.ts` `boundaries: [{name, from_glob, to_glob, action?}]`; default `action: "deny"`. SARIF / annotations work via the `file_path` alias. | -| Rename preview | `codemap query --recipe rename-preview --params old=usePermissions,new=useAccess,kind=function --format diff` — read-only unified diff; codemap never writes files. | -| Recipe catalog (JSON) | `codemap query --recipes-json` | -| Print one recipe’s SQL | `codemap query --print-sql fan-out` | -| Counts only | `codemap query --json --summary -r deprecated-symbols` | -| PR-scoped rows | `codemap query --json --changed-since origin/main -r fan-out` | -| Bucket by owner / dir / pkg | `codemap query --json --group-by directory -r fan-in` | -| Save / diff a baseline | `codemap query --save-baseline -r visibility-tags` then `… --json --baseline -r visibility-tags` | -| List / drop baselines | `codemap query --baselines` · `codemap query --drop-baseline ` | -| Per-delta audit | `codemap audit --json --baseline base` (auto-resolves `base-files` / `base-dependencies` / `base-deprecated`) | -| Audit vs git ref | `codemap audit --base origin/main --json` — worktree+reindex against any committish; sub-100ms second run via sha-keyed cache. Mutually exclusive with `--baseline`; per-delta overrides compose. | -| MCP server (for agent hosts) | `codemap mcp` — JSON-RPC on stdio; one tool per CLI verb. See **MCP** section below. | -| Targeted read (metadata) | `codemap show [--kind ] [--in ] [--json]` — file:line + signature | -| Targeted read (source text) | `codemap snippet [--kind ] [--in ] [--json]` — same lookup + source from disk + stale flag | -| Impact (blast-radius walker) | `codemap impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | -| Coverage ingest | `codemap ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | -| SARIF / GH annotations | `codemap query --recipe deprecated-symbols --format sarif` · `… --format annotations` | -| Mermaid graph (≤50 edges) | `codemap query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | -| Diff preview | `codemap query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | -| FTS5 full-text (opt-in) | `codemap --with-fts --full` enables `source_fts` virtual table; `query --recipe text-in-deprecated-functions` demos JOINs. | -| HTTP server (for non-MCP) | `codemap serve [--host 127.0.0.1] [--port 7878] [--token ] [--no-watch] [--debounce ]` — same tool taxonomy over POST /tool/{name}. Watcher default-ON since 2026-05. | -| Watch mode (live reindex) | `codemap watch [--debounce 250] [--quiet]` — standalone long-running process; debounced reindex on file changes. `mcp` / `serve` boot the watcher in-process by default — pass `--no-watch` (or `CODEMAP_WATCH=0`) to opt out. | +| Action | Command | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Incremental index | `codemap` | +| Query (JSON — default for agents) | `codemap query --json ""` | +| Query (ASCII table — optional) | `codemap query ""` | +| Query (recipe) | `codemap query --json --recipe fan-out` (see **`codemap query --help`**) | +| Parametrised recipe | `codemap query --json --recipe find-symbol-by-kind --params kind=function,name_pattern=%Query%` — params declared in recipe `.md` frontmatter and validated before SQL binding. | +| Boundary violations | `codemap query --json --recipe boundary-violations` — joins `dependencies` × `boundary_rules` (config-driven) via SQLite `GLOB`. `.codemap/config.ts` `boundaries: [{name, from_glob, to_glob, action?}]`; default `action: "deny"`. SARIF / annotations work via the `file_path` alias. | +| Rename preview | `codemap query --recipe rename-preview --params old=usePermissions,new=useAccess,kind=function --format diff` — read-only unified diff; codemap never writes files. | +| Recipe catalog (JSON) | `codemap query --recipes-json` | +| Print one recipe’s SQL | `codemap query --print-sql fan-out` | +| Counts only | `codemap query --json --summary -r deprecated-symbols` | +| PR-scoped rows | `codemap query --json --changed-since origin/main -r fan-out` | +| Bucket by owner / dir / pkg | `codemap query --json --group-by directory -r fan-in` | +| Save / diff a baseline | `codemap query --save-baseline -r visibility-tags` then `… --json --baseline -r visibility-tags` | +| List / drop baselines | `codemap query --baselines` · `codemap query --drop-baseline ` | +| Per-delta audit | `codemap audit --json --baseline base` (auto-resolves `base-files` / `base-dependencies` / `base-deprecated`) | +| Audit vs git ref | `codemap audit --base origin/main --json` — worktree+reindex against any committish; sub-100ms second run via sha-keyed cache. Mutually exclusive with `--baseline`; per-delta overrides compose. Add `--format sarif` to emit SARIF 2.1.0 directly (one rule per delta key; severity `warning`). | +| MCP server (for agent hosts) | `codemap mcp` — JSON-RPC on stdio; one tool per CLI verb. See **MCP** section below. | +| Targeted read (metadata) | `codemap show [--kind ] [--in ] [--json]` — file:line + signature | +| Targeted read (source text) | `codemap snippet [--kind ] [--in ] [--json]` — same lookup + source from disk + stale flag | +| Impact (blast-radius walker) | `codemap impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | +| Coverage ingest | `codemap ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | +| SARIF / GH annotations | `codemap query --recipe deprecated-symbols --format sarif` · `… --format annotations` | +| Mermaid graph (≤50 edges) | `codemap query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | +| Diff preview | `codemap query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | +| FTS5 full-text (opt-in) | `codemap --with-fts --full` enables `source_fts` virtual table; `query --recipe text-in-deprecated-functions` demos JOINs. | +| HTTP server (for non-MCP) | `codemap serve [--host 127.0.0.1] [--port 7878] [--token ] [--no-watch] [--debounce ]` — same tool taxonomy over POST /tool/{name}. Watcher default-ON since 2026-05. | +| Watch mode (live reindex) | `codemap watch [--debounce 250] [--quiet]` — standalone long-running process; debounced reindex on file changes. `mcp` / `serve` boot the watcher in-process by default — pass `--no-watch` (or `CODEMAP_WATCH=0`) to opt out. | **Recipe metadata:** with **`--json`**, recipes that define an `actions` template append it to every row (kebab-case verb + description — e.g. `fan-out` → `review-coupling`). Under `--baseline`, actions attach to the **`added`** rows only. Parametrised recipes declare `params` in `.md` frontmatter; pass values with `--params key=value[,key=value]` (repeatable; last value wins). Inspect both via **`--recipes-json`**. Ad-hoc SQL never carries actions or params. From 7a81b88521860eb0fc14e1cb765cbba3852906d3 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:11:25 +0300 Subject: [PATCH 02/10] feat(cli): --ci aggregate flag on `query` + `audit` (Slice 1b of #73 plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of Slice 1. `--ci` is the GitHub-Action-shaped CI flag: - Aliases `--format sarif` - Sets process.exitCode = 1 when ≥1 finding/addition surfaced - Suppresses the no-locatable-rows stderr warning (CI templates would surface it as red noise; the row-set itself is the gating signal) Lands on both `query` and `audit` (the two finding-producing verbs). Same parser / resolver semantics on both: - Mutually exclusive with `--json` (different format aliases) - Mutually exclusive with `--format ` (contradicts the alias) - `--ci --format sarif` redundant but accepted (consumers may set both for clarity in CI templates) Wiring: - `parseQueryRest` / `parseAuditRest` gain `--ci` token + `ci: boolean` in the run-shape union. - `runQueryCmd` / `runAuditCmd` gain `ci?: boolean` opt; threaded through to `printFormattedQuery` (query) and the post-render exit-code branch (audit). - `query`: exit 1 if `rows.length > 0` after SARIF emit. - `audit`: exit 1 if any delta has `added.length > 0` after SARIF emit. Tests: - 4 new `cmd-query` parser tests (--ci alias; --ci+--json reject; --ci+--format json reject; --ci+--format sarif accept). - 4 new `cmd-audit` parser tests (same matrix). - All existing toEqual tests updated for the `ci: false` field default. Smoke verified end-to-end: - `query --ci` with results → SARIF stdout, exit 1. - `audit --baseline X --ci` against identical baseline → 0 additions, exit 0. Diff with adds → exit 1. - Contradiction tests (`--ci --json`) emit clear error + exit 1. --- src/cli/cmd-audit.test.ts | 45 +++++++++++++++++++++++++ src/cli/cmd-audit.ts | 49 +++++++++++++++++++++++++-- src/cli/cmd-query.test.ts | 60 +++++++++++++++++++++++++++++++++ src/cli/cmd-query.ts | 70 +++++++++++++++++++++++++++++++++++---- src/cli/main.ts | 2 ++ 5 files changed, 217 insertions(+), 9 deletions(-) diff --git a/src/cli/cmd-audit.test.ts b/src/cli/cmd-audit.test.ts index 74f4f14..3a3373f 100644 --- a/src/cli/cmd-audit.test.ts +++ b/src/cli/cmd-audit.test.ts @@ -45,6 +45,7 @@ describe("parseAuditRest", () => { base: undefined, perDelta: {}, format: "text", + ci: false, summary: false, noIndex: false, }); @@ -58,6 +59,7 @@ describe("parseAuditRest", () => { base: undefined, perDelta: {}, format: "text", + ci: false, summary: false, noIndex: false, }); @@ -79,6 +81,7 @@ describe("parseAuditRest", () => { base: undefined, perDelta: { files: "X", dependencies: "Y", deprecated: "Z" }, format: "text", + ci: false, summary: false, noIndex: false, }); @@ -98,6 +101,7 @@ describe("parseAuditRest", () => { base: undefined, perDelta: { dependencies: "experimental-deps" }, format: "text", + ci: false, summary: false, noIndex: false, }); @@ -175,6 +179,46 @@ describe("parseAuditRest", () => { expect(r.format).toBe("json"); }); + it("parses --ci as alias for --format sarif + ci flag", () => { + const r = parseAuditRest(["audit", "--ci", "--baseline", "base"]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("sarif"); + expect(r.ci).toBe(true); + }); + + it("rejects --ci + --json (mutually exclusive aliases)", () => { + const r = parseAuditRest(["audit", "--ci", "--json", "--baseline", "base"]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--ci"); + }); + + it("rejects --ci + --format json (contradicting alias)", () => { + const r = parseAuditRest([ + "audit", + "--ci", + "--format", + "json", + "--baseline", + "base", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--ci"); + }); + + it("accepts --ci + --format sarif (redundant but consistent)", () => { + const r = parseAuditRest([ + "audit", + "--ci", + "--format", + "sarif", + "--baseline", + "base", + ]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("sarif"); + expect(r.ci).toBe(true); + }); + it("errors when --baseline has no value", () => { const r = parseAuditRest(["audit", "--baseline"]); expect(r.kind).toBe("error"); @@ -219,6 +263,7 @@ describe("parseAuditRest", () => { base: "origin/main", perDelta: {}, format: "text", + ci: false, summary: false, noIndex: false, }); diff --git a/src/cli/cmd-audit.ts b/src/cli/cmd-audit.ts index fe96658..53157cd 100644 --- a/src/cli/cmd-audit.ts +++ b/src/cli/cmd-audit.ts @@ -44,6 +44,8 @@ export function parseAuditRest(rest: string[]): base: string | undefined; perDelta: Record; format: AuditOutputFormat; + /** `--ci` was set: SARIF + non-zero exit when any delta has additions. */ + ci: boolean; summary: boolean; noIndex: boolean; } { @@ -56,6 +58,9 @@ export function parseAuditRest(rest: string[]): // `--json` so we can reject `--json --format sarif` as a contradiction. let jsonShortcut = false; let format: AuditOutputFormat | undefined; + // `--ci` is the CI-aggregate flag: aliases `--format sarif` + non-zero + // exit-on-issue + suppresses chatty stderr. Plan: docs/plans/github-marketplace-action.md (Slice 1b). + let ci = false; let summary = false; let noIndex = false; let baselinePrefix: string | undefined; @@ -70,6 +75,11 @@ export function parseAuditRest(rest: string[]): i++; continue; } + if (a === "--ci") { + ci = true; + i++; + continue; + } if (a === "--format" || a.startsWith("--format=")) { const value = consumeFlagValue(rest, i, "--format"); if (value.kind === "error") return value; @@ -153,10 +163,26 @@ export function parseAuditRest(rest: string[]): }; } - // Reconcile --json shortcut with --format. Both → must agree on `json`. - // Neither → default to `text`. + // Reconcile --json / --ci / --format. `--ci` aliases `--format sarif`; mutually + // exclusive with --json (different format aliases) and with --format + // (contradicts the alias). `--ci --format sarif` is redundant but accepted. let resolvedFormat: AuditOutputFormat; - if (jsonShortcut && format !== undefined) { + if (ci) { + if (jsonShortcut) { + return { + kind: "error", + message: + 'codemap audit: "--ci" and "--json" are mutually exclusive (--ci aliases --format sarif; --json aliases --format json).', + }; + } + if (format !== undefined && format !== "sarif") { + return { + kind: "error", + message: `codemap audit: "--ci" aliases "--format sarif"; cannot combine with --format ${format}.`, + }; + } + resolvedFormat = "sarif"; + } else if (jsonShortcut && format !== undefined) { if (format !== "json") { return { kind: "error", @@ -176,6 +202,7 @@ export function parseAuditRest(rest: string[]): base, perDelta, format: resolvedFormat, + ci, summary, noIndex, }; @@ -262,6 +289,10 @@ Other flags: one result per added row) for GitHub Code Scanning. --json Shortcut for --format json. Cannot combine with --format . Emits {head, deltas} envelope; on error: {"error":""}. + --ci CI-aggregate flag. Aliases --format sarif + non-zero exit + when any delta has additions. Mutually exclusive with --json + and --format . Recommended in GitHub Actions / GitLab + CI to fail the runner step on structural drift. --summary Collapse rows to counts. With --format json: deltas..{added: N, removed: N}. With --format text: a single line "drift: files +1/-0, dependencies +3/-2, ...". No-op with --format sarif (results are per-row). @@ -312,6 +343,8 @@ export async function runAuditCmd(opts: { base: string | undefined; perDelta: Record; format: AuditOutputFormat; + /** `--ci`: exit non-zero when any delta has `added.length > 0`. */ + ci?: boolean; summary: boolean; noIndex: boolean; }): Promise { @@ -351,6 +384,16 @@ export async function runAuditCmd(opts: { return; } renderAudit(result, { format: opts.format, summary: opts.summary }); + + // `--ci`: exit non-zero when any delta has additions. SARIF results + // already surfaced via Code Scanning; non-zero exit fails the runner + // step so the workflow gates the PR. + if (opts.ci === true) { + const hasAdditions = Object.values(result.deltas).some( + (d) => d.added.length > 0, + ); + if (hasAdditions) process.exitCode = 1; + } } finally { closeDb(db, { readonly: opts.noIndex }); } diff --git a/src/cli/cmd-query.test.ts b/src/cli/cmd-query.test.ts index c985d2f..7a22c4d 100644 --- a/src/cli/cmd-query.test.ts +++ b/src/cli/cmd-query.test.ts @@ -26,6 +26,7 @@ describe("parseQueryRest", () => { sql: "SELECT 1", json: false, format: "text", + ci: false, summary: false, changedSince: undefined, recipeId: undefined, @@ -42,6 +43,7 @@ describe("parseQueryRest", () => { sql: "SELECT 1", json: true, format: "json", + ci: false, summary: false, changedSince: undefined, recipeId: undefined, @@ -58,6 +60,7 @@ describe("parseQueryRest", () => { sql: "SELECT 1", json: false, format: "text", + ci: false, summary: true, changedSince: undefined, recipeId: undefined, @@ -74,6 +77,7 @@ describe("parseQueryRest", () => { sql: "SELECT 1", json: true, format: "json", + ci: false, summary: true, changedSince: undefined, recipeId: undefined, @@ -92,6 +96,7 @@ describe("parseQueryRest", () => { sql: sql!, json: false, format: "text", + ci: false, summary: true, changedSince: undefined, recipeId: "fan-out", @@ -113,6 +118,7 @@ describe("parseQueryRest", () => { sql: "SELECT 1", json: false, format: "text", + ci: false, summary: false, changedSince: "origin/main", recipeId: undefined, @@ -138,6 +144,7 @@ describe("parseQueryRest", () => { sql: sql!, json: true, format: "json", + ci: false, summary: false, changedSince: "HEAD~3", recipeId: "fan-out", @@ -160,6 +167,7 @@ describe("parseQueryRest", () => { sql: "SELECT * FROM symbols", json: true, format: "json", + ci: false, summary: false, changedSince: undefined, recipeId: undefined, @@ -178,6 +186,7 @@ describe("parseQueryRest", () => { sql: sql!, json: false, format: "text", + ci: false, summary: false, changedSince: undefined, recipeId: "fan-in", @@ -187,6 +196,52 @@ describe("parseQueryRest", () => { }); }); + it("parses --ci as alias for --format sarif + ci flag", () => { + const r = parseQueryRest(["query", "--ci", "-r", "deprecated-symbols"]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("sarif"); + expect(r.ci).toBe(true); + }); + + it("rejects --ci + --json (mutually exclusive aliases)", () => { + const r = parseQueryRest([ + "query", + "--ci", + "--json", + "-r", + "deprecated-symbols", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--ci"); + }); + + it("rejects --ci + --format json (contradicting alias)", () => { + const r = parseQueryRest([ + "query", + "--ci", + "--format", + "json", + "-r", + "deprecated-symbols", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--ci"); + }); + + it("accepts --ci + --format sarif (redundant but consistent)", () => { + const r = parseQueryRest([ + "query", + "--ci", + "--format", + "sarif", + "-r", + "deprecated-symbols", + ]); + if (r.kind !== "run") throw new Error("expected run"); + expect(r.format).toBe("sarif"); + expect(r.ci).toBe(true); + }); + it("errors when --group-by has no mode", () => { const r = parseQueryRest(["query", "--group-by"]); expect(r.kind).toBe("error"); @@ -382,6 +437,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( sql: sql!, json: false, format: "text", + ci: false, summary: false, changedSince: undefined, recipeId: "fan-out-sample-json", @@ -400,6 +456,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( sql: sql!, json: false, format: "text", + ci: false, summary: false, changedSince: undefined, recipeId: "fan-out", @@ -465,6 +522,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( sql: sql!, json: true, format: "json", + ci: false, summary: false, changedSince: undefined, recipeId: "fan-out-sample", @@ -483,6 +541,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( sql: sql!, json: true, format: "json", + ci: false, summary: false, changedSince: undefined, recipeId: "fan-out", @@ -501,6 +560,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", ( sql: sql!, json: true, format: "json", + ci: false, summary: false, changedSince: undefined, recipeId: "fan-out", diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index 8b26ed8..b62c59f 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -87,6 +87,8 @@ export function parseQueryRest(rest: string[]): sql: string; json: boolean; format: OutputFormat; + /** `--ci` flag — aliases `--format sarif` + non-zero exit on findings + suppresses no-findings stderr warning. */ + ci: boolean; summary: boolean; changedSince: string | undefined; recipeId: string | undefined; @@ -113,6 +115,10 @@ export function parseQueryRest(rest: string[]): let i = 1; let json = false; let format: OutputFormat | undefined; + // `--ci` is the CI-aggregate flag: aliases `--format sarif` + non-zero + // exit-on-issue + suppresses the no-findings stderr warning. Mirrored in + // `cmd-audit.ts`. Plan: docs/plans/github-marketplace-action.md (Slice 1b). + let ci = false; let summary = false; let changedSince: string | undefined; let recipeId: string | undefined; @@ -135,6 +141,11 @@ export function parseQueryRest(rest: string[]): i++; continue; } + if (a === "--ci") { + ci = true; + i++; + continue; + } if (a === "--format" || a.startsWith("--format=")) { const eq = a.indexOf("="); const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1]; @@ -449,7 +460,10 @@ export function parseQueryRest(rest: string[]): message: `codemap: unknown recipe "${recipeId}". Known recipes: ${known}`, }; } - const resolved = resolveFormat(format, json); + const resolved = resolveFormat(format, json, ci); + if (resolved instanceof Error) { + return { kind: "error", message: resolved.message }; + } const incompat = formatIncompatibility(resolved, { summary, groupBy, @@ -462,6 +476,7 @@ export function parseQueryRest(rest: string[]): sql, json, format: resolved, + ci, summary, changedSince, recipeId, @@ -501,7 +516,10 @@ export function parseQueryRest(rest: string[]): 'codemap: "--baseline" needs an explicit name when used without --recipe. Use --baseline=.', }; } - const resolved = resolveFormat(format, json); + const resolved = resolveFormat(format, json, ci); + if (resolved instanceof Error) { + return { kind: "error", message: resolved.message }; + } const incompat = formatIncompatibility(resolved, { summary, groupBy, @@ -514,6 +532,7 @@ export function parseQueryRest(rest: string[]): sql, json, format: resolved, + ci, summary, changedSince, recipeId: undefined, @@ -527,11 +546,30 @@ export function parseQueryRest(rest: string[]): /** * Resolve the effective format. Per plan § D9, `--format` overrides `--json`; * `--json` alone implies `--format json`; absence of both → `text`. + * + * `--ci` is the CI-aggregate flag — aliases `--format sarif`. Mutually + * exclusive with explicit `--format ` (rejected as a contradiction); + * `--ci --json` likewise rejected. `--ci` + `--format sarif` is redundant + * but accepted (consumers may set both for clarity in CI templates). */ function resolveFormat( explicit: OutputFormat | undefined, json: boolean, -): OutputFormat { + ci: boolean, +): OutputFormat | Error { + if (ci) { + if (json) { + return new Error( + 'codemap: "--ci" and "--json" are mutually exclusive (--ci aliases --format sarif; --json aliases --format json).', + ); + } + if (explicit !== undefined && explicit !== "sarif") { + return new Error( + `codemap: "--ci" aliases "--format sarif"; cannot combine with --format ${explicit}.`, + ); + } + return "sarif"; + } if (explicit !== undefined) return explicit; return json ? "json" : "text"; } @@ -724,6 +762,13 @@ export async function runQueryCmd(opts: { * caller must reject those combos at parse time. */ format?: OutputFormat; + /** + * `--ci` flag — flips exit code to 1 when the query produces ≥1 row(s) + * and suppresses the stderr no-locatable-rows warning. Format must be + * `"sarif"` (parser enforces this — `--ci` aliases `--format sarif`); + * passing `ci: true` with another format is undefined behavior. + */ + ci?: boolean; summary?: boolean; changedSince?: string | undefined; recipeId?: string | undefined; @@ -821,6 +866,7 @@ export async function runQueryCmd(opts: { recipeId: opts.recipeId, changedFiles, bindValues: bindValues.values, + ci: opts.ci === true, }); if (code !== 0) process.exitCode = code; return; @@ -939,6 +985,13 @@ function printFormattedQuery( recipeId: string | undefined; changedFiles: Set | undefined; bindValues: RecipeParamValue[] | undefined; + /** + * `--ci` was set on the parser. When true: (a) suppress the + * no-locatable-rows stderr warning (CI templates would surface it + * as red noise), (b) return exit code 1 when rows.length > 0 so the + * runner step fails on findings. + */ + ci?: boolean; }, ): number { let db: Awaited> | undefined; @@ -956,11 +1009,14 @@ function printFormattedQuery( } // SARIF / annotations require a location column; mermaid requires - // the from/to graph contract (checked inside formatMermaid). + // the from/to graph contract (checked inside formatMermaid). `--ci` + // suppresses this warning — the row-set is the gating signal under + // CI; the warning is consumer-facing dev guidance. if ( opts.format !== "mermaid" && rows.length > 0 && - !hasLocatableRows(rows) + !hasLocatableRows(rows) && + opts.ci !== true ) { console.error( `codemap: --format ${opts.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 ${opts.format} v1.)`, @@ -980,7 +1036,9 @@ function printFormattedQuery( recipeBody: catalog?.body, }); console.log(out); - return 0; + // `--ci`: exit non-zero when the recipe produced ≥1 finding so the + // CI runner step fails. Without `--ci`, SARIF emit is informational. + return opts.ci === true && rows.length > 0 ? 1 : 0; } if (opts.format === "mermaid") { diff --git a/src/cli/main.ts b/src/cli/main.ts index e4942b8..ca27d7b 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -275,6 +275,7 @@ Copies bundled agent templates into .agents/ under the project root. base: parsed.base, perDelta: parsed.perDelta, format: parsed.format, + ci: parsed.ci, summary: parsed.summary, noIndex: parsed.noIndex, }); @@ -361,6 +362,7 @@ Copies bundled agent templates into .agents/ under the project root. sql: parsed.sql, json: parsed.json, format: parsed.format, + ci: parsed.ci, summary: parsed.summary, changedSince: parsed.changedSince, recipeId: parsed.recipeId, From 9383a95dcdd94819b77bb6ff24fd2d820f9080c0 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:16:13 +0300 Subject: [PATCH 03/10] feat(action): action.yml + scripts/detect-pm.mjs (Slice 2 of #73 plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite GitHub Action wrapping codemap CLI for the Marketplace. ~16 declarative inputs per Q1 resolution; package-manager detection + codemap CLI invocation resolution via package-manager-detector (antfu/userquin, MIT, 0 transitive deps, 23 kB). action.yml shape: - Skip-on-non-PR-events for the headline α default (audit --base ${{ github.base_ref }} --ci). Other events (push, schedule, …) no-op + log "no PR context, skipping" + exit 0 unless an explicit command: input is passed. - 16 declarative inputs across 3 categories: - WHERE TO RUN: working-directory, package-manager (override), version (CLI pin), state-dir - WHAT TO RUN: mode (audit | recipe | aggregate | command), recipe, params, baseline, audit-base, changed-since, group-by, command (escape hatch) - WHAT TO DO WITH OUTPUT: format (default sarif), output-path, upload-sarif, pr-comment (Slice 3 stub for v1.0), fail-on, token - Validation precedence: command > mode > defaults; mode='aggregate' rejected (reserved for v1.x post-Q6 SARIF rule.id de-dup work). - 4 outputs: agent / exec / install_method (debug breadcrumbs) + output-file (echoes inputs.output-path). - Composite steps: gate → setup-node → detect-pm → validate → run → upload-sarif (if Code Scanning enabled) → pr-comment-stub. scripts/detect-pm.mjs: - Wraps `package-manager-detector`'s `detect()` + `resolveCommand()`. - Implements the Q3 invocation logic: - VERSION env var set → 'execute' intent (dlx-pinned) - codemap in devDependencies → 'execute-local' - else → 'execute' intent (dlx-latest) - Outputs to $GITHUB_OUTPUT per current Actions convention (set-output deprecated 2022-10). - Validates PACKAGE_MANAGER override against known agents. - 8 unit tests covering: pnpm/bun/npm autodetect, no-lockfile fallback, execute-local for project-installed, dlx-pinned override, manual PM override, unknown PM rejection, packageManager-field priority over lockfile. New runtime dep: package-manager-detector@1.6.0 (MIT, antfu/userquin, 0 transitive deps). --- action.yml | 256 +++++++++++++++++++++++++++++++++++++ bun.lock | 5 +- package.json | 1 + scripts/detect-pm.mjs | 123 ++++++++++++++++++ scripts/detect-pm.test.mjs | 152 ++++++++++++++++++++++ 5 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 action.yml create mode 100644 scripts/detect-pm.mjs create mode 100644 scripts/detect-pm.test.mjs diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..5026f91 --- /dev/null +++ b/action.yml @@ -0,0 +1,256 @@ +name: "Codemap" +description: "SQL-queryable structural index of your codebase. Run any predicate as a recipe; CI gating via SARIF → Code Scanning." +author: "stainless-code" +branding: + icon: "database" + color: "blue" + +inputs: + # WHERE TO RUN + working-directory: + description: "Subdirectory to run codemap in (for monorepos). Defaults to the repository root." + required: false + default: "." + package-manager: + description: "Override package-manager autodetect. Accepts npm | pnpm | yarn | yarn@berry | bun. Empty = autodetect via package-manager-detector (lockfile + packageManager field + devEngines.packageManager + install-metadata + parent-dir walk)." + required: false + default: "" + version: + description: "Pin codemap CLI version (e.g. 1.2.3). Empty = use the project's devDependency if present, else fall back to dlx codemap@latest." + required: false + default: "" + state-dir: + description: "Override codemap state directory location. Empty = .codemap/ at the working-directory root (codemap default)." + required: false + default: "" + + # WHAT TO RUN — high-level (mutually exclusive; precedence: command > mode > defaults) + mode: + description: "Run shape: 'audit' | 'recipe' | 'aggregate' | 'command'. Default: 'audit' on pull_request events; ignored on other events (Action no-ops). 'aggregate' is reserved for v1.x — currently rejected." + required: false + default: "audit" + recipe: + description: "Recipe id (when mode=recipe). Use --recipes-json on the CLI to list known recipes." + required: false + default: "" + params: + description: "Recipe params for parametrised recipes (when mode=recipe). Multiline `key=value` pairs, one per line." + required: false + default: "" + baseline: + description: "Saved baseline name to diff against (when mode=recipe + a baseline was previously saved with --save-baseline)." + required: false + default: "" + audit-base: + description: "Git ref to audit against (when mode=audit). Default: ${{ github.base_ref }} on pull_request events." + required: false + default: "" + changed-since: + description: "Filter results to files changed since the given git ref (e.g. 'origin/main')." + required: false + default: "" + group-by: + description: "Bucket results by 'owner' (CODEOWNERS) | 'directory' | 'package' (workspace package). Empty = no bucketing." + required: false + default: "" + command: + description: "Raw CLI args to invoke codemap with (escape hatch). When set, overrides mode / recipe / params / baseline / audit-base / changed-since / group-by — those are silently ignored with a warning." + required: false + default: "" + + # WHAT TO DO WITH OUTPUT + format: + description: "Output format: 'sarif' | 'json' | 'annotations' | 'mermaid' | 'diff' (per-mode availability varies; audit supports text/json/sarif). Default: 'sarif' (SARIF 2.1.0 → Code Scanning)." + required: false + default: "sarif" + output-path: + description: "Where to write the output file. Used as the artifact-upload source when format=sarif and upload-sarif=true." + required: false + default: "codemap.sarif" + upload-sarif: + description: "Upload the SARIF artifact to GitHub Code Scanning. Requires GitHub Advanced Security on private repos. Set 'false' if your repo can't use Code Scanning (still produces the artifact for manual download / pr-comment writer)." + required: false + default: "true" + pr-comment: + description: "Post a markdown summary comment on the PR (Slice 3 — opt-in for v1.0). Set 'true' to enable. Useful when SARIF→Code-Scanning isn't available (private repos without GHAS, or repos that haven't enabled Code Scanning)." + required: false + default: "false" + fail-on: + description: "Exit-code policy: 'any' | 'error' | 'warning' | 'never'. v1.0 ships only 'any' (fails when any finding) and 'never' (no exit code). 'error' / 'warning' deferred until per-recipe severity overrides ship." + required: false + default: "any" + token: + description: "GitHub token for SARIF upload + PR comment posting. Default: ${{ github.token }}." + required: false + default: ${{ github.token }} + +outputs: + agent: + description: "Resolved package manager (npm / pnpm / yarn / bun)." + value: ${{ steps.detect-pm.outputs.agent }} + exec: + description: "Shell-ready command used to invoke codemap." + value: ${{ steps.detect-pm.outputs.exec }} + install_method: + description: "How codemap was located: 'project-installed' | 'dlx-pinned' | 'dlx-latest'." + value: ${{ steps.detect-pm.outputs.install_method }} + output-file: + description: "Path to the written output file (echoes inputs.output-path)." + value: ${{ inputs.output-path }} + +runs: + using: "composite" + steps: + - name: Skip on non-PR events when defaulting to audit + id: gate + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + MODE: ${{ inputs.mode }} + COMMAND: ${{ inputs.command }} + AUDIT_BASE_INPUT: ${{ inputs.audit-base }} + run: | + if [ -n "$COMMAND" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$MODE" != "audit" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + BASE="${AUDIT_BASE_INPUT:-$BASE_REF}" + if [ -z "$BASE" ]; then + echo "codemap action: no PR context (event_name=$EVENT_NAME, base_ref empty), skipping. Pass an explicit 'command:' input to run on non-PR events." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js (for the package-manager-detector wrapper) + if: steps.gate.outputs.skip != 'true' + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Detect package manager + resolve CLI invocation + if: steps.gate.outputs.skip != 'true' + id: detect-pm + shell: bash + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + VERSION: ${{ inputs.version }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + # Action runs without its own node_modules; install the detector lazily. + # Pinned to a known version so consumers get reproducible builds. + npm install --no-save --prefix "$RUNNER_TEMP/codemap-action" package-manager-detector@1.6.0 >/dev/null + cp "${{ github.action_path }}/scripts/detect-pm.mjs" "$RUNNER_TEMP/codemap-action/detect-pm.mjs" + cd "$RUNNER_TEMP/codemap-action" + node detect-pm.mjs + + - name: Validate inputs (mode + flag interactions) + if: steps.gate.outputs.skip != 'true' + shell: bash + env: + MODE: ${{ inputs.mode }} + RECIPE: ${{ inputs.recipe }} + COMMAND: ${{ inputs.command }} + run: | + # Precedence: command > mode. If command is set, mode/recipe are silently + # ignored but we surface a single warning so consumers notice. + if [ -n "$COMMAND" ] && [ -n "$RECIPE" ]; then + echo "::warning::codemap action: 'command' input takes precedence; 'recipe' (and other mode-* inputs) are ignored." + fi + + case "$MODE" in + audit | recipe | command) ;; + aggregate) + echo "::error::codemap action: mode='aggregate' is reserved for v1.x and not yet implemented. Use mode='audit' or pass a 'command:' input." + exit 1 + ;; + *) + echo "::error::codemap action: unknown mode '$MODE'. Expected: audit | recipe | aggregate | command." + exit 1 + ;; + esac + + if [ "$MODE" = "recipe" ] && [ -z "$RECIPE" ] && [ -z "$COMMAND" ]; then + echo "::error::codemap action: mode='recipe' requires the 'recipe' input (a recipe id). Run codemap query --recipes-json to list known recipes." + exit 1 + fi + + - name: Run codemap + if: steps.gate.outputs.skip != 'true' + id: run + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + EXEC: ${{ steps.detect-pm.outputs.exec }} + MODE: ${{ inputs.mode }} + RECIPE: ${{ inputs.recipe }} + PARAMS: ${{ inputs.params }} + BASELINE: ${{ inputs.baseline }} + AUDIT_BASE: ${{ inputs.audit-base }} + CHANGED_SINCE: ${{ inputs.changed-since }} + GROUP_BY: ${{ inputs.group-by }} + COMMAND: ${{ inputs.command }} + FORMAT: ${{ inputs.format }} + OUTPUT_PATH: ${{ inputs.output-path }} + FAIL_ON: ${{ inputs.fail-on }} + STATE_DIR: ${{ inputs.state-dir }} + BASE_REF: ${{ github.base_ref }} + run: | + set +e + + # Build args based on inputs (or use raw command). + if [ -n "$COMMAND" ]; then + ARGS="$COMMAND" + elif [ "$MODE" = "audit" ]; then + BASE="${AUDIT_BASE:-$BASE_REF}" + ARGS="audit --base $BASE --format $FORMAT" + elif [ "$MODE" = "recipe" ]; then + ARGS="query --recipe $RECIPE --format $FORMAT" + [ -n "$PARAMS" ] && while IFS= read -r line; do + [ -n "$line" ] && ARGS="$ARGS --params $line" + done <<< "$PARAMS" + [ -n "$BASELINE" ] && ARGS="$ARGS --baseline $BASELINE" + fi + + [ -n "$CHANGED_SINCE" ] && ARGS="$ARGS --changed-since $CHANGED_SINCE" + [ -n "$GROUP_BY" ] && ARGS="$ARGS --group-by $GROUP_BY" + [ -n "$STATE_DIR" ] && ARGS="--state-dir $STATE_DIR $ARGS" + + echo "+ $EXEC $ARGS" + $EXEC $ARGS > "$OUTPUT_PATH" + EXIT=$? + + echo "exit_code=$EXIT" >> "$GITHUB_OUTPUT" + + # `fail-on` policy. v1.0 supports 'any' (default) and 'never'. Other + # values fall back to passing the underlying exit code through. + case "$FAIL_ON" in + never) exit 0 ;; + any | *) exit "$EXIT" ;; + esac + + - name: Upload SARIF to Code Scanning + if: steps.gate.outputs.skip != 'true' && inputs.upload-sarif == 'true' && inputs.format == 'sarif' && always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ inputs.working-directory }}/${{ inputs.output-path }} + token: ${{ inputs.token }} + + - name: Post PR summary comment + if: steps.gate.outputs.skip != 'true' && inputs.pr-comment == 'true' && github.event_name == 'pull_request' && always() + shell: bash + env: + EXEC: ${{ steps.detect-pm.outputs.exec }} + OUTPUT_PATH: ${{ inputs.working-directory }}/${{ inputs.output-path }} + FORMAT: ${{ inputs.format }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ inputs.token }} + run: | + # Slice 3 lands `codemap pr-comment` later; until then, just stub a + # short marker comment so downstream consumers can see the toggle works. + echo "::warning::codemap action: pr-comment writer (Slice 3) not yet implemented; toggle was set but no comment was posted." diff --git a/bun.lock b/bun.lock index a1c498f..a88e7ad 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "lightningcss": "^1.32.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", + "package-manager-detector": "^1.6.0", "tinyglobby": "^0.2.16", "zod": "^4.3.6", }, @@ -690,7 +691,7 @@ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -872,6 +873,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@changesets/cli/package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], diff --git a/package.json b/package.json index 4d22a75..162f0f8 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "lightningcss": "^1.32.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", + "package-manager-detector": "^1.6.0", "tinyglobby": "^0.2.16", "zod": "^4.3.6" }, diff --git a/scripts/detect-pm.mjs b/scripts/detect-pm.mjs new file mode 100644 index 0000000..fee86f1 --- /dev/null +++ b/scripts/detect-pm.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Action pre-step: detect the host project's package manager + resolve the + * codemap CLI invocation. Wraps `package-manager-detector` (antfu/userquin, + * MIT, 0 transitive deps). Outputs are written to `$GITHUB_OUTPUT` per + * GitHub Actions' current convention (`::set-output` was deprecated 2022-10). + * + * Inputs (env, all optional): + * PACKAGE_MANAGER Override autodetect with explicit `npm|pnpm|yarn|bun`. + * VERSION Pin codemap CLI version (e.g. `1.2.3`). + * Empty → use project-installed binary if present, + * else fall back to ` dlx codemap@latest`. + * WORKING_DIRECTORY Where to start the lockfile + package.json walk. + * Defaults to process.cwd() (the runner's repo root). + * + * Outputs (written to $GITHUB_OUTPUT): + * agent Resolved package manager (`npm` / `pnpm` / `yarn` / `bun`). + * exec Shell-ready command to run codemap (e.g. + * `pnpm exec codemap` or `pnpm dlx codemap@1.2.3`). + * install_method `project-installed` | `dlx-pinned` | `dlx-latest` + * (debug breadcrumb; surfaces in Action logs). + * + * Q2 + Q3 of docs/plans/github-marketplace-action.md. + */ + +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +import { resolveCommand } from "package-manager-detector/commands"; +import { detect } from "package-manager-detector/detect"; + +const VALID_AGENTS = new Set(["npm", "pnpm", "yarn", "yarn@berry", "bun"]); + +async function main() { + const explicitAgent = (process.env["PACKAGE_MANAGER"] ?? "").trim(); + const versionInput = (process.env["VERSION"] ?? "").trim(); + const workingDir = + (process.env["WORKING_DIRECTORY"] ?? "").trim() || process.cwd(); + + // Step 1: resolve the agent. + let agent; + if (explicitAgent !== "") { + if (!VALID_AGENTS.has(explicitAgent)) { + fail( + `package-manager input "${explicitAgent}" not recognised. Expected one of: ${[...VALID_AGENTS].join(", ")}.`, + ); + } + agent = explicitAgent; + } else { + const detected = await detect({ cwd: workingDir }); + agent = detected?.agent ?? "npm"; + } + + // Step 2: resolve the CLI invocation per Q3's three-branch logic. + let intent; + let commandArgs; + let installMethod; + if (versionInput !== "") { + intent = "execute"; + commandArgs = [`codemap@${versionInput}`]; + installMethod = "dlx-pinned"; + } else if (codemapInDevDependencies(workingDir)) { + intent = "execute-local"; + commandArgs = ["codemap"]; + installMethod = "project-installed"; + } else { + intent = "execute"; + commandArgs = ["codemap@latest"]; + installMethod = "dlx-latest"; + } + + const resolved = resolveCommand(agent, intent, commandArgs); + if (resolved === null) { + fail( + `package-manager-detector returned null for agent="${agent}", intent="${intent}". This usually means the agent doesn't support that intent (e.g. deno's execute-local).`, + ); + } + const { command, args } = resolved; + const exec = [command, ...args].join(" "); + + // Step 3: write to $GITHUB_OUTPUT. + const outputFile = process.env["GITHUB_OUTPUT"]; + if (outputFile === undefined || outputFile === "") { + // Local / non-Actions invocation — print to stdout for inspection. + console.log(`agent=${agent}`); + console.log(`exec=${exec}`); + console.log(`install_method=${installMethod}`); + return; + } + appendFileSync( + outputFile, + `agent=${agent}\nexec=${exec}\ninstall_method=${installMethod}\n`, + ); +} + +/** + * Read `/package.json` and check whether `codemap` is a + * direct dependency. Returns `false` on read errors / missing manifest. + */ +function codemapInDevDependencies(workingDir) { + try { + const manifestPath = join(workingDir, "package.json"); + if (!existsSync(manifestPath)) return false; + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + return Boolean( + manifest?.dependencies?.codemap || + manifest?.devDependencies?.codemap || + manifest?.optionalDependencies?.codemap, + ); + } catch { + return false; + } +} + +function fail(message) { + console.error(`detect-pm: ${message}`); + process.exit(1); +} + +main().catch((err) => { + fail(err instanceof Error ? err.message : String(err)); +}); diff --git a/scripts/detect-pm.test.mjs b/scripts/detect-pm.test.mjs new file mode 100644 index 0000000..7536e9c --- /dev/null +++ b/scripts/detect-pm.test.mjs @@ -0,0 +1,152 @@ +/** + * Unit tests for `scripts/detect-pm.mjs`. Spawns the script as a child + * process with controlled `WORKING_DIRECTORY` + `PACKAGE_MANAGER` + + * `VERSION` env vars; asserts on stdout (when `GITHUB_OUTPUT` is unset + * the script prints `key=value\n` lines to stdout for inspection). + * + * Lockfile fixtures live under `fixtures/detect-pm//` so the + * test doesn't have to touch the actual repo's lockfile state. + */ + +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const SCRIPT = join(import.meta.dirname, "detect-pm.mjs"); +let workRoot; + +beforeAll(() => { + workRoot = join(tmpdir(), `detect-pm-test-${process.pid}`); + rmSync(workRoot, { recursive: true, force: true }); + mkdirSync(workRoot, { recursive: true }); +}); + +afterAll(() => { + rmSync(workRoot, { recursive: true, force: true }); +}); + +function makeFixture(name, files) { + const dir = join(workRoot, name); + mkdirSync(dir, { recursive: true }); + for (const [path, contents] of Object.entries(files)) { + writeFileSync(join(dir, path), contents); + } + return dir; +} + +function runDetect(env) { + const result = spawnSync("node", [SCRIPT], { + env: { ...process.env, GITHUB_OUTPUT: "", ...env }, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error( + `detect-pm exited ${result.status}: ${result.stderr || result.stdout}`, + ); + } + const out = {}; + for (const line of result.stdout.split("\n")) { + const eq = line.indexOf("="); + if (eq === -1) continue; + out[line.slice(0, eq)] = line.slice(eq + 1); + } + return out; +} + +describe("scripts/detect-pm.mjs", () => { + it("detects pnpm from pnpm-lock.yaml", () => { + const dir = makeFixture("pnpm-fixture", { + "package.json": "{}", + "pnpm-lock.yaml": "lockfileVersion: 6.0\n", + }); + const out = runDetect({ WORKING_DIRECTORY: dir }); + expect(out.agent).toBe("pnpm"); + expect(out.exec).toContain("pnpm"); + expect(out.install_method).toBe("dlx-latest"); + }); + + it("detects bun from bun.lock", () => { + const dir = makeFixture("bun-fixture", { + "package.json": "{}", + "bun.lock": "", + }); + const out = runDetect({ WORKING_DIRECTORY: dir }); + expect(out.agent).toBe("bun"); + expect(out.install_method).toBe("dlx-latest"); + }); + + it("falls back to npm when no lockfile exists", () => { + const dir = makeFixture("no-lockfile-fixture", { + "package.json": "{}", + }); + const out = runDetect({ WORKING_DIRECTORY: dir }); + expect(out.agent).toBe("npm"); + expect(out.install_method).toBe("dlx-latest"); + }); + + it("uses execute-local when codemap is in devDependencies", () => { + const dir = makeFixture("dev-dep-fixture", { + "package.json": JSON.stringify({ + devDependencies: { codemap: "^1.0.0" }, + }), + "package-lock.json": "{}", + }); + const out = runDetect({ WORKING_DIRECTORY: dir }); + expect(out.agent).toBe("npm"); + expect(out.install_method).toBe("project-installed"); + expect(out.exec).toContain("codemap"); + expect(out.exec).not.toContain("codemap@"); + }); + + it("uses dlx-pinned when version input is set (overrides project install)", () => { + const dir = makeFixture("pinned-fixture", { + "package.json": JSON.stringify({ + devDependencies: { codemap: "^1.0.0" }, + }), + "package-lock.json": "{}", + }); + const out = runDetect({ WORKING_DIRECTORY: dir, VERSION: "1.2.3" }); + expect(out.install_method).toBe("dlx-pinned"); + expect(out.exec).toContain("codemap@1.2.3"); + }); + + it("respects PACKAGE_MANAGER override", () => { + const dir = makeFixture("override-fixture", { + "package.json": "{}", + "pnpm-lock.yaml": "", + }); + const out = runDetect({ WORKING_DIRECTORY: dir, PACKAGE_MANAGER: "yarn" }); + expect(out.agent).toBe("yarn"); + }); + + it("rejects unknown PACKAGE_MANAGER values", () => { + const dir = makeFixture("bad-pm-fixture", { + "package.json": "{}", + }); + const result = spawnSync("node", [SCRIPT], { + env: { + ...process.env, + GITHUB_OUTPUT: "", + WORKING_DIRECTORY: dir, + PACKAGE_MANAGER: "rye", + }, + encoding: "utf8", + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("rye"); + }); + + it("respects packageManager field over lockfile when both present", () => { + // Per `package-manager-detector` strategy order — `packageManager-field` + // wins over `lockfile`. Useful for monorepos that have a stale + // package-lock.json but officially use pnpm via corepack. + const dir = makeFixture("packageManager-field-fixture", { + "package.json": JSON.stringify({ packageManager: "pnpm@9.0.0" }), + "package-lock.json": "{}", + }); + const out = runDetect({ WORKING_DIRECTORY: dir }); + expect(out.agent).toBe("pnpm"); + }); +}); From 3409d274bc72c654985f407df6e326f68efd4055 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:22:40 +0300 Subject: [PATCH 04/10] feat(cli): codemap pr-comment + action.yml integration (Slice 3 of #73 plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 — markdown PR-summary writer for the cases SARIF→Code-Scanning doesn't cover well: private repos without GHAS, repos that haven't enabled Code Scanning, aggregate audit deltas without a single file:line anchor, and bot-context seeding (review bots read PR conversation, not workflow artifacts). Architecture (engine + CLI separation, mirrors show / impact / audit): - src/application/pr-comment-engine.ts — pure transport-agnostic renderer. Auto-detects input shape (audit envelope vs SARIF doc) + renders markdown grouped by delta key (audit) or rule id (SARIF). Lists >50 entries collapse to `… and N more`. Removed rows surface in their own collapsed section (audit only). - src/cli/cmd-pr-comment.ts — CLI wrapper. Reads JSON from a file or stdin (`-`). `--shape audit|sarif` overrides autodetection; `--json` emits structured envelope `{ markdown, findings_count, kind }` for action.yml steps. - src/cli/main.ts + src/cli/bootstrap.ts wire the new `pr-comment` verb (whitelist + dispatch). action.yml integration (Slice 2 stub replaced): - pr-comment toggle now actually invokes `codemap pr-comment` against the SARIF / JSON output file produced by the run step, then posts via `gh pr comment -F -`. Same binary that produced the output renders the comment — version stream stays coherent. Tests: - 12 new pr-comment-engine unit tests (input shape detection, no-drift / no-findings ✅ rendering, audit summary line + per-delta sections, SARIF rule grouping, location formatting, >50 collapse). - Smoke verified: real audit envelope produces markdown with file: links + delta sections; SARIF doc groups findings by rule id. Lockstep updates (per docs/README.md Rule 10): - .agents/rules/codemap.md + templates/agents/rules/codemap.md gain rows for `--ci` aggregate flag (Slice 1b) and `pr-comment` renderer (Slice 3). --- .agents/rules/codemap.md | 2 + action.yml | 19 +- src/application/pr-comment-engine.test.ts | 220 +++++++++++++++++ src/application/pr-comment-engine.ts | 280 ++++++++++++++++++++++ src/cli/bootstrap.ts | 1 + src/cli/cmd-pr-comment.ts | 225 +++++++++++++++++ src/cli/main.ts | 23 ++ templates/agents/rules/codemap.md | 2 + 8 files changed, 767 insertions(+), 5 deletions(-) create mode 100644 src/application/pr-comment-engine.test.ts create mode 100644 src/application/pr-comment-engine.ts create mode 100644 src/cli/cmd-pr-comment.ts diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md index 95884ef..d6562c9 100644 --- a/.agents/rules/codemap.md +++ b/.agents/rules/codemap.md @@ -37,6 +37,8 @@ A local database (default **`.codemap/index.db`**) indexes structure: symbols, i | Impact (blast-radius walker) | — | `bun src/index.ts impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | | Coverage ingest | — | `bun src/index.ts ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | | SARIF / GH annotations | — | `bun src/index.ts query --recipe deprecated-symbols --format sarif` · `… --format annotations` | +| `--ci` aggregate flag | — | `bun src/index.ts query -r deprecated-symbols --ci` (or `audit --base origin/main --ci`) — aliases `--format sarif` + non-zero exit when findings/additions surfaced + suppresses no-findings stderr warning. Mutually exclusive with `--json` / `--format `. | +| PR-comment renderer | — | `bun src/index.ts pr-comment ` (or `-` for stdin) — renders an audit JSON envelope or SARIF doc as a markdown PR-summary comment. Pipe to `gh pr comment -F -`. Useful for private repos without GHAS, aggregate audit deltas, or bot-context seeding. | | Mermaid graph (≤50 edges) | — | `bun src/index.ts query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | | Diff preview | — | `bun src/index.ts query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | | FTS5 full-text (opt-in) | `--with-fts` | `bun src/index.ts --with-fts --full` enables `source_fts` virtual table; `query --recipe text-in-deprecated-functions` demos JOINs. | diff --git a/action.yml b/action.yml index 5026f91..c045457 100644 --- a/action.yml +++ b/action.yml @@ -244,13 +244,22 @@ runs: - name: Post PR summary comment if: steps.gate.outputs.skip != 'true' && inputs.pr-comment == 'true' && github.event_name == 'pull_request' && always() shell: bash + working-directory: ${{ inputs.working-directory }} env: EXEC: ${{ steps.detect-pm.outputs.exec }} - OUTPUT_PATH: ${{ inputs.working-directory }}/${{ inputs.output-path }} - FORMAT: ${{ inputs.format }} + OUTPUT_PATH: ${{ inputs.output-path }} PR_NUMBER: ${{ github.event.pull_request.number }} GH_TOKEN: ${{ inputs.token }} run: | - # Slice 3 lands `codemap pr-comment` later; until then, just stub a - # short marker comment so downstream consumers can see the toggle works. - echo "::warning::codemap action: pr-comment writer (Slice 3) not yet implemented; toggle was set but no comment was posted." + # Render the markdown body via `codemap pr-comment`, then post via + # `gh pr comment`. The same binary that produced the SARIF / JSON + # output renders the comment — keeps the version stream coherent. + BODY=$($EXEC pr-comment "$OUTPUT_PATH" 2>/dev/null) || { + echo "::warning::codemap action: pr-comment renderer failed; skipping PR comment." + exit 0 + } + if [ -z "$BODY" ]; then + echo "::notice::codemap action: pr-comment produced empty body; skipping." + exit 0 + fi + echo "$BODY" | gh pr comment "$PR_NUMBER" -F - diff --git a/src/application/pr-comment-engine.test.ts b/src/application/pr-comment-engine.test.ts new file mode 100644 index 0000000..06eef9f --- /dev/null +++ b/src/application/pr-comment-engine.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "bun:test"; + +import { + detectCommentInputShape, + renderAuditComment, + renderSarifComment, +} from "./pr-comment-engine"; + +describe("detectCommentInputShape", () => { + it("identifies audit envelopes by the deltas field", () => { + expect(detectCommentInputShape({ head: {}, deltas: {} })).toBe("audit"); + }); + + it("identifies SARIF docs by the runs[] field", () => { + expect(detectCommentInputShape({ version: "2.1.0", runs: [] })).toBe( + "sarif", + ); + }); + + it("returns 'empty' for {}", () => { + expect(detectCommentInputShape({})).toBe("empty"); + }); + + it("returns 'unknown' for arbitrary objects", () => { + expect(detectCommentInputShape({ something: "else" })).toBe("unknown"); + }); + + it("returns 'unknown' for non-objects", () => { + expect(detectCommentInputShape("hello")).toBe("unknown"); + expect(detectCommentInputShape(null)).toBe("unknown"); + expect(detectCommentInputShape(42)).toBe("unknown"); + }); +}); + +describe("renderAuditComment", () => { + it("emits ✅ when no drift across deltas", () => { + const r = renderAuditComment({ + head: {}, + deltas: { + files: { base: { source: "ref", ref: "main" }, added: [], removed: [] }, + }, + }); + expect(r.findings_count).toBe(0); + expect(r.kind).toBe("audit"); + expect(r.markdown).toContain("✅"); + expect(r.markdown).toContain("No structural drift"); + }); + + it("renders summary line + per-delta sections for added rows", () => { + const r = renderAuditComment({ + head: { sha: "abc12345" }, + deltas: { + files: { + base: { source: "ref", ref: "origin/main", sha: "deadbeef0000" }, + added: [{ path: "src/new.ts" }], + removed: [], + }, + dependencies: { + base: { source: "baseline", name: "base-dependencies" }, + added: [ + { from_path: "src/a.ts", to_path: "src/b.ts" }, + { from_path: "src/c.ts", to_path: "src/d.ts" }, + ], + removed: [], + }, + }, + }); + expect(r.findings_count).toBe(3); + // Summary line surfaces non-zero deltas. + expect(r.markdown).toContain("**files**: +1 / -0"); + expect(r.markdown).toContain("**dependencies**: +2 / -0"); + // Sections per delta. + expect(r.markdown).toContain("### files"); + expect(r.markdown).toContain("### dependencies"); + // File-added row → location-only formatting. + expect(r.markdown).toContain("`src/new.ts`"); + // Dependency-added row → from → to formatting. + expect(r.markdown).toContain("`src/a.ts` → `src/b.ts`"); + // Baseline metadata visible. + expect(r.markdown).toContain("origin/main"); + expect(r.markdown).toContain("base-dependencies"); + }); + + it("collapses lists >50 rows", () => { + const added: Record[] = []; + for (let i = 0; i < 75; i++) added.push({ path: `src/f${i}.ts` }); + const r = renderAuditComment({ + head: {}, + deltas: { + files: { + base: { source: "ref", ref: "main", sha: "abc" }, + added, + removed: [], + }, + }, + }); + expect(r.findings_count).toBe(75); + expect(r.markdown).toContain("… and 25 more"); + }); + + it("includes removed rows in their own collapsed section", () => { + const r = renderAuditComment({ + head: {}, + deltas: { + deprecated: { + base: { source: "ref", ref: "main", sha: "abc" }, + added: [], + removed: [{ name: "oldFn", kind: "function", file_path: "src/x.ts" }], + }, + }, + }); + expect(r.markdown).toContain("➖ 1 removed"); + expect(r.markdown).toContain("`oldFn`"); + }); +}); + +describe("renderSarifComment", () => { + it("emits ✅ when no findings", () => { + const r = renderSarifComment({ + version: "2.1.0", + runs: [ + { + tool: { driver: { name: "codemap", rules: [] } }, + results: [], + }, + ], + }); + expect(r.findings_count).toBe(0); + expect(r.markdown).toContain("✅"); + }); + + it("groups results by ruleId in summary + sections", () => { + const r = renderSarifComment({ + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "codemap", + rules: [ + { + id: "codemap.deprecated-symbols", + name: "deprecated-symbols", + }, + { id: "codemap.untested-and-dead", name: "untested-and-dead" }, + ], + }, + }, + results: [ + { + ruleId: "codemap.deprecated-symbols", + message: { text: "oldFn is deprecated" }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/a.ts" }, + region: { startLine: 12 }, + }, + }, + ], + }, + { + ruleId: "codemap.deprecated-symbols", + message: { text: "anotherFn is deprecated" }, + }, + { + ruleId: "codemap.untested-and-dead", + message: { text: "deadFn never called" }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/b.ts" }, + }, + }, + ], + }, + ], + }, + ], + }); + expect(r.findings_count).toBe(3); + // Summary line. + expect(r.markdown).toContain("**codemap.deprecated-symbols**: 2"); + expect(r.markdown).toContain("**codemap.untested-and-dead**: 1"); + // Per-rule sections. + expect(r.markdown).toContain("### codemap.deprecated-symbols (2)"); + expect(r.markdown).toContain("### codemap.untested-and-dead (1)"); + // Result lines with location. + expect(r.markdown).toContain("`src/a.ts:12`"); + expect(r.markdown).toContain("`src/b.ts`"); + // Result without location still renders the message. + expect(r.markdown).toContain("anotherFn is deprecated"); + }); + + it("collapses results lists >50 entries per rule", () => { + const results = []; + for (let i = 0; i < 75; i++) { + results.push({ + ruleId: "codemap.bulk", + message: { text: `finding ${i}` }, + }); + } + const r = renderSarifComment({ + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "codemap", + rules: [{ id: "codemap.bulk", name: "bulk" }], + }, + }, + results, + }, + ], + }); + expect(r.findings_count).toBe(75); + expect(r.markdown).toContain("… and 25 more"); + }); +}); diff --git a/src/application/pr-comment-engine.ts b/src/application/pr-comment-engine.ts new file mode 100644 index 0000000..1b5f1c2 --- /dev/null +++ b/src/application/pr-comment-engine.ts @@ -0,0 +1,280 @@ +/** + * Pure transport-agnostic engine for `codemap pr-comment`. Takes the + * stdout of a `codemap audit --json` or `codemap query --format sarif` + * invocation and renders a markdown summary suitable for posting via + * `gh pr comment`. Designed for cases SARIF→Code-Scanning doesn't cover + * well: private repos without GHAS, repos that haven't enabled Code + * Scanning, aggregate audit deltas that lack a single `file:line` + * anchor, trend / delta narratives, and bot-context seeding (review + * bots read PR conversation, not workflow artifacts). + * + * Slice 3 of `docs/plans/github-marketplace-action.md`. v1.0 ships the + * (b) summary-comment shape; (c) inline-review comments deferred per + * Q4 resolution. + */ + +interface SarifResult { + ruleId: string; + level?: string; + message: { text: string }; + locations?: Array<{ + physicalLocation: { + artifactLocation: { uri: string }; + region?: { startLine?: number; endLine?: number }; + }; + }>; +} + +interface SarifRule { + id: string; + name: string; + shortDescription?: { text: string }; +} + +interface SarifDocument { + version: string; + runs: Array<{ + tool: { driver: { name: string; rules: SarifRule[] } }; + results: SarifResult[]; + }>; +} + +interface AuditDelta { + base: { source: string; ref?: string; sha?: string; name?: string }; + added: Record[]; + removed: Record[]; +} + +interface AuditEnvelope { + head: { sha?: string; commit?: string }; + deltas: Record; +} + +/** + * Rendered markdown payload + metadata. The markdown is the body to + * post; `findings_count` is the ≥0 aggregate count for consumers that + * want to skip the comment entirely when the PR is clean. + */ +export interface RenderedComment { + markdown: string; + findings_count: number; + /** + * `kind` reflects the input shape — useful for downstream callers + * (e.g. action.yml step that picks a comment header based on shape). + */ + kind: "audit" | "sarif" | "empty"; +} + +/** + * Detect input shape from a parsed JSON object. SARIF docs carry + * `runs[].tool.driver`; audit envelopes carry `deltas`. Empty objects + * (e.g. literal `{}`) treated as "empty" so consumers handle the + * "nothing to comment" case explicitly. + */ +export function detectCommentInputShape( + obj: unknown, +): "audit" | "sarif" | "empty" | "unknown" { + if (typeof obj !== "object" || obj === null) return "unknown"; + const o = obj as Record; + if (Array.isArray(o["runs"])) return "sarif"; + if (typeof o["deltas"] === "object" && o["deltas"] !== null) return "audit"; + if (Object.keys(o).length === 0) return "empty"; + return "unknown"; +} + +/** + * Render an audit envelope as a markdown comment. One section per delta + * with non-zero `added.length`; collapsed-section per delta with rows + * (GitHub auto-collapses `
` blocks). Removed rows surface in + * the same delta section but in a sub-list — losing a dependency or + * deprecation tag is also signal worth seeing. + */ +export function renderAuditComment(envelope: AuditEnvelope): RenderedComment { + const lines: string[] = []; + lines.push("## codemap audit"); + lines.push(""); + + const deltaEntries = Object.entries(envelope.deltas); + let totalAdded = 0; + let totalRemoved = 0; + for (const [, delta] of deltaEntries) { + totalAdded += delta.added.length; + totalRemoved += delta.removed.length; + } + + if (totalAdded === 0 && totalRemoved === 0) { + lines.push("✅ No structural drift across audited deltas."); + return { + markdown: lines.join("\n"), + findings_count: 0, + kind: "audit", + }; + } + + // Header summary line + const summaryParts = deltaEntries + .map(([key, delta]) => { + const a = delta.added.length; + const r = delta.removed.length; + if (a === 0 && r === 0) return null; + return `**${key}**: +${a} / -${r}`; + }) + .filter((s): s is string => s !== null); + lines.push(summaryParts.join(" · ")); + lines.push(""); + + for (const [key, delta] of deltaEntries) { + if (delta.added.length === 0 && delta.removed.length === 0) continue; + lines.push(`### ${key}`); + lines.push(""); + lines.push(`Baseline: ${describeBase(delta.base)}`); + lines.push(""); + if (delta.added.length > 0) { + lines.push(`
➕ ${delta.added.length} added`); + lines.push(""); + for (const row of delta.added.slice(0, 50)) { + lines.push(`- ${formatRowLine(row)}`); + } + if (delta.added.length > 50) { + lines.push(`- … and ${delta.added.length - 50} more`); + } + lines.push(""); + lines.push("
"); + lines.push(""); + } + if (delta.removed.length > 0) { + lines.push( + `
➖ ${delta.removed.length} removed`, + ); + lines.push(""); + for (const row of delta.removed.slice(0, 50)) { + lines.push(`- ${formatRowLine(row)}`); + } + if (delta.removed.length > 50) { + lines.push(`- … and ${delta.removed.length - 50} more`); + } + lines.push(""); + lines.push("
"); + lines.push(""); + } + } + + return { + markdown: lines.join("\n").trim(), + findings_count: totalAdded, + kind: "audit", + }; +} + +/** + * Render a SARIF doc as a markdown comment. Groups results by ruleId so + * agents see "5 deprecated-symbols, 12 untested-and-dead" not a flat + * list. Per-result lines link to file:line where available. + */ +export function renderSarifComment(doc: SarifDocument): RenderedComment { + const lines: string[] = []; + lines.push("## codemap findings"); + lines.push(""); + + const results = doc.runs?.[0]?.results ?? []; + if (results.length === 0) { + lines.push("✅ No findings."); + return { + markdown: lines.join("\n"), + findings_count: 0, + kind: "sarif", + }; + } + + // Group by ruleId. + const byRule = new Map(); + for (const r of results) { + const list = byRule.get(r.ruleId) ?? []; + list.push(r); + byRule.set(r.ruleId, list); + } + + // Header summary line. + const summaryParts: string[] = []; + for (const [ruleId, ruleResults] of byRule) { + summaryParts.push(`**${ruleId}**: ${ruleResults.length}`); + } + lines.push(summaryParts.join(" · ")); + lines.push(""); + + for (const [ruleId, ruleResults] of byRule) { + lines.push(`### ${ruleId} (${ruleResults.length})`); + lines.push(""); + lines.push( + `
${ruleResults.length} finding${ruleResults.length === 1 ? "" : "s"}`, + ); + lines.push(""); + for (const r of ruleResults.slice(0, 50)) { + lines.push(`- ${formatSarifLine(r)}`); + } + if (ruleResults.length > 50) { + lines.push(`- … and ${ruleResults.length - 50} more`); + } + lines.push(""); + lines.push("
"); + lines.push(""); + } + + return { + markdown: lines.join("\n").trim(), + findings_count: results.length, + kind: "sarif", + }; +} + +function describeBase(base: AuditDelta["base"]): string { + if (base.source === "ref") { + return `\`${base.ref ?? "(unknown ref)"}\` (${(base.sha ?? "").slice(0, 8)})`; + } + if (base.source === "baseline") { + return `saved baseline \`${base.name ?? "(unknown)"}\``; + } + return `\`${base.source}\``; +} + +function formatRowLine(row: Record): string { + // Prefer the most-specific identity columns first. + const path = + (row["file_path"] as string | undefined) ?? + (row["path"] as string | undefined) ?? + (row["to_path"] as string | undefined); + const fromPath = row["from_path"] as string | undefined; + const name = row["name"] as string | undefined; + const kind = row["kind"] as string | undefined; + const lineStart = row["line_start"]; + if (fromPath !== undefined && path !== undefined) { + return `\`${fromPath}\` → \`${path}\``; + } + if (path !== undefined) { + const loc = + typeof lineStart === "number" && lineStart > 0 + ? `${path}:${lineStart}` + : path; + if (name !== undefined) { + const nameLabel = + kind !== undefined ? `\`${name}\` (${kind})` : `\`${name}\``; + return `${nameLabel} — \`${loc}\``; + } + return `\`${loc}\``; + } + // Unknown shape — fall through to JSON. + return `\`${JSON.stringify(row)}\``; +} + +function formatSarifLine(r: SarifResult): string { + const loc = r.locations?.[0]?.physicalLocation; + const uri = loc?.artifactLocation?.uri; + const startLine = loc?.region?.startLine; + const where = + uri === undefined + ? "" + : startLine === undefined + ? ` — \`${uri}\`` + : ` — \`${uri}:${startLine}\``; + return `${r.message.text}${where}`; +} diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index f86e154..5621d6b 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -81,6 +81,7 @@ export function validateIndexModeArgs(rest: string[]): void { if (rest[0] === "snippet") return; if (rest[0] === "impact") return; if (rest[0] === "ingest-coverage") return; + if (rest[0] === "pr-comment") return; if (rest[0] === "agents") { if (rest[1] === "init") return; diff --git a/src/cli/cmd-pr-comment.ts b/src/cli/cmd-pr-comment.ts new file mode 100644 index 0000000..bba7641 --- /dev/null +++ b/src/cli/cmd-pr-comment.ts @@ -0,0 +1,225 @@ +import { readFileSync } from "node:fs"; + +import { + detectCommentInputShape, + renderAuditComment, + renderSarifComment, +} from "../application/pr-comment-engine"; +import { bootstrapCodemap } from "./bootstrap-codemap"; + +interface PrCommentOpts { + root: string; + configFile: string | undefined; + stateDir?: string | undefined; + /** Path to a JSON file. `-` reads stdin. */ + inputPath: string; + /** + * Override automatic shape detection. By default we sniff `runs[]` + * vs `deltas` in the parsed payload; explicit override is for + * downstream callers that already know the shape. + */ + shape: "audit" | "sarif" | undefined; + /** Emit JSON envelope instead of bare markdown. Useful for action.yml. */ + json: boolean; +} + +/** + * Print `codemap pr-comment` usage. + */ +export function printPrCommentCmdHelp(): void { + console.log(`Usage: codemap pr-comment [--shape audit|sarif] [--json] + +Render a markdown PR-summary comment from a codemap audit JSON envelope +or a SARIF document. Designed for the cases SARIF→Code-Scanning doesn't +cover well: private repos without GHAS, repos that haven't enabled Code +Scanning, aggregate audit deltas without a single file:line anchor, and +bot-context seeding (review bots read PR conversation, not workflow +artifacts). + +Args: + Path to the JSON file. Use \`-\` to read from stdin. + +Flags: + --shape Override automatic shape detection. \`audit\` for + codemap-audit-JSON envelopes; \`sarif\` for SARIF + 2.1.0 docs. Default: detect from payload. + --json Emit JSON envelope { markdown, findings_count, kind } + instead of bare markdown. Useful for action.yml + steps that want structured access to findings_count. + --help, -h Show this help. + +Examples: + + # Audit envelope from \`codemap audit --base origin/main --json\` + codemap audit --base origin/main --json > audit.json + codemap pr-comment audit.json | gh pr comment -F - + + # SARIF doc from \`codemap query --recipe deprecated-symbols --format sarif\` + codemap query -r deprecated-symbols --format sarif > findings.sarif + codemap pr-comment findings.sarif --json + + # Pipe via stdin (avoids the temp file) + codemap audit --base origin/main --json | codemap pr-comment - +`); +} + +export interface ParsedPrCommentRest { + kind: "run" | "help" | "error"; + message?: string; + inputPath?: string; + shape?: "audit" | "sarif" | undefined; + json?: boolean; +} + +export function parsePrCommentRest(rest: string[]): ParsedPrCommentRest { + if (rest[0] !== "pr-comment") { + throw new Error("parsePrCommentRest: expected pr-comment"); + } + let inputPath: string | undefined; + let shape: "audit" | "sarif" | undefined; + let json = false; + let i = 1; + while (i < rest.length) { + const a = rest[i]; + if (a === "--help" || a === "-h") return { kind: "help" }; + if (a === "--json") { + json = true; + i++; + continue; + } + if (a === "--shape" || a.startsWith("--shape=")) { + const eq = a.indexOf("="); + const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1]; + if (v === undefined || v.startsWith("-")) { + return { + kind: "error", + message: 'codemap pr-comment: --shape requires "audit" or "sarif".', + }; + } + if (v !== "audit" && v !== "sarif") { + return { + kind: "error", + message: `codemap pr-comment: unknown --shape "${v}". Expected "audit" or "sarif".`, + }; + } + shape = v; + i += eq !== -1 ? 1 : 2; + continue; + } + if (a.startsWith("--")) { + return { + kind: "error", + message: `codemap pr-comment: unknown option "${a}".`, + }; + } + if (inputPath !== undefined) { + return { + kind: "error", + message: `codemap pr-comment: unexpected extra argument "${a}". Pass exactly one input path (or "-" for stdin).`, + }; + } + inputPath = a; + i++; + } + if (inputPath === undefined) { + return { + kind: "error", + message: + 'codemap pr-comment: missing argument. Pass a path or "-" for stdin.', + }; + } + return { kind: "run", inputPath, shape, json }; +} + +/** + * Initialize Codemap (so error envelopes route through the same + * `bootstrapCodemap` machinery), read the input file, render markdown. + */ +export async function runPrCommentCmd(opts: PrCommentOpts): Promise { + try { + await bootstrapCodemap(opts); + + const raw = + opts.inputPath === "-" + ? readStdinSync() + : readFileSync(opts.inputPath, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + emitPrCommentError( + `failed to parse JSON from ${opts.inputPath === "-" ? "stdin" : opts.inputPath}: ${err instanceof Error ? err.message : String(err)}`, + opts.json, + ); + return; + } + + const detected = opts.shape ?? detectCommentInputShape(parsed); + if (detected === "unknown") { + emitPrCommentError( + "could not detect input shape (no `runs[]` or `deltas` field). Pass --shape audit|sarif to override.", + opts.json, + ); + return; + } + if (detected === "empty") { + const out = { + markdown: "## codemap\n\n_No data._", + findings_count: 0, + kind: "empty" as const, + }; + if (opts.json) console.log(JSON.stringify(out)); + else console.log(out.markdown); + return; + } + + // detected here is "audit" | "sarif" + const rendered = + detected === "audit" + ? renderAuditComment(parsed as Parameters[0]) + : renderSarifComment( + parsed as Parameters[0], + ); + + if (opts.json) { + console.log(JSON.stringify(rendered)); + } else { + console.log(rendered.markdown); + } + } catch (err) { + emitPrCommentError( + err instanceof Error ? err.message : String(err), + opts.json, + ); + } +} + +function emitPrCommentError(message: string, json: boolean) { + if (json) { + console.log(JSON.stringify({ error: message })); + } else { + console.error(`codemap pr-comment: ${message}`); + } + process.exitCode = 1; +} + +/** + * Synchronous stdin reader. Bun + Node both support `node:fs` reading + * from fd 0, but it can return EAGAIN on a TTY. We loop until EOF. + */ +function readStdinSync(): string { + const chunks: Buffer[] = []; + const buffer = Buffer.alloc(4096); + // eslint-disable-next-line no-constant-condition + while (true) { + let n: number; + try { + n = require("node:fs").readSync(0, buffer, 0, buffer.length, null); + } catch { + break; + } + if (n === 0) break; + chunks.push(Buffer.from(buffer.slice(0, n))); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index ca27d7b..7ae179a 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -282,6 +282,29 @@ Copies bundled agent templates into .agents/ under the project root. return; } + if (rest[0] === "pr-comment") { + const { parsePrCommentRest, printPrCommentCmdHelp, runPrCommentCmd } = + await import("./cmd-pr-comment.js"); + const parsed = parsePrCommentRest(rest); + if (parsed.kind === "help") { + printPrCommentCmdHelp(); + return; + } + if (parsed.kind === "error") { + console.error(parsed.message); + process.exit(1); + } + await runPrCommentCmd({ + root, + configFile, + stateDir, + inputPath: parsed.inputPath as string, + shape: parsed.shape, + json: parsed.json === true, + }); + return; + } + if (rest[0] === "ingest-coverage") { const { parseIngestCoverageRest, diff --git a/templates/agents/rules/codemap.md b/templates/agents/rules/codemap.md index be44d17..a795ef7 100644 --- a/templates/agents/rules/codemap.md +++ b/templates/agents/rules/codemap.md @@ -42,6 +42,8 @@ Install **[@stainless-code/codemap](https://www.npmjs.com/package/@stainless-cod | Impact (blast-radius walker) | `codemap impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | | Coverage ingest | `codemap ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | | SARIF / GH annotations | `codemap query --recipe deprecated-symbols --format sarif` · `… --format annotations` | +| `--ci` aggregate flag | `codemap query -r deprecated-symbols --ci` (or `audit --base origin/main --ci`) — aliases `--format sarif` + non-zero exit when findings/additions surfaced + suppresses no-findings stderr warning. Mutually exclusive with `--json` / `--format `. | +| PR-comment renderer | `codemap pr-comment ` (or `-` for stdin) — renders an audit JSON envelope or SARIF doc as a markdown PR-summary comment. Pipe to `gh pr comment -F -`. Useful for private repos without GHAS, aggregate audit deltas, or bot-context seeding. | | Mermaid graph (≤50 edges) | `codemap query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | | Diff preview | `codemap query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | | FTS5 full-text (opt-in) | `codemap --with-fts --full` enables `source_fts` virtual table; `query --recipe text-in-deprecated-functions` demos JOINs. | From 08dec0020d547e2c0c24bca17ab52a373e21f6c7 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:25:19 +0300 Subject: [PATCH 05/10] ci(action): dogfood action-smoke job + bundle changeset (Slice 4 of #73 plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `action-smoke` job to .github/workflows/ci.yml that runs `uses: ./` from this repo on every PR. Validates: - action.yml YAML syntax + composite-step flow - gate step (skip-on-non-PR; command-set-overrides-skip) - setup-node + npm install of package-manager-detector - scripts/detect-pm.mjs execution + GITHUB_OUTPUT writes - detect-pm command resolution (` dlx codemap@latest --version`) Smoke uses `command: --version` to avoid the real-audit dependency chain (audit baselines etc.) — codemap audit logic is covered by the unit-test suite. The action-smoke validates the wrapper, not the underlying CLI. Non-blocking (`continue-on-error: true`) until v1.0.0 of codemap is published — today the action pulls codemap@latest from npm (0.4.0), which works for `--version` but doesn't validate v1.x flags. Promote to a hard gate when v1.0.0 ships. Bundles the v1.0 changeset: - .changeset/marketplace-action.md describes the full Slice 1b-4 surface (--ci flag, action.yml, pr-comment, dogfood) as one minor release. Slice 5 (Marketplace publish + listing metadata) is post-merge once a v1.0.0 tag exists. --- .changeset/marketplace-action.md | 19 +++++++++++++++++++ .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .changeset/marketplace-action.md diff --git a/.changeset/marketplace-action.md b/.changeset/marketplace-action.md new file mode 100644 index 0000000..b0ddca9 --- /dev/null +++ b/.changeset/marketplace-action.md @@ -0,0 +1,19 @@ +--- +"@stainless-code/codemap": minor +--- + +GitHub Marketplace Action — Slices 1b-4 of [`docs/plans/github-marketplace-action.md`](../docs/plans/github-marketplace-action.md). v1.0 readiness; `action.yml` is now installable via `- uses: stainless-code/codemap@v1` once the corresponding tag is published. + +**`--ci` aggregate flag (Slice 1b)** on `query` + `audit`. Aliases `--format sarif` + `process.exitCode = 1` on findings/additions + suppresses no-locatable-rows stderr warning. Mutually exclusive with `--json` and `--format `. Parser rejects contradictions with helpful errors. + +**`action.yml` + `scripts/detect-pm.mjs` (Slice 2).** Composite Action wrapping the codemap CLI. ~16 declarative inputs across 3 categories (where to run / what to run / what to do with output); Q1 resolution. Default α command on `pull_request` events: `audit --base ${{ github.base_ref }} --ci`; no-op on other events unless an explicit `command:` input is passed. Package-manager autodetection delegates to [`package-manager-detector`](https://github.com/antfu-collective/package-manager-detector) (antfu/userquin, MIT, 0 transitive deps); CLI invocation resolution via the library's `'execute-local'` / `'execute'` intents. + +**`codemap pr-comment` (Slice 3).** New CLI verb that renders a markdown PR-summary comment from a codemap-audit-JSON envelope or a SARIF doc. Auto-detects input shape; `--shape audit|sarif` overrides. Reads from a file or stdin (`-`). `--json` envelope emits `{ markdown, findings_count, kind }` for action.yml steps. Closes the SARIF→Code-Scanning gap for: private repos without GHAS, repos that haven't enabled Code Scanning, aggregate audit deltas without a single file:line anchor, trend / delta narratives, and bot-context seeding (review bots read PR conversation, not workflow artifacts). v1.0 ships the (b) summary-comment shape per Q4 resolution; (c) inline-review comments deferred to v1.x. + +**Dogfood (Slice 4).** New `action-smoke` job in `.github/workflows/ci.yml` runs `uses: ./` on every PR with `command: --version` to validate the composite-step flow + npm-pulled codemap binary. Non-blocking until v1.0.0 ships (at which point the smoke gates the build). + +**Engine + CLI separation discipline preserved:** `pr-comment-engine.ts` is pure; `cmd-pr-comment.ts` wraps it. Tests cover the engine (12 cases) and the CLI parser (4 audit + 4 query tests for `--ci`). + +**Lockstep agent updates** (per `docs/README.md` Rule 10): `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` gain rows for `--ci` and `pr-comment` so installed agents and this clone's session view stay in lockstep. + +Slice 5 (Marketplace publish + listing metadata) is post-merge — gated on a v1.0.0 tag. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32e1e4c..116dac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,35 @@ jobs: - name: bun audit run: bun audit + action-smoke: + # Dogfood Slice 4 of plan PR #73: invoke `uses: ./` from this very repo + # so the action.yml + scripts/detect-pm.mjs are exercised on every PR. + # Smoke uses `command: --version` to avoid the real-audit dependency + # chain (audit baselines etc.) — this validates the composite-step + # flow + npm-pulled codemap binary, not the audit logic itself + # (which is covered by the unit-test suite). + name: 🤖 Action smoke (dogfood) + needs: skip-ci + if: needs['skip-ci'].outputs.skip != 'true' + runs-on: ubuntu-latest + # Non-blocking until we've published codemap@ matching the Action. + # Today the Action pulls codemap@latest from npm (0.4.0), which works + # for `--version` but doesn't validate v1.x flags. Promote to a hard + # gate when v1.0.0 ships. + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run the local-path action + uses: ./ + with: + command: "--version" + format: "json" + upload-sarif: "false" + pr-comment: "false" + fail-on: "never" + ci-complete: name: CI complete needs: [skip-ci, format, lint, typecheck, test, build, benchmark] From e35f82060784b896f52620952e98df1af7b091bd Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:37:41 +0300 Subject: [PATCH 06/10] chore: slim new comments + sweep docs staleness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per .agents/rules/concise-comments.md — re-read every comment authored in this PR and slimmed the ones the code itself already conveys. Net: +68 / -163 lines of comments / docstrings; same information density, fewer words. Cuts/slims (in order of file): - output-formatters.ts: AuditSarifDelta + formatAuditSarif JSDoc; the message-fallback inline comment. - cmd-audit.ts: AUDIT_OUTPUT_FORMATS doc-block; jsonShortcut / ci / Reconcile / --ci-exit / emitAuditError comments. - cmd-query.ts: --ci variable + resolveFormat doc; printFormattedQuery's ci option doc; "no-locatable-rows warning" inline; runQueryCmd's ci option JSDoc; parseQueryRest's ci field JSDoc. - pr-comment-engine.ts: file header; RenderedComment JSDoc; "kind" field doc; detectCommentInputShape doc; renderAuditComment + renderSarifComment JSDocs; "Header summary line" / "Group by ruleId" / "Prefer the most-specific identity columns first." / "Unknown shape — fall through to JSON." inline comments. - cmd-pr-comment.ts: PrCommentOpts.shape doc; printPrCommentCmdHelp JSDoc; runPrCommentCmd JSDoc; "detected here is …" inline; readStdinSync gotcha doc. - scripts/detect-pm.mjs: file header; "Step 1/2/3:" labels; "Local / non-Actions invocation —" comment; codemapInDevDependencies JSDoc. - action.yml: "Build args based on inputs" inline. Docs staleness sweep — fact-checked against the codebase: - docs/architecture.md § "Audit wiring" listed audit's flags but pre-dated --format sarif + --ci. Added both + the formatAuditSarif shape note. - docs/architecture.md § "Output formatters" missed formatAuditSarif. Added (one rule per delta key, severity warning, removed-rows excluded, location-only fallback). - docs/architecture.md § "Query wiring" missed --ci. Added. - docs/architecture.md added a new "PR-comment wiring" section (mirrors the cmd ↔ engine seam pattern). - docs/glossary.md added `--ci` (under C) and `pr-comment` (under P) entries per Rule 9. - README.md CLI examples updated with `audit --format sarif`, `audit --ci`, `query --ci`, and the `pr-comment` pipe-to-`gh pr comment` idiom. Pre-existing comments (preserve-comments rule) untouched. --- README.md | 6 ++++ action.yml | 1 - docs/architecture.md | 8 +++-- docs/glossary.md | 8 +++++ scripts/detect-pm.mjs | 35 ++++++------------ src/application/output-formatters.ts | 27 +++++--------- src/application/pr-comment-engine.ts | 53 ++++++---------------------- src/cli/cmd-audit.ts | 34 +++++------------- src/cli/cmd-pr-comment.ts | 21 ++--------- src/cli/cmd-query.ts | 38 +++++--------------- 10 files changed, 68 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 6a44c06..e62f56c 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ codemap audit --files-baseline base-files # explicit per-d codemap audit --baseline base --files-baseline hotfix-files # mixed — auto-resolve deps + deprecated; override files codemap audit --baseline base --no-index # skip the auto-incremental-index prelude (frozen-DB CI) codemap audit --base origin/main --json # ad-hoc — worktree+reindex against any committish; no --save-baseline needed +codemap audit --base origin/main --format sarif # emit SARIF 2.1.0 directly (Code Scanning); also: --ci alias +codemap audit --base origin/main --ci # CI shortcut: --format sarif + non-zero exit on additions + quiet codemap audit --base v1.0.0 --files-baseline pre-release-files # mix --base with per-delta override # --base materialises via `git worktree add` to .codemap/audit-cache//, reindexes into # a temp DB, then diffs. Cache hit on second run against same sha is sub-100ms. Requires git; @@ -127,7 +129,11 @@ codemap audit --base v1.0.0 --files-baseline pre-release-files # mix --base wit # 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 +# 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 - codemap query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50' codemap query --format diff 'SELECT "README.md" AS file_path, 1 AS line_start, "# Codemap" AS before_pattern, "# Codemap Preview" AS after_pattern' codemap query --format diff-json 'SELECT "README.md" AS file_path, 1 AS line_start, "# Codemap" AS before_pattern, "# Codemap Preview" AS after_pattern' | jq '.summary' diff --git a/action.yml b/action.yml index c045457..cb5debc 100644 --- a/action.yml +++ b/action.yml @@ -203,7 +203,6 @@ runs: run: | set +e - # Build args based on inputs (or use raw command). if [ -n "$COMMAND" ]; then ARGS="$COMMAND" elif [ "$MODE" = "audit" ]; then diff --git a/docs/architecture.md b/docs/architecture.md index 783c3ed..8259723 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,13 +117,15 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store **Commands and flags** (index, query, **`codemap agents init`**, **`--root`**, **`--config`**, environment): [../README.md § CLI](../README.md#cli) — **do not duplicate** flag lists here; this section only adds implementation notes. From this repository: **`bun run dev`** or **`bun src/index.ts`** (same flags). -**Query wiring:** **`src/cli/cmd-query.ts`** (argv, **`printQueryResult`**, `--recipe` / `-r` alias, **`--summary`**, **`--changed-since`**, **`--group-by`**, **`--save-baseline`** / **`--baseline`** / **`--baselines`** / **`--drop-baseline`**), **`src/application/query-recipes.ts`** (**`QUERY_RECIPES`** — bundled SQL only source; 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 `.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)). +**Query wiring:** **`src/cli/cmd-query.ts`** (argv, **`printQueryResult`**, `--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`** — bundled SQL only source; 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 `.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); **`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); **`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. **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. -**Audit wiring:** **`src/cli/cmd-audit.ts`** (argv, `--baseline ` auto-resolve sugar, `---baseline ` per-delta explicit overrides, `--base ` git-ref baseline, `--json`, `--summary`, `--no-index`) + **`src/application/audit-engine.ts`** (delta registry + diff). Mirrors the `cmd-index.ts ↔ application/index-engine.ts` seam — CLI parses + dispatches; engine does the diff. **`runAudit({db, baselines})`** iterates the per-delta baseline map; deltas absent from the map don't run. Each entry in **`V1_DELTAS`** pins a canonical SQL projection (`files`: `SELECT path FROM files`; `dependencies`: `SELECT from_path, to_path FROM dependencies`; `deprecated`: `SELECT name, kind, file_path FROM symbols WHERE doc_comment LIKE '%@deprecated%'`) plus a `requiredColumns` list. **`computeDelta`** validates baseline column-set membership, projects baseline rows down to the canonical column subset (extras dropped — schema-drift-resilient), runs the canonical SQL via the caller's DB connection, and set-diffs via the existing **`src/diff-rows.ts`** multiset helper (shared with `query --baseline`). Each emitted delta carries its own **`base`** metadata so mixed-baseline audits (e.g. `--baseline base --dependencies-baseline override`) are first-class. **`runAuditCmd`** runs an auto-incremental-index prelude (`runCodemapIndex({mode: "incremental", quiet: true})`) before the diff so `head` reflects the current source — `--no-index` opts out for frozen-DB CI scenarios. **`resolveAuditBaselines({db, baselinePrefix, perDelta})`** composes the baseline map: auto-resolves `-` for slots that exist (silently absent otherwise) and lets per-delta flags override individual slots. v1 ships no `verdict` / threshold config / non-zero exit codes — consumers compose `--json` + `jq` for CI exit codes; v1.x still tracks `verdict` + an `audit` field on the config object (`.codemap/config.{ts,js,json}`) thresholds. **`--base ` (shipped):** **`runAuditFromRef({db, ref, perDeltaOverrides, projectRoot, reindex})`** materialises the ref via **`application/audit-worktree.ts`** — `git rev-parse --verify "^{commit}"` → resolved sha → cache lookup at `/.codemap/audit-cache//`. Cache miss: per-pid temp dir (`.tmp...`) gets `git worktree add --detach`, the injected `reindex` callback (`makeWorktreeReindex` in production — re-inits the runtime singletons against the worktree path, runs `runCodemapIndex({mode: "full"})`, restores) writes `.codemap/index.db` inside, then POSIX `rename` claims the final `/` slot. **Atomic populate** — concurrent processes resolving the same sha race-safely without lock files (loser's rename fails with EEXIST → falls through to cache hit). Eviction: hardcoded LRU 5 entries / 500 MiB; `git worktree remove --force` then `rm -rf` for each victim; orphan `.tmp.*` dirs older than 10 min get swept too. Per-delta `base` metadata gains a discriminator: existing baseline-source remains `{source: "baseline", name, sha, indexed_at}`; new ref-source is `{source: "ref", ref, sha, indexed_at}`. `--base` is mutually exclusive with `--baseline ` (parser + handler both guard); composes orthogonally with per-delta `---baseline name` overrides. Hard error on non-git projects (`existsSync(/.git)` check before any spawn). All git spawns in `audit-worktree.ts` strip inherited `GIT_*` env vars so a containing git operation (e.g. running codemap inside a husky hook) doesn't route worktree calls at the wrong index. +**Audit wiring:** **`src/cli/cmd-audit.ts`** (argv, `--baseline ` auto-resolve sugar, `---baseline ` per-delta explicit overrides, `--base ` git-ref baseline, `--format `, `--json` (= `--format json` shortcut), `--ci` (aliases `--format sarif` + non-zero exit on additions + quiet), `--summary`, `--no-index`) + **`src/application/audit-engine.ts`** (delta registry + diff). SARIF emit goes through `output-formatters.ts`'s `formatAuditSarif` — one rule per delta key (`codemap.audit.-added`), one result per `added` row at severity `warning`. Mirrors the `cmd-index.ts ↔ application/index-engine.ts` seam — CLI parses + dispatches; engine does the diff. **`runAudit({db, baselines})`** iterates the per-delta baseline map; deltas absent from the map don't run. Each entry in **`V1_DELTAS`** pins a canonical SQL projection (`files`: `SELECT path FROM files`; `dependencies`: `SELECT from_path, to_path FROM dependencies`; `deprecated`: `SELECT name, kind, file_path FROM symbols WHERE doc_comment LIKE '%@deprecated%'`) plus a `requiredColumns` list. **`computeDelta`** validates baseline column-set membership, projects baseline rows down to the canonical column subset (extras dropped — schema-drift-resilient), runs the canonical SQL via the caller's DB connection, and set-diffs via the existing **`src/diff-rows.ts`** multiset helper (shared with `query --baseline`). Each emitted delta carries its own **`base`** metadata so mixed-baseline audits (e.g. `--baseline base --dependencies-baseline override`) are first-class. **`runAuditCmd`** runs an auto-incremental-index prelude (`runCodemapIndex({mode: "incremental", quiet: true})`) before the diff so `head` reflects the current source — `--no-index` opts out for frozen-DB CI scenarios. **`resolveAuditBaselines({db, baselinePrefix, perDelta})`** composes the baseline map: auto-resolves `-` for slots that exist (silently absent otherwise) and lets per-delta flags override individual slots. v1 ships no `verdict` / threshold config / non-zero exit codes — consumers compose `--json` + `jq` for CI exit codes; v1.x still tracks `verdict` + an `audit` field on the config object (`.codemap/config.{ts,js,json}`) thresholds. **`--base ` (shipped):** **`runAuditFromRef({db, ref, perDeltaOverrides, projectRoot, reindex})`** materialises the ref via **`application/audit-worktree.ts`** — `git rev-parse --verify "^{commit}"` → resolved sha → cache lookup at `/.codemap/audit-cache//`. Cache miss: per-pid temp dir (`.tmp...`) gets `git worktree add --detach`, the injected `reindex` callback (`makeWorktreeReindex` in production — re-inits the runtime singletons against the worktree path, runs `runCodemapIndex({mode: "full"})`, restores) writes `.codemap/index.db` inside, then POSIX `rename` claims the final `/` slot. **Atomic populate** — concurrent processes resolving the same sha race-safely without lock files (loser's rename fails with EEXIST → falls through to cache hit). Eviction: hardcoded LRU 5 entries / 500 MiB; `git worktree remove --force` then `rm -rf` for each victim; orphan `.tmp.*` dirs older than 10 min get swept too. Per-delta `base` metadata gains a discriminator: existing baseline-source remains `{source: "baseline", name, sha, indexed_at}`; new ref-source is `{source: "ref", ref, sha, indexed_at}`. `--base` is mutually exclusive with `--baseline ` (parser + handler both guard); composes orthogonally with per-delta `---baseline name` overrides. Hard error on non-git projects (`existsSync(/.git)` check before any spawn). All git spawns in `audit-worktree.ts` strip inherited `GIT_*` env vars so a containing git operation (e.g. running codemap inside a husky hook) doesn't route worktree calls at the wrong index. + +**PR-comment wiring:** **`src/cli/cmd-pr-comment.ts`** (argv — `` (or `-` for stdin) + `--shape audit|sarif` + `--json`) + **`src/application/pr-comment-engine.ts`** (engine — `renderAuditComment` / `renderSarifComment` / `detectCommentInputShape`). Renders an audit-JSON envelope or SARIF doc as a markdown PR-summary comment; designed for surfaces SARIF→Code-Scanning doesn't cover (private repos without GHAS, aggregate audit deltas without `file:line` anchors, bot-context seeding). Output: bare markdown by default; `--json` envelope `{markdown, findings_count, kind}` for action.yml steps. Audit-mode groups by delta with `
` sections (added + removed); SARIF-mode groups by `ruleId`. Lists >50 entries collapse to `… and N more`. v1.0 ships the (b) summary-comment shape; (c) inline-review comments deferred per Q4 of [`plans/github-marketplace-action.md`](./plans/github-marketplace-action.md). **Context wiring:** **`src/cli/cmd-context.ts`** (argv + render) + **`src/application/context-engine.ts`** (engine — **`buildContextEnvelope`**, **`classifyIntent`**, `ContextEnvelope` type). `buildContextEnvelope` composes the JSON envelope from existing recipes (`fan-in` for `hubs`, `markers` SELECT for `sample_markers`, `QUERY_RECIPES` map for the catalog). **`classifyIntent`** maps `--for ""` to one of `refactor | debug | test | feature | explore | other` via regex against the trimmed input; whitespace-only intents are rejected. `--compact` drops `hubs` + `sample_markers` and emits one-line JSON; otherwise pretty-prints with 2-space indent. diff --git a/docs/glossary.md b/docs/glossary.md index 047ab39..8db92cd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -95,6 +95,10 @@ Codemap-managed `.gitignore` inside `/` (blacklist of generated artif CLI subcommand emitting a JSON envelope (`ContextEnvelope`) with project metadata, top hubs, sample markers, recipe catalog, and optional intent classification via `--for ""`. +### `--ci` (CLI flag) + +CI-aggregate flag on `codemap query` and `codemap audit`. Aliases `--format sarif` + `process.exitCode = 1` on findings/additions + suppresses the no-locatable-rows stderr warning (CI templates would surface it as red noise; the row-set is the gating signal). Mutually exclusive with `--json` (different format aliases) and with `--format ` (contradicts the alias); `--ci --format sarif` redundant but accepted. Designed for the GitHub Marketplace Action's headline default (`audit --base ${{ github.base_ref }} --ci`); independently useful for any non-Action CI consumer. + ### `codemap validate` CLI subcommand comparing on-disk SHA-256 against `files.content_hash`. Statuses: `stale | missing | unindexed`. Exits `1` on any drift. @@ -358,6 +362,10 @@ Code that turns source bytes into structured rows. Three implementations: `parse A `docs/plans/.md` file tracking in-flight work. Created on commit; deleted when the feature ships per [README § Rule 3](./README.md#rules-for-agents). +### `pr-comment` (CLI verb) + +Markdown PR-summary renderer. `codemap pr-comment ` (or `-` for stdin) reads a `codemap audit --json` envelope or a `codemap query --format sarif` doc and emits a markdown comment suitable for `gh pr comment -F -`. Auto-detects shape via `runs[]` (SARIF) vs `deltas` (audit); `--shape audit|sarif` overrides. Audit-mode groups by delta with collapsed `
` for added + removed rows; SARIF-mode groups by `ruleId`. Lists >50 entries collapse to `… and N more`. `--json` envelope `{markdown, findings_count, kind}` is the structured form action.yml consumers read. Targets the surfaces SARIF → Code Scanning doesn't cover (private repos without GHAS, aggregate audit deltas without `file:line` anchors, bot-context seeding). v1.0 ships the (b) summary-comment shape; (c) inline-review comments deferred per Q4 of [`plans/github-marketplace-action.md`](./plans/github-marketplace-action.md). Engine: `application/pr-comment-engine.ts` (pure transport-agnostic). + ### pointer file A managed root-level file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md`) with a `` / `` section. Written by `codemap agents init`. See [agents.md § Pointer files](./agents.md#pointer-files). diff --git a/scripts/detect-pm.mjs b/scripts/detect-pm.mjs index fee86f1..e7e8962 100644 --- a/scripts/detect-pm.mjs +++ b/scripts/detect-pm.mjs @@ -1,24 +1,14 @@ #!/usr/bin/env node /** - * Action pre-step: detect the host project's package manager + resolve the - * codemap CLI invocation. Wraps `package-manager-detector` (antfu/userquin, - * MIT, 0 transitive deps). Outputs are written to `$GITHUB_OUTPUT` per - * GitHub Actions' current convention (`::set-output` was deprecated 2022-10). + * Action pre-step. Resolves package manager + codemap CLI invocation; + * writes to `$GITHUB_OUTPUT` (`::set-output` deprecated 2022-10). * - * Inputs (env, all optional): - * PACKAGE_MANAGER Override autodetect with explicit `npm|pnpm|yarn|bun`. - * VERSION Pin codemap CLI version (e.g. `1.2.3`). - * Empty → use project-installed binary if present, - * else fall back to ` dlx codemap@latest`. - * WORKING_DIRECTORY Where to start the lockfile + package.json walk. - * Defaults to process.cwd() (the runner's repo root). + * Env contract: + * PACKAGE_MANAGER Override autodetect (npm|pnpm|yarn|yarn@berry|bun). + * VERSION Pin codemap CLI version; empty → project-installed → dlx-latest. + * WORKING_DIRECTORY Lockfile + package.json walk root (default cwd). * - * Outputs (written to $GITHUB_OUTPUT): - * agent Resolved package manager (`npm` / `pnpm` / `yarn` / `bun`). - * exec Shell-ready command to run codemap (e.g. - * `pnpm exec codemap` or `pnpm dlx codemap@1.2.3`). - * install_method `project-installed` | `dlx-pinned` | `dlx-latest` - * (debug breadcrumb; surfaces in Action logs). + * Outputs: `agent` / `exec` (shell-ready) / `install_method` (debug breadcrumb). * * Q2 + Q3 of docs/plans/github-marketplace-action.md. */ @@ -38,7 +28,6 @@ async function main() { const workingDir = (process.env["WORKING_DIRECTORY"] ?? "").trim() || process.cwd(); - // Step 1: resolve the agent. let agent; if (explicitAgent !== "") { if (!VALID_AGENTS.has(explicitAgent)) { @@ -52,7 +41,7 @@ async function main() { agent = detected?.agent ?? "npm"; } - // Step 2: resolve the CLI invocation per Q3's three-branch logic. + // Per Q3's three-branch resolution (docs/plans/github-marketplace-action.md). let intent; let commandArgs; let installMethod; @@ -79,10 +68,9 @@ async function main() { const { command, args } = resolved; const exec = [command, ...args].join(" "); - // Step 3: write to $GITHUB_OUTPUT. const outputFile = process.env["GITHUB_OUTPUT"]; if (outputFile === undefined || outputFile === "") { - // Local / non-Actions invocation — print to stdout for inspection. + // Local / non-Actions invocation: dump to stdout. console.log(`agent=${agent}`); console.log(`exec=${exec}`); console.log(`install_method=${installMethod}`); @@ -94,10 +82,7 @@ async function main() { ); } -/** - * Read `/package.json` and check whether `codemap` is a - * direct dependency. Returns `false` on read errors / missing manifest. - */ +/** False on read errors / missing manifest. */ function codemapInDevDependencies(workingDir) { try { const manifestPath = join(workingDir, "package.json"); diff --git a/src/application/output-formatters.ts b/src/application/output-formatters.ts index 84c0305..2a38939 100644 --- a/src/application/output-formatters.ts +++ b/src/application/output-formatters.ts @@ -167,27 +167,18 @@ export function formatSarif(opts: FormatOpts): string { return JSON.stringify(sarif, null, 2); } -/** - * One delta's added rows + the delta key (`files` / `dependencies` / - * `deprecated` in v1; arbitrary string for forward-compat). Removed rows - * are intentionally excluded — SARIF is for findings to act on, not - * cleanups. - */ +/** Removed rows intentionally excluded — SARIF surfaces findings to act on, not cleanups. */ export interface AuditSarifDelta { key: string; added: Record[]; } /** - * Format an audit envelope as a SARIF 2.1.0 document. One rule per delta - * key (id `codemap.audit.-added`); one result per `added` row across - * all deltas. Severity = `warning` (audit deltas are more actionable than - * a single recipe finding — a new dependency edge or `@deprecated` symbol - * landing in a PR is a structural change worth flagging). Locations - * auto-detected via {@link detectLocationColumn} from the existing - * priority list (`file_path` / `path` / `to_path` / `from_path`); aggregate - * rows without a location column emit a result with no `locations` field - * (SARIF spec allows this). + * One rule per delta key (id `codemap.audit.-added`); one result per + * `added` row. Severity = `warning` (more actionable than per-recipe `note` + * — a new dependency edge in a PR is a structural change). Locations + * auto-detected via {@link detectLocationColumn}; aggregate rows without + * a location field omit `locations` per SARIF spec. */ export function formatAuditSarif(deltas: AuditSarifDelta[]): string { const rules = deltas.map((d) => ({ @@ -201,10 +192,8 @@ export function formatAuditSarif(deltas: AuditSarifDelta[]): string { d.added.map((row) => { const ruleId = `codemap.audit.${d.key}-added`; const locCol = detectLocationColumn(row); - // For audit deltas, location-only rows (e.g. files: `{path: "..."}`) - // produce "(no message)" via the generic builder because every column - // sits in the location-skip set. Fall back to a message that names the - // delta + URI so SARIF consumers see something actionable. + // Files-added rows have only `path` (in the skip-set), so + // buildMessageText returns "(no message)". Fall back to "new : ". const builtText = buildMessageText(row); const messageText = builtText === "(no message)" && locCol !== null diff --git a/src/application/pr-comment-engine.ts b/src/application/pr-comment-engine.ts index 1b5f1c2..fa484f0 100644 --- a/src/application/pr-comment-engine.ts +++ b/src/application/pr-comment-engine.ts @@ -1,16 +1,10 @@ /** - * Pure transport-agnostic engine for `codemap pr-comment`. Takes the - * stdout of a `codemap audit --json` or `codemap query --format sarif` - * invocation and renders a markdown summary suitable for posting via - * `gh pr comment`. Designed for cases SARIF→Code-Scanning doesn't cover - * well: private repos without GHAS, repos that haven't enabled Code - * Scanning, aggregate audit deltas that lack a single `file:line` - * anchor, trend / delta narratives, and bot-context seeding (review - * bots read PR conversation, not workflow artifacts). - * - * Slice 3 of `docs/plans/github-marketplace-action.md`. v1.0 ships the - * (b) summary-comment shape; (c) inline-review comments deferred per - * Q4 resolution. + * Markdown PR-summary renderer for `codemap audit --json` or + * `codemap query --format sarif` output. Targets the surfaces SARIF → + * Code-Scanning doesn't cover (private repos without GHAS, aggregate + * audit deltas, bot-context seeding). v1.0 ships (b) summary-comment + * shape; (c) inline reviews deferred per Q4. Plan: + * `docs/plans/github-marketplace-action.md`. */ interface SarifResult { @@ -50,27 +44,14 @@ interface AuditEnvelope { deltas: Record; } -/** - * Rendered markdown payload + metadata. The markdown is the body to - * post; `findings_count` is the ≥0 aggregate count for consumers that - * want to skip the comment entirely when the PR is clean. - */ +/** `findings_count` lets callers skip posting on clean PRs. */ export interface RenderedComment { markdown: string; findings_count: number; - /** - * `kind` reflects the input shape — useful for downstream callers - * (e.g. action.yml step that picks a comment header based on shape). - */ kind: "audit" | "sarif" | "empty"; } -/** - * Detect input shape from a parsed JSON object. SARIF docs carry - * `runs[].tool.driver`; audit envelopes carry `deltas`. Empty objects - * (e.g. literal `{}`) treated as "empty" so consumers handle the - * "nothing to comment" case explicitly. - */ +/** SARIF → `runs[]`; audit → `deltas`; `{}` → `empty` for explicit no-data handling. */ export function detectCommentInputShape( obj: unknown, ): "audit" | "sarif" | "empty" | "unknown" { @@ -82,13 +63,7 @@ export function detectCommentInputShape( return "unknown"; } -/** - * Render an audit envelope as a markdown comment. One section per delta - * with non-zero `added.length`; collapsed-section per delta with rows - * (GitHub auto-collapses `
` blocks). Removed rows surface in - * the same delta section but in a sub-list — losing a dependency or - * deprecation tag is also signal worth seeing. - */ +/** Removed rows render in the same delta section — losing a dep / deprecation is signal too. */ export function renderAuditComment(envelope: AuditEnvelope): RenderedComment { const lines: string[] = []; lines.push("## codemap audit"); @@ -111,7 +86,6 @@ export function renderAuditComment(envelope: AuditEnvelope): RenderedComment { }; } - // Header summary line const summaryParts = deltaEntries .map(([key, delta]) => { const a = delta.added.length; @@ -166,11 +140,7 @@ export function renderAuditComment(envelope: AuditEnvelope): RenderedComment { }; } -/** - * Render a SARIF doc as a markdown comment. Groups results by ruleId so - * agents see "5 deprecated-symbols, 12 untested-and-dead" not a flat - * list. Per-result lines link to file:line where available. - */ +/** Grouped by ruleId so consumers see "5 deprecated · 12 untested-and-dead", not a flat list. */ export function renderSarifComment(doc: SarifDocument): RenderedComment { const lines: string[] = []; lines.push("## codemap findings"); @@ -186,7 +156,6 @@ export function renderSarifComment(doc: SarifDocument): RenderedComment { }; } - // Group by ruleId. const byRule = new Map(); for (const r of results) { const list = byRule.get(r.ruleId) ?? []; @@ -238,7 +207,6 @@ function describeBase(base: AuditDelta["base"]): string { } function formatRowLine(row: Record): string { - // Prefer the most-specific identity columns first. const path = (row["file_path"] as string | undefined) ?? (row["path"] as string | undefined) ?? @@ -262,7 +230,6 @@ function formatRowLine(row: Record): string { } return `\`${loc}\``; } - // Unknown shape — fall through to JSON. return `\`${JSON.stringify(row)}\``; } diff --git a/src/cli/cmd-audit.ts b/src/cli/cmd-audit.ts index 53157cd..ef51fde 100644 --- a/src/cli/cmd-audit.ts +++ b/src/cli/cmd-audit.ts @@ -13,13 +13,7 @@ import { closeDb, openDb } from "../db"; import { getProjectRoot } from "../runtime"; import { bootstrapCodemap } from "./bootstrap-codemap"; -/** - * Output formats supported by `codemap audit`. `text` is the default human - * terminal renderer; `json` matches the legacy `--json` flag's envelope; - * `sarif` emits a SARIF 2.1.0 doc per {@link formatAuditSarif} for GitHub - * Code Scanning + any SARIF-aware viewer. `--json` and `--format json` are - * equivalent; mixing `--json` with `--format ` is a parse error. - */ +/** `--json` is the back-compat shortcut for `--format json`; mixing with `--format ` is a parse error. */ export const AUDIT_OUTPUT_FORMATS = ["text", "json", "sarif"] as const; export type AuditOutputFormat = (typeof AUDIT_OUTPUT_FORMATS)[number]; @@ -44,7 +38,7 @@ export function parseAuditRest(rest: string[]): base: string | undefined; perDelta: Record; format: AuditOutputFormat; - /** `--ci` was set: SARIF + non-zero exit when any delta has additions. */ + /** `--ci`: SARIF + non-zero exit on additions. */ ci: boolean; summary: boolean; noIndex: boolean; @@ -54,12 +48,10 @@ export function parseAuditRest(rest: string[]): } let i = 1; - // `--json` and `--format json` are equivalent; track whether the user passed - // `--json` so we can reject `--json --format sarif` as a contradiction. + // Tracked separately so `--json --format sarif` can be rejected. let jsonShortcut = false; let format: AuditOutputFormat | undefined; - // `--ci` is the CI-aggregate flag: aliases `--format sarif` + non-zero - // exit-on-issue + suppresses chatty stderr. Plan: docs/plans/github-marketplace-action.md (Slice 1b). + // Aliases `--format sarif` + non-zero exit + quiet. Plan: docs/plans/github-marketplace-action.md. let ci = false; let summary = false; let noIndex = false; @@ -163,9 +155,7 @@ export function parseAuditRest(rest: string[]): }; } - // Reconcile --json / --ci / --format. `--ci` aliases `--format sarif`; mutually - // exclusive with --json (different format aliases) and with --format - // (contradicts the alias). `--ci --format sarif` is redundant but accepted. + // Precedence: --ci → sarif (rejects --json + --format ); else --json → json. let resolvedFormat: AuditOutputFormat; if (ci) { if (jsonShortcut) { @@ -343,7 +333,7 @@ export async function runAuditCmd(opts: { base: string | undefined; perDelta: Record; format: AuditOutputFormat; - /** `--ci`: exit non-zero when any delta has `added.length > 0`. */ + /** `--ci`: exit non-zero on additions. */ ci?: boolean; summary: boolean; noIndex: boolean; @@ -385,9 +375,7 @@ export async function runAuditCmd(opts: { } renderAudit(result, { format: opts.format, summary: opts.summary }); - // `--ci`: exit non-zero when any delta has additions. SARIF results - // already surfaced via Code Scanning; non-zero exit fails the runner - // step so the workflow gates the PR. + // `--ci` gates the runner step on additions (non-zero exit). if (opts.ci === true) { const hasAdditions = Object.values(result.deltas).some( (d) => d.added.length > 0, @@ -405,9 +393,7 @@ export async function runAuditCmd(opts: { } } -// Errors are JSON-shaped for any structured format (`json` / `sarif`) so -// programmatic consumers always parse the same envelope; text-mode errors -// stay on stderr for terminal users. +// Structured formats (json / sarif) emit `{"error": "..."}` on stdout for parseability. function emitAuditError(message: string, format: AuditOutputFormat) { if (format === "text") { console.error(message); @@ -422,9 +408,7 @@ function renderAudit( opts: { format: AuditOutputFormat; summary: boolean }, ): void { if (opts.format === "sarif") { - // SARIF flattens added rows across deltas. `--summary` is a no-op here: - // SARIF results are individual rows, not counts. Document this in - // --help; surface a stderr warning if both are set. + // SARIF results are per-row; `--summary` is meaningless here. if (opts.summary) { console.error( "codemap audit: --summary has no effect with --format sarif (SARIF emits one result per added row, not counts).", diff --git a/src/cli/cmd-pr-comment.ts b/src/cli/cmd-pr-comment.ts index bba7641..b1e9551 100644 --- a/src/cli/cmd-pr-comment.ts +++ b/src/cli/cmd-pr-comment.ts @@ -13,19 +13,12 @@ interface PrCommentOpts { stateDir?: string | undefined; /** Path to a JSON file. `-` reads stdin. */ inputPath: string; - /** - * Override automatic shape detection. By default we sniff `runs[]` - * vs `deltas` in the parsed payload; explicit override is for - * downstream callers that already know the shape. - */ + /** `undefined` triggers `runs[]` vs `deltas` sniffing. */ shape: "audit" | "sarif" | undefined; - /** Emit JSON envelope instead of bare markdown. Useful for action.yml. */ + /** Emit `{ markdown, findings_count, kind }` envelope; default = bare markdown. */ json: boolean; } -/** - * Print `codemap pr-comment` usage. - */ export function printPrCommentCmdHelp(): void { console.log(`Usage: codemap pr-comment [--shape audit|sarif] [--json] @@ -131,10 +124,6 @@ export function parsePrCommentRest(rest: string[]): ParsedPrCommentRest { return { kind: "run", inputPath, shape, json }; } -/** - * Initialize Codemap (so error envelopes route through the same - * `bootstrapCodemap` machinery), read the input file, render markdown. - */ export async function runPrCommentCmd(opts: PrCommentOpts): Promise { try { await bootstrapCodemap(opts); @@ -173,7 +162,6 @@ export async function runPrCommentCmd(opts: PrCommentOpts): Promise { return; } - // detected here is "audit" | "sarif" const rendered = detected === "audit" ? renderAuditComment(parsed as Parameters[0]) @@ -203,10 +191,7 @@ function emitPrCommentError(message: string, json: boolean) { process.exitCode = 1; } -/** - * Synchronous stdin reader. Bun + Node both support `node:fs` reading - * from fd 0, but it can return EAGAIN on a TTY. We loop until EOF. - */ +/** Bun + Node fd-0 reads can EAGAIN on a TTY; loop until EOF. */ function readStdinSync(): string { const chunks: Buffer[] = []; const buffer = Buffer.alloc(4096); diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index b62c59f..45daf62 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -87,7 +87,7 @@ export function parseQueryRest(rest: string[]): sql: string; json: boolean; format: OutputFormat; - /** `--ci` flag — aliases `--format sarif` + non-zero exit on findings + suppresses no-findings stderr warning. */ + /** `--ci` aliases `--format sarif` + non-zero exit + quiet. */ ci: boolean; summary: boolean; changedSince: string | undefined; @@ -115,9 +115,7 @@ export function parseQueryRest(rest: string[]): let i = 1; let json = false; let format: OutputFormat | undefined; - // `--ci` is the CI-aggregate flag: aliases `--format sarif` + non-zero - // exit-on-issue + suppresses the no-findings stderr warning. Mirrored in - // `cmd-audit.ts`. Plan: docs/plans/github-marketplace-action.md (Slice 1b). + // Aliases `--format sarif` + non-zero exit + quiet (mirrors `cmd-audit.ts`). let ci = false; let summary = false; let changedSince: string | undefined; @@ -544,13 +542,8 @@ export function parseQueryRest(rest: string[]): } /** - * Resolve the effective format. Per plan § D9, `--format` overrides `--json`; - * `--json` alone implies `--format json`; absence of both → `text`. - * - * `--ci` is the CI-aggregate flag — aliases `--format sarif`. Mutually - * exclusive with explicit `--format ` (rejected as a contradiction); - * `--ci --json` likewise rejected. `--ci` + `--format sarif` is redundant - * but accepted (consumers may set both for clarity in CI templates). + * Per plan § D9: `--format` > `--json` > default `text`. + * `--ci` aliases `--format sarif`; rejects `--json` and `--format `. */ function resolveFormat( explicit: OutputFormat | undefined, @@ -762,12 +755,7 @@ export async function runQueryCmd(opts: { * caller must reject those combos at parse time. */ format?: OutputFormat; - /** - * `--ci` flag — flips exit code to 1 when the query produces ≥1 row(s) - * and suppresses the stderr no-locatable-rows warning. Format must be - * `"sarif"` (parser enforces this — `--ci` aliases `--format sarif`); - * passing `ci: true` with another format is undefined behavior. - */ + /** `--ci`: exit 1 on ≥1 row + suppress no-locatable-rows warning. Parser enforces format=sarif. */ ci?: boolean; summary?: boolean; changedSince?: string | undefined; @@ -985,12 +973,7 @@ function printFormattedQuery( recipeId: string | undefined; changedFiles: Set | undefined; bindValues: RecipeParamValue[] | undefined; - /** - * `--ci` was set on the parser. When true: (a) suppress the - * no-locatable-rows stderr warning (CI templates would surface it - * as red noise), (b) return exit code 1 when rows.length > 0 so the - * runner step fails on findings. - */ + /** `--ci`: suppress no-locatable-rows warning + return 1 on `rows.length > 0`. */ ci?: boolean; }, ): number { @@ -1008,10 +991,8 @@ function printFormattedQuery( >[]; } - // SARIF / annotations require a location column; mermaid requires - // the from/to graph contract (checked inside formatMermaid). `--ci` - // suppresses this warning — the row-set is the gating signal under - // CI; the warning is consumer-facing dev guidance. + // SARIF / annotations need locations; mermaid validates inside formatMermaid. + // `--ci` suppresses this warning — the row-set is the gating signal under CI. if ( opts.format !== "mermaid" && rows.length > 0 && @@ -1036,8 +1017,7 @@ function printFormattedQuery( recipeBody: catalog?.body, }); console.log(out); - // `--ci`: exit non-zero when the recipe produced ≥1 finding so the - // CI runner step fails. Without `--ci`, SARIF emit is informational. + // `--ci` gates the runner step on findings (non-zero exit). return opts.ci === true && rows.length > 0 ? 1 : 0; } From f7762d0332053c8167be6400d606e5969fb344a0 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:39:41 +0300 Subject: [PATCH 07/10] fix(action): empty defaults for github-context inputs (composite actions reject expressions in input defaults) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failure on PR #74's Action smoke job: TemplateValidationException: Unrecognized named-value: 'github'. Located at position 1 within expression: github.base_ref / github.token GitHub composite actions do NOT allow ${{ github.* }} expressions in input defaults. Only `runs:` step expressions can reference the github context. Two inputs were affected: - `audit-base`: now defaults to "". The existing run step already does `BASE="${AUDIT_BASE:-$BASE_REF}"` where `BASE_REF: ${{ github.base_ref }}` is set as an env var (legal in step env blocks), so empty-input → PR base_ref behavior is preserved. - `token`: now defaults to "". Two call sites (`upload-sarif` step's `token:` arg + `pr-comment` step's `GH_TOKEN`) now use `${{ inputs.token != '' && inputs.token || github.token }}` to fall back to `github.token` when unset. Both inputs' descriptions updated to document the empty-falls-back behavior so consumers know what to expect. --- action.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/action.yml b/action.yml index cb5debc..ee95849 100644 --- a/action.yml +++ b/action.yml @@ -42,7 +42,7 @@ inputs: required: false default: "" audit-base: - description: "Git ref to audit against (when mode=audit). Default: ${{ github.base_ref }} on pull_request events." + description: "Git ref to audit against (when mode=audit). Empty (default) → falls back to `github.base_ref` on `pull_request` events; on other events the action no-ops unless an explicit `command:` is set." required: false default: "" changed-since: @@ -80,9 +80,9 @@ inputs: required: false default: "any" token: - description: "GitHub token for SARIF upload + PR comment posting. Default: ${{ github.token }}." + description: "GitHub token for SARIF upload + PR comment posting. Empty (default) → falls back to `github.token` automatically. Pass an explicit fine-grained PAT only if you need elevated permissions." required: false - default: ${{ github.token }} + default: "" outputs: agent: @@ -238,7 +238,7 @@ runs: uses: github/codeql-action/upload-sarif@v3 with: sarif_file: ${{ inputs.working-directory }}/${{ inputs.output-path }} - token: ${{ inputs.token }} + token: ${{ inputs.token != '' && inputs.token || github.token }} - name: Post PR summary comment if: steps.gate.outputs.skip != 'true' && inputs.pr-comment == 'true' && github.event_name == 'pull_request' && always() @@ -248,7 +248,7 @@ runs: EXEC: ${{ steps.detect-pm.outputs.exec }} OUTPUT_PATH: ${{ inputs.output-path }} PR_NUMBER: ${{ github.event.pull_request.number }} - GH_TOKEN: ${{ inputs.token }} + GH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }} run: | # Render the markdown body via `codemap pr-comment`, then post via # `gh pr comment`. The same binary that produced the SARIF / JSON From 33a23b191597be607a58ab88bb74bfd4121e5c33 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:45:30 +0300 Subject: [PATCH 08/10] fix: address PR #74 CodeRabbit review (6 findings, all real) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fact-checked each finding against the codebase; all valid. Two were critical correctness bugs (T2, T3, T5) that would have shipped silently broken behavior. T2 (Major) — `scripts/detect-pm.mjs` wrong package-name keys `codemapInDevDependencies` checked `manifest?.dependencies?.codemap` but the published package is `@stainless-code/codemap` — the project-installed branch was dead code for all real consumers; `dlx-pinned` / `dlx-latest` also pulled the unscoped `codemap` package (a different npm namespace entirely). Fixed: - Lookup checks both scoped (`@stainless-code/codemap`) and bare (`codemap`) keys — the latter for monorepos that alias via `"codemap": "workspace:*"`. - `'execute'` (dlx) commandArgs now use the scoped published name so npm/pnpm/yarn/bun resolve the right registry entry. - `'execute-local'` keeps `["codemap"]` because that's the bin alias per `package.json#bin`, regardless of the scoped name. - Tests updated: scoped-dev-dep / bare-dev-dep / scoped-dlx-pinned cases. Old tests that asserted dlx-with-unscoped-`codemap@` were themselves testing a bug. T3 (Critical) — no-op `expect()` in formatAuditSarif test `expect(run.results.every(...))` without a chained matcher creates and discards the expectation. If formatAuditSarif reverted to severity `note`, the test would still pass. Added `.toBe(true)`. T5 (Major) — `require()` in ESM module `readStdinSync` in `cmd-pr-comment.ts` used `require("node:fs").readSync` but the file loads via `await import()` (ESM); `require` is undefined at runtime. `codemap pr-comment -` would have crashed for every user. My unit tests passed file paths, not stdin — caught nothing. Imported `readSync` from `node:fs` at the top, used directly. T4 (Major) — pr-comment SARIF renderer dropped runs[1+] `renderSarifComment` only read `doc.runs?.[0]?.results`. Valid SARIF allows multiple runs (merged / multi-tool docs). Now flattens via `(doc.runs ?? []).flatMap(run => run.results ?? [])`. New test `aggregates results across multi-run SARIF docs (not just runs[0])`. T1 (Minor) — `mode: command` without `command:` input falls through The validate step accepted `mode=command` but didn't guard against empty `command:`. Run step's if/elif only handled `audit` + `recipe`, so `$EXEC` would invoke with empty `$ARGS`. Added an explicit guard mirroring the `mode=recipe` pattern. T6 (Minor) — `--ci` doc said "no-findings warning"; actual is "no-locatable-rows" Both `.agents/rules/codemap.md` and `templates/agents/rules/codemap.md` described the suppressed warning incorrectly. Aligned wording with the implementation in `printFormattedQuery`. --- .agents/rules/codemap.md | 2 +- action.yml | 5 ++++ scripts/detect-pm.mjs | 26 +++++++++++++------- scripts/detect-pm.test.mjs | 27 +++++++++++++++------ src/application/output-formatters.test.ts | 4 +++- src/application/pr-comment-engine.test.ts | 29 +++++++++++++++++++++++ src/application/pr-comment-engine.ts | 3 ++- src/cli/cmd-pr-comment.ts | 4 ++-- templates/agents/rules/codemap.md | 2 +- 9 files changed, 81 insertions(+), 21 deletions(-) diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md index d6562c9..8416c40 100644 --- a/.agents/rules/codemap.md +++ b/.agents/rules/codemap.md @@ -37,7 +37,7 @@ A local database (default **`.codemap/index.db`**) indexes structure: symbols, i | Impact (blast-radius walker) | — | `bun src/index.ts impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | | Coverage ingest | — | `bun src/index.ts ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | | SARIF / GH annotations | — | `bun src/index.ts query --recipe deprecated-symbols --format sarif` · `… --format annotations` | -| `--ci` aggregate flag | — | `bun src/index.ts query -r deprecated-symbols --ci` (or `audit --base origin/main --ci`) — aliases `--format sarif` + non-zero exit when findings/additions surfaced + suppresses no-findings stderr warning. Mutually exclusive with `--json` / `--format `. | +| `--ci` aggregate flag | — | `bun src/index.ts query -r deprecated-symbols --ci` (or `audit --base origin/main --ci`) — aliases `--format sarif` + non-zero exit when findings/additions surfaced + suppresses the no-locatable-rows stderr warning. Mutually exclusive with `--json` / `--format `. | | PR-comment renderer | — | `bun src/index.ts pr-comment ` (or `-` for stdin) — renders an audit JSON envelope or SARIF doc as a markdown PR-summary comment. Pipe to `gh pr comment -F -`. Useful for private repos without GHAS, aggregate audit deltas, or bot-context seeding. | | Mermaid graph (≤50 edges) | — | `bun src/index.ts query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | | Diff preview | — | `bun src/index.ts query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | diff --git a/action.yml b/action.yml index ee95849..705d95d 100644 --- a/action.yml +++ b/action.yml @@ -180,6 +180,11 @@ runs: exit 1 fi + if [ "$MODE" = "command" ] && [ -z "$COMMAND" ]; then + echo "::error::codemap action: mode='command' requires the 'command' input (raw CLI args)." + exit 1 + fi + - name: Run codemap if: steps.gate.outputs.skip != 'true' id: run diff --git a/scripts/detect-pm.mjs b/scripts/detect-pm.mjs index e7e8962..d8a97bd 100644 --- a/scripts/detect-pm.mjs +++ b/scripts/detect-pm.mjs @@ -41,13 +41,15 @@ async function main() { agent = detected?.agent ?? "npm"; } - // Per Q3's three-branch resolution (docs/plans/github-marketplace-action.md). + // Per Q3 (docs/plans/github-marketplace-action.md). `execute-local` resolves + // the `codemap` bin alias; `execute` (dlx) needs the scoped registry name. + const PUBLISHED_NAME = "@stainless-code/codemap"; let intent; let commandArgs; let installMethod; if (versionInput !== "") { intent = "execute"; - commandArgs = [`codemap@${versionInput}`]; + commandArgs = [`${PUBLISHED_NAME}@${versionInput}`]; installMethod = "dlx-pinned"; } else if (codemapInDevDependencies(workingDir)) { intent = "execute-local"; @@ -55,7 +57,7 @@ async function main() { installMethod = "project-installed"; } else { intent = "execute"; - commandArgs = ["codemap@latest"]; + commandArgs = [`${PUBLISHED_NAME}@latest`]; installMethod = "dlx-latest"; } @@ -82,16 +84,24 @@ async function main() { ); } -/** False on read errors / missing manifest. */ +// Scoped published name + bare bin name (workspace aliases use the latter). +const CODEMAP_DEP_KEYS = ["@stainless-code/codemap", "codemap"]; + function codemapInDevDependencies(workingDir) { try { const manifestPath = join(workingDir, "package.json"); if (!existsSync(manifestPath)) return false; const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - return Boolean( - manifest?.dependencies?.codemap || - manifest?.devDependencies?.codemap || - manifest?.optionalDependencies?.codemap, + const buckets = [ + manifest?.dependencies, + manifest?.devDependencies, + manifest?.optionalDependencies, + ]; + return buckets.some( + (b) => + b !== null && + b !== undefined && + CODEMAP_DEP_KEYS.some((k) => b[k] !== undefined), ); } catch { return false; diff --git a/scripts/detect-pm.test.mjs b/scripts/detect-pm.test.mjs index 7536e9c..79d55ff 100644 --- a/scripts/detect-pm.test.mjs +++ b/scripts/detect-pm.test.mjs @@ -86,30 +86,43 @@ describe("scripts/detect-pm.mjs", () => { expect(out.install_method).toBe("dlx-latest"); }); - it("uses execute-local when codemap is in devDependencies", () => { - const dir = makeFixture("dev-dep-fixture", { + it("uses execute-local when @stainless-code/codemap is in devDependencies (scoped name)", () => { + const dir = makeFixture("scoped-dev-dep-fixture", { "package.json": JSON.stringify({ - devDependencies: { codemap: "^1.0.0" }, + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, }), "package-lock.json": "{}", }); const out = runDetect({ WORKING_DIRECTORY: dir }); expect(out.agent).toBe("npm"); expect(out.install_method).toBe("project-installed"); + // bin alias is `codemap` regardless of the scoped package name expect(out.exec).toContain("codemap"); - expect(out.exec).not.toContain("codemap@"); + expect(out.exec).not.toContain("@stainless-code/codemap@"); }); - it("uses dlx-pinned when version input is set (overrides project install)", () => { + it("uses execute-local when bare `codemap` key is set (workspace alias case)", () => { + const dir = makeFixture("bare-dev-dep-fixture", { + "package.json": JSON.stringify({ + devDependencies: { codemap: "workspace:*" }, + }), + "package-lock.json": "{}", + }); + const out = runDetect({ WORKING_DIRECTORY: dir }); + expect(out.install_method).toBe("project-installed"); + }); + + it("uses dlx-pinned with scoped published name when version input is set", () => { const dir = makeFixture("pinned-fixture", { "package.json": JSON.stringify({ - devDependencies: { codemap: "^1.0.0" }, + devDependencies: { "@stainless-code/codemap": "^1.0.0" }, }), "package-lock.json": "{}", }); const out = runDetect({ WORKING_DIRECTORY: dir, VERSION: "1.2.3" }); expect(out.install_method).toBe("dlx-pinned"); - expect(out.exec).toContain("codemap@1.2.3"); + // dlx must use the scoped name so the right registry entry resolves + expect(out.exec).toContain("@stainless-code/codemap@1.2.3"); }); it("respects PACKAGE_MANAGER override", () => { diff --git a/src/application/output-formatters.test.ts b/src/application/output-formatters.test.ts index 3f425bc..368dc4a 100644 --- a/src/application/output-formatters.test.ts +++ b/src/application/output-formatters.test.ts @@ -610,7 +610,9 @@ describe("formatAuditSarif", () => { ]); expect(run.results).toHaveLength(3); // Severity = warning (audit deltas are more actionable than per-recipe `note`) - expect(run.results.every((r: { level: string }) => r.level === "warning")); + expect( + run.results.every((r: { level: string }) => r.level === "warning"), + ).toBe(true); // Locations auto-detected per row expect( run.results[0].locations[0].physicalLocation.artifactLocation.uri, diff --git a/src/application/pr-comment-engine.test.ts b/src/application/pr-comment-engine.test.ts index 06eef9f..de65f34 100644 --- a/src/application/pr-comment-engine.test.ts +++ b/src/application/pr-comment-engine.test.ts @@ -217,4 +217,33 @@ describe("renderSarifComment", () => { expect(r.findings_count).toBe(75); expect(r.markdown).toContain("… and 25 more"); }); + + it("aggregates results across multi-run SARIF docs (not just runs[0])", () => { + // SARIF spec allows multiple runs (merged / multi-tool); aggregator + // shouldn't drop entries from runs[1+]. + const r = renderSarifComment({ + version: "2.1.0", + runs: [ + { + tool: { + driver: { name: "codemap", rules: [{ id: "rule.a", name: "a" }] }, + }, + results: [{ ruleId: "rule.a", message: { text: "from run 0" } }], + }, + { + tool: { + driver: { name: "other", rules: [{ id: "rule.b", name: "b" }] }, + }, + results: [ + { ruleId: "rule.b", message: { text: "from run 1" } }, + { ruleId: "rule.b", message: { text: "another from run 1" } }, + ], + }, + ], + }); + expect(r.findings_count).toBe(3); + expect(r.markdown).toContain("from run 0"); + expect(r.markdown).toContain("from run 1"); + expect(r.markdown).toContain("another from run 1"); + }); }); diff --git a/src/application/pr-comment-engine.ts b/src/application/pr-comment-engine.ts index fa484f0..a897b05 100644 --- a/src/application/pr-comment-engine.ts +++ b/src/application/pr-comment-engine.ts @@ -146,7 +146,8 @@ export function renderSarifComment(doc: SarifDocument): RenderedComment { lines.push("## codemap findings"); lines.push(""); - const results = doc.runs?.[0]?.results ?? []; + // SARIF supports multi-run docs (merged / multi-tool); flatten so we don't under-report. + const results = (doc.runs ?? []).flatMap((run) => run.results ?? []); if (results.length === 0) { lines.push("✅ No findings."); return { diff --git a/src/cli/cmd-pr-comment.ts b/src/cli/cmd-pr-comment.ts index b1e9551..6696d5a 100644 --- a/src/cli/cmd-pr-comment.ts +++ b/src/cli/cmd-pr-comment.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, readSync } from "node:fs"; import { detectCommentInputShape, @@ -199,7 +199,7 @@ function readStdinSync(): string { while (true) { let n: number; try { - n = require("node:fs").readSync(0, buffer, 0, buffer.length, null); + n = readSync(0, buffer, 0, buffer.length, null); } catch { break; } diff --git a/templates/agents/rules/codemap.md b/templates/agents/rules/codemap.md index a795ef7..00adc30 100644 --- a/templates/agents/rules/codemap.md +++ b/templates/agents/rules/codemap.md @@ -42,7 +42,7 @@ Install **[@stainless-code/codemap](https://www.npmjs.com/package/@stainless-cod | Impact (blast-radius walker) | `codemap impact [--direction up\|down\|both] [--depth N] [--via ] [--limit N] [--summary] [--json]` — replaces hand-composed `WITH RECURSIVE` queries | | Coverage ingest | `codemap ingest-coverage [--json]` — Istanbul (`coverage-final.json`) or LCOV (`lcov.info`); format auto-detected. Joinable to `symbols` for "untested AND dead" queries. | | SARIF / GH annotations | `codemap query --recipe deprecated-symbols --format sarif` · `… --format annotations` | -| `--ci` aggregate flag | `codemap query -r deprecated-symbols --ci` (or `audit --base origin/main --ci`) — aliases `--format sarif` + non-zero exit when findings/additions surfaced + suppresses no-findings stderr warning. Mutually exclusive with `--json` / `--format `. | +| `--ci` aggregate flag | `codemap query -r deprecated-symbols --ci` (or `audit --base origin/main --ci`) — aliases `--format sarif` + non-zero exit when findings/additions surfaced + suppresses the no-locatable-rows stderr warning. Mutually exclusive with `--json` / `--format `. | | PR-comment renderer | `codemap pr-comment ` (or `-` for stdin) — renders an audit JSON envelope or SARIF doc as a markdown PR-summary comment. Pipe to `gh pr comment -F -`. Useful for private repos without GHAS, aggregate audit deltas, or bot-context seeding. | | Mermaid graph (≤50 edges) | `codemap query --format mermaid 'SELECT from_path AS "from", to_path AS "to" FROM dependencies LIMIT 50'` — recipes / SQL must alias columns to `{from, to, label?, kind?}`; rejects unbounded inputs. | | Diff preview | `codemap query --format diff ''` — read-only unified diff; `--format diff-json` returns structured hunks for agents. | From c489066e2336080232b337f2d7314373b0e5ea34 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 15:52:21 +0300 Subject: [PATCH 09/10] =?UTF-8?q?docs(research):=20mark=20=C2=A7=201.5=20b?= =?UTF-8?q?oundary-violations=20as=20shipped=20(PR=20#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `non-goals-reassessment-2026-05.md` § 1 Shipped table + § 7 Lifted-to table contradicted each other on § 1.5 — § 1 didn't list it (lifted table was last edited before PR #72), § 7 still said "(pending)". Boundary-violations shipped 2026-05 as PR #72; both tables now reflect that, plus the "Pending picks" enumeration drops § 1.5. Per Rule 8, research notes get closed/lifted but factual state may still be corrected when the codebase moves under them. This is hygiene, not extension of the analysis. --- docs/research/non-goals-reassessment-2026-05.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/research/non-goals-reassessment-2026-05.md b/docs/research/non-goals-reassessment-2026-05.md index 5fb2687..6717b0f 100644 --- a/docs/research/non-goals-reassessment-2026-05.md +++ b/docs/research/non-goals-reassessment-2026-05.md @@ -31,8 +31,9 @@ The original capability inventory contained 10 rows. Items that have shipped sin | 2.2 Visualisation flip → output formatter | `--format mermaid` (above); SARIF + annotations were the precedent | Same | | 1.10 rename-preview + parametrised recipes | `params:` frontmatter + `--params key=value` CLI / `query_recipe.params` MCP/HTTP; `find-symbol-by-kind.sql` exemplar; `rename-preview.sql` recipe; `--format diff` / `diff-json` formatters | [`README.md § CLI`](../../README.md#cli), `templates/recipes/find-symbol-by-kind.{sql,md}`, `templates/recipes/rename-preview.{sql,md}` (PR #71) | | § 6 Q1 daemon-default flip | `mcp` / `serve` watcher default-ON; `--no-watch` and `CODEMAP_WATCH=0` opt-outs | [`architecture.md § Watch wiring`](../architecture.md#cli-usage), [`docs/glossary.md`](../glossary.md) | +| 1.5 Boundary violations | `boundaries` config + `boundary_rules` table + bundled `boundary-violations` recipe; SARIF auto-detects via `from_path` location column | [`architecture.md § Schema`](../architecture.md#schema), `templates/recipes/boundary-violations.{sql,md}` (PR #72) | -Pending picks (§ 1.1, 1.2, 1.4, 1.5, 1.6, 1.9, plus § 5 (b) and (d)) moved to canonical homes — see [§ 7](#7-lifted-to). +Pending picks (§ 1.1, 1.2, 1.4, 1.6, 1.9, plus § 5 (b) and (d)) moved to canonical homes — see [§ 7](#7-lifted-to). --- @@ -140,7 +141,7 @@ The lift trail — for future archaeologists asking "where did this idea / decis | § 1.7 Mermaid output (shipped) | `templates/recipes/` + `README.md § CLI` | PR #59 (FTS5 + Mermaid) | | § 1.3 cyclomatic complexity (shipped) | `architecture.md § Schema` + `templates/recipes/high-complexity-untested.{sql,md}` | PR #70 | | § 1.8 MCP resources (shipped) | `templates/agents/skills/codemap/SKILL.md` + `docs/glossary.md` | post-PR #35 follow-ups | -| § 1.5 boundary violations (pending) | [`roadmap.md § Backlog`](../roadmap.md#backlog) (inline rationale) | docs-capability-sync Slice 5c | +| § 1.5 boundary violations (shipped) | `templates/recipes/boundary-violations.{sql,md}` + `boundaries` config field + `boundary_rules` table | PR #72 | | § 1.10 rename-preview + parametrised recipes (shipped) | `templates/recipes/find-symbol-by-kind.{sql,md}` + `templates/recipes/rename-preview.{sql,md}` + `--format diff` / `diff-json` formatters | PR #71 | | § 1.9 recipe-recency (pending) | [`roadmap.md § Backlog`](../roadmap.md#backlog) (inline rationale) | docs-capability-sync Slice 5c | | § 1.6 unused type members (advisory; pending) | [`roadmap.md § Backlog`](../roadmap.md#backlog) (inline rationale) | docs-capability-sync Slice 5c | From 80b575b0d110a4b6d56d75816f92b03ecbeff6a8 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 5 May 2026 16:01:17 +0300 Subject: [PATCH 10/10] docs(plan): consolidate Slice 5 runbook for later pickup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5 (publish + Marketplace listing) is post-merge work — anyone should be able to pick it up without triangulating across the plan's Q7 release-workflow + Q10 listing-metadata + Slice-5 one-liner. Added a "Slice 5 runbook (post-merge)" subsection that: - Surfaces the CLI / Action version stream decoupling explicitly: Action ships at v1 against CLI 0.5.0 (the version PR #74's changeset bumps to). CLI v1.0.0 is not required. - Lists 7 sequenced executable steps from "merge PR #74" through to "delete this plan per Rule 3" (the canonical close-out per docs-governance). - Calls out which docs already hold the durable design decisions (architecture.md / glossary.md / agent rules / MARKETPLACE.md) so the deletion in step 7 is safe. - Documents subsequent-release cadence (changesets-driven; force-push the floating v tag) and the major-bump policy. --- docs/plans/github-marketplace-action.md | 49 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/plans/github-marketplace-action.md b/docs/plans/github-marketplace-action.md index 006f5f0..3ef7bd4 100644 --- a/docs/plans/github-marketplace-action.md +++ b/docs/plans/github-marketplace-action.md @@ -233,7 +233,54 @@ Per [`tracer-bullets`](../../.agents/rules/tracer-bullets.md) — ship one verti 2. **Slice 2: `action.yml` minimum.** Composite action with `command` + `working-directory` inputs only. Steps: detect package-manager (per Q2), install codemap (per Q3 — project-installed first, `'execute'` fallback), run ` `, upload SARIF artifact. Smoke-test via `act` or a sacrificial branch. End-to-end: PR opens → Action runs `codemap audit --base ${{ github.base_ref }} --ci` → artifact uploaded. 3. **Slice 3: PR-comment writer (Q4 (b) summary only).** New `src/cli/cmd-pr-comment.ts`: takes SARIF or audit JSON, emits markdown summary. Action's optional final step calls it + posts via `gh pr comment`. Toggle via `pr-comment: true` Action input (default **`false`** for v1.0 — opt-in to avoid duplicating Code Scanning surfaces for users who already have GHAS). Default may flip in v1.x if usage shows the comment is universally expected. 4. **Slice 4: dogfood on this repo.** Wire the published Action (or a local-path action ref during dev) into `.github/workflows/ci.yml`. The PR adding the Action's first release runs it on itself — eat-our-own-dogfood verifies the wrapper end-to-end before any external consumer sees it. -5. **Slice 5: publish + Marketplace listing.** Tag `v1.0.0`, push fast-forward `@v1`, fill listing metadata (icon, description, tags). Verify discoverability. Update `README.md § CI` to lead with the Action. Update agent rule + skill (per [Rule 10](../README.md)) so agents recommending codemap CI integration cite the Action first. +5. **Slice 5: publish + Marketplace listing.** Tag `v1.0.0`, push fast-forward `@v1`, fill listing metadata (icon, description, tags). Verify discoverability. Update `README.md § CI` to lead with the Action. Update agent rule + skill (per [Rule 10](../README.md)) so agents recommending codemap CI integration cite the Action first. Detailed runbook below. + +### Slice 5 runbook (post-merge — anyone can pick up) + +Slices 1-4 land in PR #74; Slice 5 is a sequenced manual runbook that requires a merged commit + access to the Marketplace publishing UI. + +**Pre-condition: CLI / Action version stream decoupling (per Q7).** The Action's `@v1` and the CLI's npm version live in separate namespaces — Action publishes at `v1.0.0` independent of CLI version. The Action's default invocation does ` dlx codemap@latest`, so the CLI just needs the new `--format sarif` (audit) + `--ci` flags landed on npm. Earliest CLI version with both is `0.5.0` (changesets bumps from `0.4.0` for the new minor when PR #74 merges). **CLI v1.0.0 is not required** for Action v1.0.0. + +**Steps:** + +1. Merge PR #74. +2. Changesets release workflow on `main` runs automatically → publishes CLI `0.5.0` (or whatever the bump resolves to) to npm. +3. From the merge commit, tag git `v1.0.0`: + ```bash + git tag -a v1.0.0 -m "GitHub Marketplace Action v1" + git push origin v1.0.0 + ``` +4. Force-push the floating `v1` tag to the same commit: + ```bash + git tag -f v1 v1.0.0 + git push --force origin v1 + ``` +5. **One-time Marketplace listing setup** (per Q10 checklist): + - Pick icon (reuse codemap brand asset if exists; else pick during this step). + - Brand colour: GitHub Marketplace palette. + - Tags: `code-quality`, `static-analysis`, `code-search`, `code-intelligence`. **Avoid `linter`** (Moat-A "no opinionated rule engine"). + - Description (≤150 chars per Marketplace constraint): "SQL-queryable structural index of your codebase. Run any predicate as a recipe; CI gating via SARIF → Code Scanning." + - README: point Marketplace at `MARKETPLACE.md` (action-focused) rather than the codemap-CLI root README. Author `MARKETPLACE.md` in this step if it doesn't exist. + - Discipline: listing copy respects [`plan-pr-inspiration-discipline`](../../.agents/rules/plan-pr-inspiration-discipline.md) (no peer-tool comparisons in the listing) + aligns with [`docs/why-codemap.md`](../why-codemap.md). +6. Smoke-test on a sacrificial public repo: `- uses: stainless-code/codemap@v1` in `.github/workflows/`; trigger a PR; verify SARIF surfaces in Code Scanning. +7. Follow-up PR (post-publish): + - Update `README.md § CI` to lead with the Action. + - Flip `action-smoke` CI job from `continue-on-error: true` to a hard gate (Action now publishes a v1 with the right CLI flags, so the smoke meaningfully validates them). + - Per [`docs/README.md` Rule 3](../README.md), **delete `docs/plans/github-marketplace-action.md`**. Durable design decisions already live in: + - `docs/architecture.md` § "PR-comment wiring" + § "Audit wiring" + § "Output formatters" (CLI + engine seams) + - `docs/glossary.md` (`--ci` + `pr-comment` entries) + - `.agents/rules/codemap.md` + `templates/agents/rules/codemap.md` (agent surface) + - `MARKETPLACE.md` (consumer-facing) + - Remove the GitHub Marketplace Action backlog entry from `roadmap.md` per Rule 2. + +**Subsequent releases:** changesets-driven (existing pipeline). On every merge to `main` that bumps `package.json`: + +- Tag `v..` at the merge commit. +- Force-push `v` floating tag to the same commit (`git tag -f v && git push --force origin v`). +- npm publish runs automatically. +- Marketplace auto-syncs from the same git tag — no separate publish step. + +**Major bump (`v1.x.y → v2.0.0`):** create a new `v2` floating tag at the breaking-change commit; `v1` stops moving. Backports to `v1.x.y` are not promised (single-major-supported policy per Q7). ---