diff --git a/.changeset/affected-tests-recipe.md b/.changeset/affected-tests-recipe.md new file mode 100644 index 00000000..aaaa0a4f --- /dev/null +++ b/.changeset/affected-tests-recipe.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Add `affected-tests` recipe and `codemap affected` CLI for reverse-dependency test selection from changed files. diff --git a/README.md b/README.md index acb50aad..bc0fe37f 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,13 @@ codemap impact runWatchLoop --json --summary | jq '.summary.nodes' # CI-gate fa # (default 3, --depth 0 = unbounded), limit-capped (default 500). Result envelope: # {target, matches: [{depth, edge, kind, name?, file_path}], summary: {nodes, terminated_by}}. +# Affected tests — reverse dependency walk from changed sources → test files to run +codemap affected --json # working-tree changes vs HEAD (git status + diff) +git diff --name-only origin/main | codemap affected --stdin --json +codemap affected src/lib/cache.ts --json # explicit changed paths +codemap affected --changed-since origin/main --json # committed delta + working tree vs ref +# Moat-A twin: `affected-tests` recipe. Output: [{test_path, impact_depth}] — CI composes the runner command. + # Apply — substrate-shaped fix executor (recipe SQL describes hunks; codemap validates + writes) codemap apply rename-preview --params old=usePermissions,new=useAccess,kind=function --dry-run codemap apply rename-preview --params old=usePermissions,new=useAccess,kind=function --yes # TTY prompts without --yes diff --git a/docs/plans/affected-tests-recipe.md b/docs/plans/affected-tests-recipe.md index 8e6080a0..7f28b982 100644 --- a/docs/plans/affected-tests-recipe.md +++ b/docs/plans/affected-tests-recipe.md @@ -10,12 +10,12 @@ ## Pre-locked decisions -| # | Decision | Source | -| --- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| L.1 | **Moat-A clean** — `affected-tests` recipe + optional CLI alias `codemap affected` (outcome alias cap: 5 total — audit alias budget). | [Moat A](../roadmap.md#moats-load-bearing) | -| L.2 | Algorithm: reverse BFS on `dependencies` from changed files → filter test paths via `test_suites.file_path` and configurable globs. | Uses existing substrate | -| L.3 | **Stdin support** — accept changed paths from `git diff --name-only` (same ergonomics as CI scripts). | CLI ergonomics | -| L.4 | Not a verdict — output is file paths only; CI composes exit policy. | Moat A | +| # | Decision | Source | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | +| L.1 | **Moat-A clean** — `affected-tests` recipe satisfies the agent surface; **`query_recipe`** is the MCP/HTTP path. Optional dedicated CLI verb **`codemap affected`** for CI (`stdin` / git path discovery) — not a 6th outcome alias. | [Moat A](../roadmap.md#moats-load-bearing) | +| L.2 | Algorithm: reverse BFS on `dependencies` from changed files → filter test paths via `test_suites.file_path` and configurable globs. | Uses existing substrate | +| L.3 | **Stdin support** — accept changed paths from `git diff --name-only` (same ergonomics as CI scripts). | CLI ergonomics | +| L.4 | Not a verdict — output is file paths only; CI composes exit policy. | Moat A | --- @@ -37,31 +37,37 @@ --- -## CLI alias +## CLI verb (CI) ```bash -codemap affected --json # git diff vs HEAD +codemap affected --json # working tree vs HEAD git diff --name-only origin/main | codemap affected --stdin --json ``` -Implement in `src/cli/aliases.ts` if alias budget allows; else recipe-only with documented shell wrapper. +Dedicated `cmd-affected.ts` (not an outcome alias — 5-alias cap unchanged). Shipped as **`codemap affected`**, not `aliases.ts`. + +## Agent surface (Moat A) + +No dedicated MCP tool required — agents call **`query_recipe`** with `recipe: "affected-tests"` and `params.changed_files` (ASCII RS between paths when multiple). The recipe is the Moat-A substrate; the CLI verb is CI ergonomics only. --- ## Implementation steps 1. Recipe SQL + frontmatter + golden query fixture -2. CLI stdin handling in `cmd-query` or dedicated thin `cmd-affected.ts` +2. CLI stdin handling in dedicated `cmd-affected.ts` 3. Document test-file conventions in recipe `.md` 4. Optional GitHub Action input `mode: affected` in [github-marketplace-action](./github-marketplace-action.md) (follow-up) +**Out of scope:** dedicated MCP/HTTP `affected` tool (same outcome reachable via `query_recipe`; revisit only if agent eval shows friction). + --- ## Acceptance -- [ ] Recipe returns test file paths for a known fixture delta -- [ ] Stdin mode works in shell pipeline -- [ ] Documented in README + skill +- [x] Recipe returns test file paths for a known fixture delta +- [x] Stdin mode works in shell pipeline +- [x] Documented in README + skill --- diff --git a/docs/plans/agent-surface-delivery.md b/docs/plans/agent-surface-delivery.md index bfdee901..89bd402e 100644 --- a/docs/plans/agent-surface-delivery.md +++ b/docs/plans/agent-surface-delivery.md @@ -10,11 +10,11 @@ ## Quick resume -| Next action | Detail | -| -------------------- | ----------------------------------------------------------------------------------- | -| **Review / merge** | PR 4 — trace recipes ([#131](https://github.com/stainless-code/codemap/pull/131)) | -| **Start next** | **PR 5** — `affected-tests-recipe` or **PR 6** — MCP trace tools (after PR 4 lands) | -| **Do not start yet** | PR 6 until PR 4 lands; PR 9 (eval harness) until PR 8 | +| Next action | Detail | +| -------------------- | ---------------------------------------------------------------------------------- | +| **Review / merge** | PR 5 — affected tests ([#132](https://github.com/stainless-code/codemap/pull/132)) | +| **Start next** | **PR 6** — MCP trace tools (`trace` / `explore` / `node`) | +| **Do not start yet** | PR 9 (eval harness) until PR 8 | Update the table below when a PR merges or a new branch opens. @@ -38,8 +38,8 @@ Max **3 parallel tracks** at once. | PR | Plans | Status | Blocked by | Parallel with | | ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | **3** | [`index-lock-and-error-log`](./index-lock-and-error-log.md) → [`parse-worker-hardening`](./parse-worker-hardening.md) (stack) | merged | [#129](https://github.com/stainless-code/codemap/pull/129), [#130](https://github.com/stainless-code/codemap/pull/130) | 4, 5 | -| **4** | Recipe half of [`mcp-trace-explore-tools`](./mcp-trace-explore-tools.md) (`call-path`, `symbol-neighborhood` SQL + tests) | open | [#131](https://github.com/stainless-code/codemap/pull/131) | 3, 5 | -| **5** | [`affected-tests-recipe`](./affected-tests-recipe.md) | planned | — | 3, 4 | +| **4** | Recipe half of [`mcp-trace-explore-tools`](./mcp-trace-explore-tools.md) (`call-path`, `symbol-neighborhood` SQL + tests) | merged | [#131](https://github.com/stainless-code/codemap/pull/131) | 3, 5 | +| **5** | [`affected-tests-recipe`](./affected-tests-recipe.md) | open | [#132](https://github.com/stainless-code/codemap/pull/132) | 3, 4 | | **6** | MCP half of trace (`trace` / `explore` / `node` tools) + update instructions | planned | PR 1, PR 4 | — | | **7** | [`field-qualified-search`](./field-qualified-search.md) | planned | PR 1 | 4, 5 if `mcp-server.ts` untouched | | **8** | [`agents-init-mcp-wiring`](./agents-init-mcp-wiring.md) | planned | PR 1 | 3–5 | diff --git a/fixtures/golden/minimal/affected-tests-complexity.json b/fixtures/golden/minimal/affected-tests-complexity.json new file mode 100644 index 00000000..d1aa117f --- /dev/null +++ b/fixtures/golden/minimal/affected-tests-complexity.json @@ -0,0 +1,6 @@ +[ + { + "test_path": "src/__tests__/smoke.test.ts", + "impact_depth": 1 + } +] diff --git a/fixtures/golden/minimal/affected-tests-direct.json b/fixtures/golden/minimal/affected-tests-direct.json new file mode 100644 index 00000000..ef9ae9b3 --- /dev/null +++ b/fixtures/golden/minimal/affected-tests-direct.json @@ -0,0 +1,6 @@ +[ + { + "test_path": "src/__tests__/smoke.test.ts", + "impact_depth": 0 + } +] diff --git a/fixtures/golden/minimal/affected-tests-max-depth-0.json b/fixtures/golden/minimal/affected-tests-max-depth-0.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/fixtures/golden/minimal/affected-tests-max-depth-0.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/golden/minimal/affected-tests-multi-file.json b/fixtures/golden/minimal/affected-tests-multi-file.json new file mode 100644 index 00000000..ef9ae9b3 --- /dev/null +++ b/fixtures/golden/minimal/affected-tests-multi-file.json @@ -0,0 +1,6 @@ +[ + { + "test_path": "src/__tests__/smoke.test.ts", + "impact_depth": 0 + } +] diff --git a/fixtures/golden/minimal/affected-tests-no-tests.json b/fixtures/golden/minimal/affected-tests-no-tests.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/fixtures/golden/minimal/affected-tests-no-tests.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/golden/minimal/affected-tests-test-glob.json b/fixtures/golden/minimal/affected-tests-test-glob.json new file mode 100644 index 00000000..d1aa117f --- /dev/null +++ b/fixtures/golden/minimal/affected-tests-test-glob.json @@ -0,0 +1,6 @@ +[ + { + "test_path": "src/__tests__/smoke.test.ts", + "impact_depth": 1 + } +] diff --git a/fixtures/golden/scenarios.json b/fixtures/golden/scenarios.json index 05258c6f..bf148cb6 100644 --- a/fixtures/golden/scenarios.json +++ b/fixtures/golden/scenarios.json @@ -182,6 +182,50 @@ "recipe": "symbol-neighborhood", "params": { "name": "nonexistentSymbol", "depth": 1 } }, + { + "id": "affected-tests-complexity", + "prompt": "Test file importing changed complexity fixture", + "recipe": "affected-tests", + "params": { "changed_files": "src/lib/complexity-fixture.ts" } + }, + { + "id": "affected-tests-no-tests", + "prompt": "Changed file with no test importer returns empty", + "recipe": "affected-tests", + "params": { "changed_files": "src/api/client.ts" } + }, + { + "id": "affected-tests-direct", + "prompt": "Changed test file is included at depth 0", + "recipe": "affected-tests", + "params": { "changed_files": "src/__tests__/smoke.test.ts" } + }, + { + "id": "affected-tests-max-depth-0", + "prompt": "max_depth=0 excludes transitive test importers", + "recipe": "affected-tests", + "params": { + "changed_files": "src/lib/complexity-fixture.ts", + "max_depth": 0 + } + }, + { + "id": "affected-tests-test-glob", + "prompt": "test_glob replaces default suffix globs when no match", + "recipe": "affected-tests", + "params": { + "changed_files": "src/lib/complexity-fixture.ts", + "test_glob": "*.spec.ts" + } + }, + { + "id": "affected-tests-multi-file", + "prompt": "Multi-file RS input uses MIN impact_depth across seeds", + "recipe": "affected-tests", + "params": { + "changed_files": "src/lib/complexity-fixture.ts\u001esrc/__tests__/smoke.test.ts" + } + }, { "id": "find-async-functions", "prompt": "Parametrised recipe: list async function-shaped symbols with return_type", diff --git a/src/cli/bootstrap.ts b/src/cli/bootstrap.ts index 4864f6b4..6152156e 100644 --- a/src/cli/bootstrap.ts +++ b/src/cli/bootstrap.ts @@ -56,6 +56,9 @@ Targeted reads (precise lookup by symbol name): Impact analysis (graph walk for refactor blast-radius): codemap impact [--direction up|down|both] [--depth N] [--via ] [--limit N] [--summary] [--json] +Affected tests (reverse dep walk → test files to run): + codemap affected [--stdin] [--changed-since ] [--json] [...] + Apply (substrate-shaped fix executor; consumes the diff-json row contract): codemap apply [--params k=v[,k=v]] [--dry-run] [--yes] [--json] @@ -127,6 +130,7 @@ export function validateIndexModeArgs(rest: string[]): void { if (rest[0] === "show") return; if (rest[0] === "snippet") return; if (rest[0] === "impact") return; + if (rest[0] === "affected") return; if (rest[0] === "apply") return; if (rest[0] === "ingest-coverage") return; if (rest[0] === "pr-comment") return; diff --git a/src/cli/cmd-affected.test.ts b/src/cli/cmd-affected.test.ts new file mode 100644 index 00000000..1f9f7c48 --- /dev/null +++ b/src/cli/cmd-affected.test.ts @@ -0,0 +1,210 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { + CHANGED_PATH_DELIM, + joinChangedPaths, + parseAffectedRest, +} from "./cmd-affected"; + +const repoRoot = join(import.meta.dir, "..", ".."); +const indexTs = join(repoRoot, "src", "index.ts"); +const minimalRoot = join(repoRoot, "fixtures", "minimal"); +let bunBin: string | null = null; + +async function runCli( + args: string[], + opts: { + env?: Record; + stdin?: string; + } = {}, +): Promise<{ exitCode: number; out: string; err: string }> { + if (bunBin === null) { + throw new Error("cmd-affected.test: bunBin not initialised by beforeAll."); + } + const proc = Bun.spawn([bunBin, indexTs, ...args], { + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + stdin: opts.stdin === undefined ? "ignore" : "pipe", + env: { ...process.env, ...opts.env }, + }); + if (opts.stdin !== undefined) { + const stdin = proc.stdin; + if (stdin === undefined) { + throw new Error( + "cmd-affected.test: expected pipe stdin on spawned process.", + ); + } + stdin.write(opts.stdin); + stdin.end(); + } + const exitCode = await proc.exited; + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + return { exitCode, out, err }; +} + +beforeAll(() => { + bunBin = Bun.which("bun"); + if (!bunBin || !existsSync(indexTs)) { + throw new Error( + `cmd-affected.test: cannot locate Bun (${bunBin}) or src entry (${indexTs}).`, + ); + } +}); + +describe("joinChangedPaths", () => { + it("joins unique trimmed paths with RS delimiter", () => { + expect( + joinChangedPaths([ + "src/a.ts", + "./src/b.ts", + "src/a.ts", + "", + " src/c.ts ", + ]), + ).toBe(["src/a.ts", "src/b.ts", "src/c.ts"].join(CHANGED_PATH_DELIM)); + }); +}); + +describe("parseAffectedRest", () => { + it("returns help on --help / -h", () => { + expect(parseAffectedRest(["affected", "--help"]).kind).toBe("help"); + expect(parseAffectedRest(["affected", "-h"]).kind).toBe("help"); + }); + + it("parses defaults with no path source flags", () => { + expect(parseAffectedRest(["affected"])).toEqual({ + kind: "run", + stdin: false, + changedSince: undefined, + positionalPaths: [], + testGlob: undefined, + maxDepth: undefined, + json: false, + }); + }); + + it("parses --stdin, --json, --changed-since, and --params", () => { + expect( + parseAffectedRest([ + "affected", + "--stdin", + "--json", + "--changed-since", + "origin/main", + "--params", + "test_glob=*.test.ts,max_depth=12", + ]), + ).toEqual({ + kind: "run", + stdin: true, + changedSince: "origin/main", + positionalPaths: [], + testGlob: "*.test.ts", + maxDepth: 12, + json: true, + }); + }); + + it("parses positional paths", () => { + expect( + parseAffectedRest(["affected", "./src/a.ts", "src/b.ts", "--json"]), + ).toEqual({ + kind: "run", + stdin: false, + changedSince: undefined, + positionalPaths: ["src/a.ts", "src/b.ts"], + testGlob: undefined, + maxDepth: undefined, + json: true, + }); + }); + + it("rejects positional paths combined with --stdin", () => { + const r = parseAffectedRest(["affected", "--stdin", "src/a.ts"]); + expect(r.kind).toBe("error"); + }); + + it("rejects changed_files in --params", () => { + const r = parseAffectedRest([ + "affected", + "--params", + "changed_files=src/a.ts", + ]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toMatch(/changed_files/); + }); + + it("rejects --changed-since without ref", () => { + const r = parseAffectedRest(["affected", "--changed-since"]); + expect(r.kind).toBe("error"); + }); + + it("rejects invalid max_depth in --params", () => { + const r = parseAffectedRest([ + "affected", + "--params", + "max_depth=not-a-number", + ]); + expect(r.kind).toBe("error"); + }); +}); + +describe("codemap affected — fixtures/minimal e2e", () => { + beforeAll(async () => { + const idx = await runCli(["--full"], { + env: { CODEMAP_ROOT: minimalRoot }, + }); + expect(idx.exitCode).toBe(0); + }, 120_000); + + it("returns transitive test file for a changed source path", async () => { + const r = await runCli( + ["affected", "src/lib/complexity-fixture.ts", "--json"], + { env: { CODEMAP_ROOT: minimalRoot } }, + ); + expect(r.exitCode).toBe(0); + const rows = JSON.parse(r.out) as Array<{ + test_path: string; + impact_depth: number; + actions?: unknown[]; + }>; + expect(rows).toEqual([ + { + test_path: "src/__tests__/smoke.test.ts", + impact_depth: 1, + actions: [ + { + type: "run-affected-tests", + description: + "Test file paths only — CI composes the exit policy and runner command.", + }, + ], + }, + ]); + }); + + it("reads changed paths from stdin pipeline", async () => { + const r = await runCli(["affected", "--stdin", "--json"], { + env: { CODEMAP_ROOT: minimalRoot }, + stdin: "src/lib/complexity-fixture.ts\n", + }); + expect(r.exitCode).toBe(0); + const rows = JSON.parse(r.out) as Array<{ test_path: string }>; + expect(rows.map((row) => row.test_path)).toEqual([ + "src/__tests__/smoke.test.ts", + ]); + }); + + it("returns empty array when stdin has no paths", async () => { + const r = await runCli(["affected", "--stdin", "--json"], { + env: { CODEMAP_ROOT: minimalRoot }, + stdin: "\n\n", + }); + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.out)).toEqual([]); + }); +}); diff --git a/src/cli/cmd-affected.ts b/src/cli/cmd-affected.ts new file mode 100644 index 00000000..c34be45d --- /dev/null +++ b/src/cli/cmd-affected.ts @@ -0,0 +1,333 @@ +import { stdin as input } from "node:process"; + +import { executeQuery } from "../application/query-engine"; +import { + getQueryRecipeActions, + getQueryRecipeParams, + getQueryRecipeSql, +} from "../application/query-recipes"; +import { resolveRecipeParams } from "../application/recipe-params"; +import { getFilesChangedSince } from "../git-changed"; +import { getProjectRoot } from "../runtime"; +import { bootstrapCodemap } from "./bootstrap-codemap"; + +/** Delimiter for `affected-tests.changed_files` (ASCII RS). */ +export const CHANGED_PATH_DELIM = "\u001e"; + +export interface AffectedOpts { + root: string; + configFile: string | undefined; + stateDir?: string | undefined; + changedPaths: string[]; + testGlob: string | undefined; + maxDepth: number | undefined; + json: boolean; +} + +/** + * Join project-relative paths for the `affected-tests` recipe param. + * Filters empty segments; preserves order of first occurrence. + */ +export function joinChangedPaths(paths: Iterable): string { + const seen = new Set(); + const out: string[] = []; + for (const raw of paths) { + const path = raw.trim().replace(/^\.\/+/, ""); + if (path.length === 0 || seen.has(path)) continue; + seen.add(path); + out.push(path); + } + return out.join(CHANGED_PATH_DELIM); +} + +/** + * Read newline-delimited paths from stdin (ignores empty lines). + */ +export async function readChangedPathsFromStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of input) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const text = Buffer.concat(chunks).toString("utf8"); + return text + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\.\/+/, "")) + .filter((line) => line.length > 0); +} + +export function printAffectedCmdHelp(): void { + console.log(`Usage: codemap affected [--stdin] [--changed-since ] [--params k=v] [--json] [...] + +List test files transitively impacted by changed source files (reverse BFS on +\`dependencies\`). Output is file paths only — CI composes the runner command. + +Path sources (first match wins): + 1. Positional ... arguments + 2. --stdin newline-delimited paths from a pipe + 3. --changed-since git diff + working tree vs + 4. (default) same as --changed-since HEAD + +Flags: + --params key=value Pass recipe params (repeatable). Supported: test_glob, + max_depth. changed_files is built automatically. + --json Emit JSON array of {test_path, impact_depth}. + --help, -h Show this help. + +Examples: + codemap affected --json + git diff --name-only origin/main | codemap affected --stdin --json + codemap affected src/lib/cache.ts --json + codemap affected --params test_glob='src/**/__tests__/*' --json +`); +} + +export function parseAffectedRest(rest: string[]): + | { kind: "help" } + | { kind: "error"; message: string } + | { + kind: "run"; + stdin: boolean; + changedSince: string | undefined; + positionalPaths: string[]; + testGlob: string | undefined; + maxDepth: number | undefined; + json: boolean; + } { + if (rest[0] !== "affected") { + throw new Error("parseAffectedRest: expected affected"); + } + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help" }; + } + + let stdin = false; + let changedSince: string | undefined; + let testGlob: string | undefined; + let maxDepth: number | undefined; + let json = false; + const positionalPaths: string[] = []; + + for (let i = 1; i < rest.length; i++) { + const a = rest[i]; + if (a === "--stdin") { + stdin = true; + continue; + } + if (a === "--json") { + json = true; + continue; + } + if (a === "--changed-since") { + const next = rest[i + 1]; + if (next === undefined || next.startsWith("-")) { + return { + kind: "error", + message: + 'codemap affected: "--changed-since" requires a git ref (e.g. origin/main).', + }; + } + changedSince = next; + i++; + continue; + } + if (a === "--params") { + const next = rest[i + 1]; + if (next === undefined || next.startsWith("-")) { + return { + kind: "error", + message: 'codemap affected: "--params" requires key=value.', + }; + } + for (const part of next.split(",")) { + const eq = part.indexOf("="); + const key = eq === -1 ? part : part.slice(0, eq); + const value = eq === -1 ? "" : part.slice(eq + 1); + if (key === "test_glob") testGlob = value; + else if (key === "max_depth") { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) { + return { + kind: "error", + message: `codemap affected: --params max_depth="${value}" must be a non-negative number.`, + }; + } + maxDepth = n; + } else if (key === "changed_files") { + return { + kind: "error", + message: + "codemap affected: changed_files is built from stdin/git/positional paths — omit from --params.", + }; + } else if (key.length > 0) { + return { + kind: "error", + message: `codemap affected: unknown --params key "${key}" (supported: test_glob, max_depth).`, + }; + } + } + i++; + continue; + } + if (a.startsWith("-")) { + return { + kind: "error", + message: `codemap affected: unknown option "${a}". Run \`codemap affected --help\` for usage.`, + }; + } + positionalPaths.push(a.trim().replace(/^\.\/+/, "")); + } + + if (stdin && positionalPaths.length > 0) { + return { + kind: "error", + message: "codemap affected: pass positional paths OR --stdin, not both.", + }; + } + + return { + kind: "run", + stdin, + changedSince, + positionalPaths, + testGlob, + maxDepth, + json, + }; +} + +async function resolveChangedPaths(opts: { + root: string; + stdin: boolean; + changedSince: string | undefined; + positionalPaths: string[]; +}): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> { + if (opts.positionalPaths.length > 0) { + return { ok: true, paths: opts.positionalPaths }; + } + if (opts.stdin) { + return { ok: true, paths: await readChangedPathsFromStdin() }; + } + const ref = opts.changedSince ?? "HEAD"; + const result = getFilesChangedSince(ref, opts.root); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, paths: [...result.files] }; +} + +/** + * Run `codemap affected`. Bootstraps, executes the `affected-tests` recipe. + */ +export async function runAffectedCmd(opts: AffectedOpts): Promise { + try { + await bootstrapCodemap(opts); + + const changedRaw = joinChangedPaths(opts.changedPaths); + if (changedRaw.length === 0) { + if (opts.json) { + console.log("[]"); + } else { + console.log("(no changed files — no affected tests)"); + } + return; + } + + const declared = getQueryRecipeParams("affected-tests"); + const resolved = resolveRecipeParams({ + recipeId: "affected-tests", + declared, + provided: { + changed_files: changedRaw, + ...(opts.testGlob !== undefined ? { test_glob: opts.testGlob } : {}), + ...(opts.maxDepth !== undefined ? { max_depth: opts.maxDepth } : {}), + }, + }); + if (!resolved.ok) { + throw new Error(resolved.error); + } + + const sql = getQueryRecipeSql("affected-tests"); + if (sql === undefined) { + throw new Error( + 'codemap affected: bundled recipe "affected-tests" missing', + ); + } + + const payload = executeQuery({ + sql, + bindValues: resolved.values, + root: getProjectRoot(), + recipeActions: getQueryRecipeActions("affected-tests"), + }); + + if ( + payload !== null && + typeof payload === "object" && + !Array.isArray(payload) && + "error" in payload + ) { + throw new Error(String((payload as { error: string }).error)); + } + + const rows = payload as unknown[]; + + if (opts.json) { + console.log(JSON.stringify(rows)); + return; + } + + if (rows.length === 0) { + console.log("(no affected test files)"); + return; + } + + for (const row of rows) { + if (typeof row === "object" && row !== null && "test_path" in row) { + const r = row as { test_path: string; impact_depth?: number }; + const depth = + r.impact_depth === undefined ? "" : `\t(depth ${r.impact_depth})`; + console.log(`${r.test_path}${depth}`); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (opts.json) { + console.log(JSON.stringify({ error: message })); + } else { + console.error(message); + } + process.exitCode = 1; + } +} + +export async function runAffectedFromParsed(opts: { + root: string; + configFile: string | undefined; + stateDir?: string | undefined; + parsed: Extract, { kind: "run" }>; +}): Promise { + const pathsResult = await resolveChangedPaths({ + root: opts.root, + stdin: opts.parsed.stdin, + changedSince: opts.parsed.changedSince, + positionalPaths: opts.parsed.positionalPaths, + }); + if (!pathsResult.ok) { + if (opts.parsed.json) { + console.log(JSON.stringify({ error: pathsResult.error })); + } else { + console.error(pathsResult.error); + } + process.exitCode = 1; + return; + } + + await runAffectedCmd({ + root: opts.root, + configFile: opts.configFile, + stateDir: opts.stateDir, + changedPaths: pathsResult.paths, + testGlob: opts.parsed.testGlob, + maxDepth: opts.parsed.maxDepth, + json: opts.parsed.json, + }); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index b86c8c33..8a553ef7 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -322,6 +322,27 @@ Copies bundled agent templates into .agents/ under the project root. return; } + if (rest[0] === "affected") { + const { parseAffectedRest, printAffectedCmdHelp, runAffectedFromParsed } = + await import("./cmd-affected.js"); + const parsed = parseAffectedRest(rest); + if (parsed.kind === "help") { + printAffectedCmdHelp(); + return; + } + if (parsed.kind === "error") { + console.error(parsed.message); + process.exit(1); + } + await runAffectedFromParsed({ + root, + configFile, + stateDir, + parsed, + }); + return; + } + if (rest[0] === "apply") { const { parseApplyRest, printApplyCmdHelp, runApplyCmd } = await import("./cmd-apply.js"); diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index 6c9aa54c..920c4441 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -49,6 +49,14 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are - **`impact`** — `{target, direction?, via?, depth?, limit?, summary?}`. Symbol/file blast-radius walker (replaces hand-composed `WITH RECURSIVE`). Auto-resolves symbol vs file target; `via` defaults to every backend compatible with the kind. - **`apply`** — `{recipe, params?, dry_run?, yes?}`. Executes the diff hunks a recipe row produces (`{file_path, line_start, before_pattern, after_pattern}`). **All-or-nothing**: any conflict aborts before any file is written. Over MCP/HTTP `yes: true` is required for the write path; `dry_run` and `yes` are mutually exclusive. +**Affected tests (CLI-first):** **`codemap affected`** — reverse `dependencies` walk from changed files to test paths; primary consumer is CI/shell (`stdin`, `--changed-since`). Thin composer over the bundled **`affected-tests`** recipe. **Agents (MCP/HTTP):** use **`query_recipe`** with `recipe: "affected-tests"` and `changed_files` (RS-delimited paths when multiple). Path sources for the CLI verb: positional args → `--stdin` → `--changed-since ` → default `HEAD` (working tree via `git status` + `HEAD...HEAD` diff). Example: + +```bash +codemap affected --json +git diff --name-only origin/main | codemap affected --stdin --json +codemap query --json --recipe affected-tests --params changed_files=src/foo.ts +``` + **Resources** — same URI set over MCP **and** HTTP (`GET /resources/{encoded-uri}` against `codemap serve`); shared `readResource()` handler so bodies are identical. Catalog / static resources lazy-cache once per process; recipes + per-file / per-symbol lookups read live so inline recency fields stay fresh. - **`codemap://recipes`** — full catalog (same as `--recipes-json`). Each row carries `source: "bundled" | "project"`, optional `shadows: true`, plus `last_run_at` / `run_count` recency fields. diff --git a/templates/recipes/affected-tests.md b/templates/recipes/affected-tests.md new file mode 100644 index 00000000..4bcf05df --- /dev/null +++ b/templates/recipes/affected-tests.md @@ -0,0 +1,37 @@ +--- +params: + - name: changed_files + type: string + required: true + description: Project-relative changed paths joined with ASCII RS (char 30); built by `codemap affected`. + - name: test_glob + type: string + required: false + description: Optional SQLite GLOB on files.path; when set, replaces default suffix globs (test_suites always included). + - name: max_depth + type: number + required: false + default: 50 + description: Maximum reverse-dependency hops from each changed file (0 = only directly-changed test files). +actions: + - type: run-affected-tests + description: "Test file paths only — CI composes the exit policy and runner command." +--- + +# affected-tests + +Reverse BFS on `dependencies` from changed source files → test files that transitively import them (or match test detection). + +**Test detection:** always includes indexed `test_suites.file_path`. When `test_glob` is omitted, also matches default `*.test.*` / `*.spec.*` suffix globs (SQLite `GLOB`; `*` matches `/`). When `test_glob` is set, it **replaces** those default suffix globs (not additive). Empty `test_glob=""` disables suffix matching but keeps `test_suites`. + +**Depth:** `impact_depth` 0 = the test file itself changed. `max_depth=0` on a non-test source returns `[]`. Expansion runs while `depth < max_depth` (same sentinel as `codemap impact`). + +**Not a verdict** — output is file paths + hop depth; CI decides whether to run, skip, or fail. + +```bash +codemap affected --json +git diff --name-only origin/main | codemap affected --stdin --json +codemap query --recipe affected-tests --params changed_files=src/lib/complexity-fixture.ts --json +``` + +Each row: `test_path`, `impact_depth`. diff --git a/templates/recipes/affected-tests.sql b/templates/recipes/affected-tests.sql new file mode 100644 index 00000000..664ee104 --- /dev/null +++ b/templates/recipes/affected-tests.sql @@ -0,0 +1,95 @@ +WITH RECURSIVE +params(changed_raw, test_glob, max_depth) AS ( + SELECT ?, ?, COALESCE(?, 50) +), +split(value, remainder) AS ( + SELECT + CASE + WHEN instr(p.changed_raw, char(30)) > 0 THEN substr( + p.changed_raw, + 1, + instr(p.changed_raw, char(30)) - 1 + ) + ELSE p.changed_raw + END, + CASE + WHEN instr(p.changed_raw, char(30)) > 0 THEN substr( + p.changed_raw, + instr(p.changed_raw, char(30)) + 1 + ) + ELSE '' + END + FROM params p + UNION ALL + SELECT + CASE + WHEN instr(s.remainder, char(30)) > 0 THEN substr( + s.remainder, + 1, + instr(s.remainder, char(30)) - 1 + ) + ELSE s.remainder + END, + CASE + WHEN instr(s.remainder, char(30)) > 0 THEN substr( + s.remainder, + instr(s.remainder, char(30)) + 1 + ) + ELSE '' + END + FROM split s + WHERE length(s.remainder) > 0 +), +changed_files(path) AS ( + SELECT DISTINCT trim(value) AS path + FROM split + WHERE length(trim(value)) > 0 +), +impact_walk(file_path, depth, visited) AS ( + SELECT cf.path, 0, char(30) || cf.path || char(30) + FROM changed_files cf + UNION ALL + SELECT + d.from_path, + iw.depth + 1, + iw.visited || d.from_path || char(30) + FROM dependencies d + JOIN impact_walk iw ON d.to_path = iw.file_path + CROSS JOIN params p + WHERE iw.depth < p.max_depth + AND instr(iw.visited, char(30) || d.from_path || char(30)) = 0 +), +test_files(path) AS ( + SELECT DISTINCT f.path + FROM files f + CROSS JOIN params p + WHERE EXISTS ( + SELECT 1 + FROM test_suites ts + WHERE ts.file_path = f.path + ) + OR ( + p.test_glob IS NOT NULL + AND f.path GLOB p.test_glob + ) + OR ( + p.test_glob IS NULL + AND ( + f.path GLOB '*.test.ts' + OR f.path GLOB '*.test.tsx' + OR f.path GLOB '*.spec.ts' + OR f.path GLOB '*.spec.tsx' + OR f.path GLOB '*.test.js' + OR f.path GLOB '*.spec.js' + OR f.path GLOB '*.test.jsx' + OR f.path GLOB '*.spec.jsx' + ) + ) +) +SELECT + tf.path AS test_path, + MIN(iw.depth) AS impact_depth +FROM impact_walk iw +JOIN test_files tf ON tf.path = iw.file_path +GROUP BY tf.path +ORDER BY impact_depth ASC, tf.path ASC;