Skip to content

Commit c32f052

Browse files
feat(query): Tier A flags (--summary, --changed-since, --group-by) + per-row recipe actions (#26)
## Summary Implements the four Tier A items from [docs/research/fallow.md § Tier A](https://github.com/stainless-code/codemap/blob/main/docs/research/fallow.md). Pure CLI surface — no schema bump, no breaking changes to the `cm.query()` programmatic API, no changes to `templates/agents/`. Four commits, reviewable independently: 1. **A.4 — `--summary`** — counts only, skips row emission 2. **A.2 — `--changed-since <ref>`** — PR-scoped row filter via `git diff <ref>...HEAD ∪ git status` 3. **A.1 — per-row `actions` on recipes** — agent-facing follow-up hints in `--json` output 4. **A.3 — `--group-by owner|directory|package`** — partition rows by CODEOWNERS / first directory segment / workspace package ## What's new ### Flags | Flag | What it does | Pairs with | | --- | --- | --- | | `--summary` | Print only the row count (`{"count": N}` with `--json`, `count: N` without). With `--group-by`, output collapses to `{group_by, groups: [{key, count}]}`. | `--recipe`, ad-hoc SQL, `--changed-since`, `--group-by` | | `--changed-since <ref>` | Post-filter rows by `path` / `file_path` / `from_path` / `to_path` / `resolved_path` against `git diff --name-only <ref>...HEAD ∪ git status --porcelain`. Bad refs surface a clean `{"error":"…"}` and exit 1. Rows with no recognised path column pass through. | All other flags | | `--group-by <mode>` | `owner` (CODEOWNERS first owner, last-match-wins), `directory` (first path segment), `package` (workspace dir from `package.json` `workspaces` or `pnpm-workspace.yaml` `packages:`; out-of-workspace → `<root>`). Emits `{group_by, groups: [{key, count, rows}]}` with `--json`. | All other flags | ### Per-row recipe `actions` Recipes can now declare an `actions: RecipeAction[]` template that gets appended to every row in `--json` output. Recipe-only and CLI-only — programmatic `cm.query(sql)` and ad-hoc CLI SQL never carry actions. Initial templates: - `fan-out` → `review-coupling` - `fan-in` → `review-stability` - `files-largest` → `split-file` - `deprecated-symbols` → `flag-caller` - `visibility-tags` → `flag-non-public` - `barrel-files` → `split-barrel` Aggregation recipes (`index-summary`, `markers-by-kind`) intentionally omit actions — counts-by-kind have no per-row target. ## What's not in this PR - `actions` on the programmatic `cm.query(sql)` surface (would couple application layer to `cli/query-recipes`; intentional boundary). - B.5 `codemap audit --base <ref>` — bigger design surface; warrants `docs/plans/<name>.md` first per the research note. - Suppression comments / per-rule severity / `fallow fix`-style mutator — explicit non-goals per `docs/research/fallow.md § Defer / skip`. ## Test plan - [x] `bun run check` passes locally (build, format:check, lint:ci, test, typecheck, test:golden — all 19 golden scenarios green; the test count grew with the new helpers). - [x] Smoke tests against this clone: - `query --json --summary -r deprecated-symbols` → `{"count": N}` - `query --json --changed-since HEAD~3 "SELECT path FROM files"` → only paths in the diff - `query --json --changed-since not-a-real-ref -r fan-out` → clean `{"error":"--changed-since: cannot resolve …"}` - `query --json -r fan-out` → each row carries `actions: [{type:"review-coupling", description:"…"}]` - `query --json --summary --group-by directory -r fan-in` → `{"group_by":"directory","groups":[{"key":"src","count":15}]}` - `query --json --group-by owner -r fan-in` → clean error in repos without CODEOWNERS - [x] Golden runner uses `cm.query()` (programmatic API) — confirms `actions` enrichment doesn't leak into the public surface. - [x] Patch changeset added per `.agents/lessons.md` "changesets bump policy (pre-v1)" — additive features default to patch in `0.x`. - [ ] CI green.
1 parent 8bb8aa7 commit c32f052

13 files changed

Lines changed: 1292 additions & 34 deletions

.changeset/tier-a-query-flags.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
`codemap query` Tier A flags — `--summary`, `--changed-since <ref>`, `--group-by owner|directory|package`, plus per-row `actions` templates on bundled recipes. All output filters; the SQL still executes against the index. Ad-hoc SQL and the `cm.query()` programmatic API stay unchanged.

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ codemap query "SELECT name, file_path FROM symbols LIMIT 10"
7676
# Bundled SQL (same as skill examples): fan-out rankings
7777
codemap query --json --recipe fan-out
7878
codemap query --json --recipe fan-out-sample
79+
# Counts only (skip the rows) — pairs well with --recipe for dashboards / agent context windows
80+
codemap query --json --summary -r deprecated-symbols
81+
# PR-scoped: filter result rows to those touching files changed since <ref>
82+
codemap query --json --changed-since origin/main -r fan-out
83+
codemap query --json --summary --changed-since HEAD~5 "SELECT file_path FROM symbols"
84+
# Group rows by directory, CODEOWNERS owner, or workspace package
85+
codemap query --json --summary --group-by directory -r fan-in
86+
codemap query --json --group-by owner -r deprecated-symbols
87+
codemap query --json --summary --group-by package "SELECT file_path FROM symbols"
88+
# Recipes that define per-row action templates append "actions" hints (kebab-case verb +
89+
# description) in --json output; ad-hoc SQL never carries actions. Inspect via --recipes-json.
7990
# List bundled recipes as JSON, or print one recipe's SQL (no DB required)
8091
codemap query --recipes-json
8192
codemap query --print-sql fan-out

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ A local SQLite database (`.codemap.db`) indexes the project tree and stores stru
117117

118118
**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).
119119

