diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md index 9648606..8416c40 100644 --- a/.agents/rules/codemap.md +++ b/.agents/rules/codemap.md @@ -12,34 +12,36 @@ 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` | +| `--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. | +| 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/.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] 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 new file mode 100644 index 0000000..705d95d --- /dev/null +++ b/action.yml @@ -0,0 +1,269 @@ +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). 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: + 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. Empty (default) → falls back to `github.token` automatically. Pass an explicit fine-grained PAT only if you need elevated permissions." + required: false + default: "" + +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 + + 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 + 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 + + 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 != '' && 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() + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + EXEC: ${{ steps.detect-pm.outputs.exec }} + OUTPUT_PATH: ${{ inputs.output-path }} + PR_NUMBER: ${{ github.event.pull_request.number }} + 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 + # 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/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/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/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). --- 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 | 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..d8a97bd --- /dev/null +++ b/scripts/detect-pm.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * Action pre-step. Resolves package manager + codemap CLI invocation; + * writes to `$GITHUB_OUTPUT` (`::set-output` deprecated 2022-10). + * + * 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: `agent` / `exec` (shell-ready) / `install_method` (debug breadcrumb). + * + * 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(); + + 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"; + } + + // 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 = [`${PUBLISHED_NAME}@${versionInput}`]; + installMethod = "dlx-pinned"; + } else if (codemapInDevDependencies(workingDir)) { + intent = "execute-local"; + commandArgs = ["codemap"]; + installMethod = "project-installed"; + } else { + intent = "execute"; + commandArgs = [`${PUBLISHED_NAME}@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(" "); + + const outputFile = process.env["GITHUB_OUTPUT"]; + if (outputFile === undefined || outputFile === "") { + // Local / non-Actions invocation: dump to stdout. + console.log(`agent=${agent}`); + console.log(`exec=${exec}`); + console.log(`install_method=${installMethod}`); + return; + } + appendFileSync( + outputFile, + `agent=${agent}\nexec=${exec}\ninstall_method=${installMethod}\n`, + ); +} + +// 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")); + 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; + } +} + +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..79d55ff --- /dev/null +++ b/scripts/detect-pm.test.mjs @@ -0,0 +1,165 @@ +/** + * 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 @stainless-code/codemap is in devDependencies (scoped name)", () => { + const dir = makeFixture("scoped-dev-dep-fixture", { + "package.json": JSON.stringify({ + 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("@stainless-code/codemap@"); + }); + + 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: { "@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"); + // 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", () => { + 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"); + }); +}); diff --git a/src/application/output-formatters.test.ts b/src/application/output-formatters.test.ts index d5433ec..368dc4a 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,106 @@ 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"), + ).toBe(true); + // 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..2a38939 100644 --- a/src/application/output-formatters.ts +++ b/src/application/output-formatters.ts @@ -167,6 +167,87 @@ export function formatSarif(opts: FormatOpts): string { return JSON.stringify(sarif, null, 2); } +/** Removed rows intentionally excluded — SARIF surfaces findings to act on, not cleanups. */ +export interface AuditSarifDelta { + key: string; + added: Record[]; +} + +/** + * 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) => ({ + 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); + // 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 + ? `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/application/pr-comment-engine.test.ts b/src/application/pr-comment-engine.test.ts new file mode 100644 index 0000000..de65f34 --- /dev/null +++ b/src/application/pr-comment-engine.test.ts @@ -0,0 +1,249 @@ +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"); + }); + + 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 new file mode 100644 index 0000000..a897b05 --- /dev/null +++ b/src/application/pr-comment-engine.ts @@ -0,0 +1,248 @@ +/** + * 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 { + 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; +} + +/** `findings_count` lets callers skip posting on clean PRs. */ +export interface RenderedComment { + markdown: string; + findings_count: number; + kind: "audit" | "sarif" | "empty"; +} + +/** SARIF → `runs[]`; audit → `deltas`; `{}` → `empty` for explicit no-data handling. */ +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"; +} + +/** 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"); + 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", + }; + } + + 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", + }; +} + +/** 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"); + lines.push(""); + + // 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 { + markdown: lines.join("\n"), + findings_count: 0, + kind: "sarif", + }; + } + + 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 { + 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}\``; + } + 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-audit.test.ts b/src/cli/cmd-audit.test.ts index b83bad8..3a3373f 100644 --- a/src/cli/cmd-audit.test.ts +++ b/src/cli/cmd-audit.test.ts @@ -44,7 +44,8 @@ describe("parseAuditRest", () => { baselinePrefix: "base", base: undefined, perDelta: {}, - json: false, + format: "text", + ci: false, summary: false, noIndex: false, }); @@ -57,7 +58,8 @@ describe("parseAuditRest", () => { baselinePrefix: "base", base: undefined, perDelta: {}, - json: false, + format: "text", + ci: false, summary: false, noIndex: false, }); @@ -78,7 +80,8 @@ describe("parseAuditRest", () => { baselinePrefix: undefined, base: undefined, perDelta: { files: "X", dependencies: "Y", deprecated: "Z" }, - json: false, + format: "text", + ci: false, summary: false, noIndex: false, }); @@ -97,13 +100,14 @@ describe("parseAuditRest", () => { baselinePrefix: "base", base: undefined, perDelta: { dependencies: "experimental-deps" }, - json: false, + format: "text", + ci: false, 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 +117,108 @@ 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("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"); @@ -162,7 +262,8 @@ describe("parseAuditRest", () => { baselinePrefix: undefined, base: "origin/main", perDelta: {}, - json: false, + format: "text", + ci: false, summary: false, noIndex: false, }); diff --git a/src/cli/cmd-audit.ts b/src/cli/cmd-audit.ts index daa67dc..ef51fde 100644 --- a/src/cli/cmd-audit.ts +++ b/src/cli/cmd-audit.ts @@ -6,11 +6,17 @@ 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"; +/** `--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]; + // 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 +27,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 +37,9 @@ export function parseAuditRest(rest: string[]): baselinePrefix: string | undefined; base: string | undefined; perDelta: Record; - json: boolean; + format: AuditOutputFormat; + /** `--ci`: SARIF + non-zero exit on additions. */ + ci: boolean; summary: boolean; noIndex: boolean; } { @@ -40,7 +48,11 @@ export function parseAuditRest(rest: string[]): } let i = 1; - let json = false; + // Tracked separately so `--json --format sarif` can be rejected. + let jsonShortcut = false; + let format: AuditOutputFormat | undefined; + // Aliases `--format sarif` + non-zero exit + quiet. Plan: docs/plans/github-marketplace-action.md. + let ci = false; let summary = false; let noIndex = false; let baselinePrefix: string | undefined; @@ -51,10 +63,28 @@ 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 === "--ci") { + ci = 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 +155,44 @@ export function parseAuditRest(rest: string[]): }; } + // Precedence: --ci → sarif (rejects --json + --format ); else --json → json. + let resolvedFormat: AuditOutputFormat; + 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", + 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, + ci, summary, noIndex, }; @@ -212,10 +274,18 @@ ${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":""}. + --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). --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 +332,9 @@ export async function runAuditCmd(opts: { baselinePrefix: string | undefined; base: string | undefined; perDelta: Record; - json: boolean; + format: AuditOutputFormat; + /** `--ci`: exit non-zero on additions. */ + ci?: boolean; summary: boolean; noIndex: boolean; }): Promise { @@ -298,32 +370,61 @@ 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 }); + + // `--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, + ); + if (hasAdditions) process.exitCode = 1; + } } 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 { +// Structured formats (json / sarif) emit `{"error": "..."}` on stdout for parseability. +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 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).", + ); + } + 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 +447,8 @@ function renderAudit( } return; } + + // format === "text" renderAuditTerminal(envelope, opts.summary); } diff --git a/src/cli/cmd-pr-comment.ts b/src/cli/cmd-pr-comment.ts new file mode 100644 index 0000000..6696d5a --- /dev/null +++ b/src/cli/cmd-pr-comment.ts @@ -0,0 +1,210 @@ +import { readFileSync, readSync } 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; + /** `undefined` triggers `runs[]` vs `deltas` sniffing. */ + shape: "audit" | "sarif" | undefined; + /** Emit `{ markdown, findings_count, kind }` envelope; default = bare markdown. */ + json: boolean; +} + +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 }; +} + +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; + } + + 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; +} + +/** Bun + Node fd-0 reads can EAGAIN on a TTY; 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 = 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/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..45daf62 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` aliases `--format sarif` + non-zero exit + quiet. */ + ci: boolean; summary: boolean; changedSince: string | undefined; recipeId: string | undefined; @@ -113,6 +115,8 @@ export function parseQueryRest(rest: string[]): let i = 1; let json = false; let format: OutputFormat | undefined; + // Aliases `--format sarif` + non-zero exit + quiet (mirrors `cmd-audit.ts`). + let ci = false; let summary = false; let changedSince: string | undefined; let recipeId: string | undefined; @@ -135,6 +139,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 +458,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 +474,7 @@ export function parseQueryRest(rest: string[]): sql, json, format: resolved, + ci, summary, changedSince, recipeId, @@ -501,7 +514,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 +530,7 @@ export function parseQueryRest(rest: string[]): sql, json, format: resolved, + ci, summary, changedSince, recipeId: undefined, @@ -525,13 +542,27 @@ export function parseQueryRest(rest: string[]): } /** - * Resolve the effective format. Per plan § D9, `--format` overrides `--json`; - * `--json` alone implies `--format json`; absence of both → `text`. + * Per plan § D9: `--format` > `--json` > default `text`. + * `--ci` aliases `--format sarif`; rejects `--json` and `--format `. */ 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 +755,8 @@ export async function runQueryCmd(opts: { * caller must reject those combos at parse time. */ format?: OutputFormat; + /** `--ci`: exit 1 on ≥1 row + suppress no-locatable-rows warning. Parser enforces format=sarif. */ + ci?: boolean; summary?: boolean; changedSince?: string | undefined; recipeId?: string | undefined; @@ -821,6 +854,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 +973,8 @@ function printFormattedQuery( recipeId: string | undefined; changedFiles: Set | undefined; bindValues: RecipeParamValue[] | undefined; + /** `--ci`: suppress no-locatable-rows warning + return 1 on `rows.length > 0`. */ + ci?: boolean; }, ): number { let db: Awaited> | undefined; @@ -955,12 +991,13 @@ function printFormattedQuery( >[]; } - // SARIF / annotations require a location column; mermaid requires - // the from/to graph contract (checked inside formatMermaid). + // 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 && - !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 +1017,8 @@ function printFormattedQuery( recipeBody: catalog?.body, }); console.log(out); - return 0; + // `--ci` gates the runner step on findings (non-zero exit). + 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 1fa38ae..7ae179a 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -274,13 +274,37 @@ 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, + ci: parsed.ci, summary: parsed.summary, noIndex: parsed.noIndex, }); 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, @@ -361,6 +385,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, diff --git a/templates/agents/rules/codemap.md b/templates/agents/rules/codemap.md index 2b39fd9..00adc30 100644 --- a/templates/agents/rules/codemap.md +++ b/templates/agents/rules/codemap.md @@ -18,35 +18,37 @@ 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` | +| `--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. | +| 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.