120-
**Query wiring:** **`src/cli/cmd-query.ts`** (argv, **`printQueryResult`**, `--recipe` / `-r` alias), **`src/cli/query-recipes.ts`** (**`QUERY_RECIPES`** — bundled SQL only source), **`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.db`" — `no such table: <X>` and `no such column: <X>` errors are rewritten in **`enrichQueryError`** to point at `codemap` / `codemap --full`. 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)).
120+
**Query wiring:** **`src/cli/cmd-query.ts`** (argv, **`printQueryResult`**, `--recipe` / `-r` alias, **`--summary`**, **`--changed-since`**, **`--group-by`**), **`src/cli/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.db`" — `no such table: <X>` and `no such column: <X>` 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 <ref>`** post-filters result rows by `path` / `file_path` / `from_path` / `to_path` / `resolved_path` against `git diff --name-only <ref>...HEAD ∪ git status --porcelain` (helper: **`src/git-changed.ts`** — `getFilesChangedSince`, `filterRowsByChangedFiles`, `PATH_COLUMNS`); rows with no recognised path column pass through. **`--group-by <mode>`** (`owner` | `directory` | `package`) routes through **`runGroupedQuery`** in `cmd-query.ts` and emits `{"group_by": "<mode>", "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:`. **Per-row recipe `actions`** are appended only when the user runs **`--recipe <id>`** with **`--json`** AND the recipe defines an `actions` template — programmatic `cm.query(sql)` and ad-hoc CLI SQL never carry actions. 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)).
121121

122122
**Validate wiring:** **`src/cli/cmd-validate.ts`****`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`.
123123

src/application/index-engine.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
SCHEMA_VERSION,
3131
} from "../db";
3232
import type { CodemapDatabase, FileRow } from "../db";
33+
import { filterRowsByChangedFiles } from "../git-changed";
3334
import { globSync } from "../glob-sync";
3435
import { hashContent } from "../hash";
3536
import { extractMarkers } from "../markers";
@@ -527,19 +528,52 @@ export async function targetedReindex(
527528
/**
528529
* Run read-only SQL and print results to stdout (`console.table`, or JSON when `opts.json`).
529530
* Does not throw on invalid SQL: prints an error and returns **1** (CLI-style). With **`json`**, errors are printed as **`{"error":"<message>"}`** on stdout.
531+
*
532+
* When `opts.summary` is true, only the row count is emitted — `{"count": N}` with `--json`,
533+
* `count: N` otherwise. The SQL still executes against the index; `--summary` filters output, not work.
534+
*
535+
* When `opts.changedFiles` is provided, rows are post-filtered to those whose path columns
536+
* (`path`, `file_path`, `from_path`, `to_path`, `resolved_path`) match at least one entry.
537+
* Rows with no recognised path column pass through (the filter cannot decide; pair with `--summary`
538+
* if the count of changed-touching rows is what's wanted).
539+
*
540+
* When `opts.recipeActions` is provided AND `opts.json` is true, each row gets an `actions`
541+
* key set to the same template (recipe-only feature; ad-hoc SQL never carries actions).
542+
* Rows that already define their own `actions` column are not overwritten.
530543
* @returns **0** on success, **1** on SQL/runtime error.
531544
*/
532545
export function printQueryResult(
533546
sql: string,
534-
opts?: { json?: boolean },
547+
opts?: {
548+
json?: boolean;
549+
summary?: boolean;
550+
changedFiles?: Set<string> | undefined;
551+
recipeActions?: ReadonlyArray<unknown> | undefined;
552+
},
535553
): number {
536554
const json = opts?.json === true;
555+
const summary = opts?.summary === true;
556+
const changedFiles = opts?.changedFiles;
557+
const recipeActions = opts?.recipeActions;
537558
let db: CodemapDatabase | undefined;
538559
try {
539560
db = openDb();
540-
const rows = db.query(sql).all();
541-
if (json) {
542-
console.log(JSON.stringify(rows));
561+
let rows = db.query(sql).all();
562+
if (changedFiles !== undefined) {
563+
rows = filterRowsByChangedFiles(rows, changedFiles);
564+
}
565+
if (summary) {
566+
if (json) {
567+
console.log(JSON.stringify({ count: rows.length }));
568+
} else {
569+
console.log(`count: ${rows.length}`);
570+
}
571+
} else if (json) {
572+
const enriched =
573+
recipeActions !== undefined && recipeActions.length > 0
574+
? rows.map((row) => attachRecipeActions(row, recipeActions))
575+
: rows;
576+
console.log(JSON.stringify(enriched));
543577
} else if (rows.length === 0) {
544578
console.log("(no results)");
545579
} else {
@@ -561,6 +595,19 @@ export function printQueryResult(
561595
}
562596
}
563597

598+
// Append the recipe's action template to a row without overwriting a pre-existing
599+
// `actions` column from the SQL itself (recipe authors should never collide, but
600+
// defensive: keep the SQL output authoritative).
601+
function attachRecipeActions(
602+
row: unknown,
603+
actions: ReadonlyArray<unknown>,
604+
): unknown {
605+
if (typeof row !== "object" || row === null) return row;
606+
const obj = row as Record<string, unknown>;
607+
if ("actions" in obj) return obj;
608+
return { ...obj, actions };
609+
}
610+
564611
/**
565612
* Rewrites raw SQLite errors that almost always indicate a missing or empty
566613
* `.codemap.db` into an actionable hint. Other errors are returned unchanged.

src/cli.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@ describe("CLI --help", () => {
3636
expect(err).toBe("");
3737
});
3838

39-
test("query --help exits 0 and documents --json", async () => {
39+
test("query --help exits 0 and documents --json + --summary + --group-by", async () => {
4040
const { exitCode, out, err } = await runCli(["query", "--help"]);
4141
expect(exitCode).toBe(0);
4242
expect(out).toContain("--json");
43+
expect(out).toContain("--summary");
44+
expect(out).toContain("--changed-since");
45+
expect(out).toContain("--group-by");
4346
expect(out).toContain("--recipe");
4447
expect(out).toContain("fan-out");
4548
expect(out).toContain("codemap query");

0 commit comments

Comments
 (0)